Set up a webhook and verify signatures
Receive new leak and scan events at your own address instead of polling. Every delivery is signed; your receiver verifies the signature to know the body came from Dark Radar unchanged.
Set the address
A webhook is tied to a protection resource group, one per group. It must be https and publicly reachable; private networks, localhost and cloud metadata addresses are rejected. The key needs the webhooks:write scope.
curl -X PUT "https://api.darkradar.com/api/public/v1/webhooks/64b1f0c2a9e4d21f8c3b9a10" \
-H "x-api-key: $DARKRADAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://ornek.com.tr/darkradar-webhook","events":["leak.created","scan.failed"],"severities":["critical","high"]}'201 and contains signingSecret (whsec_…). The secret is shown only in this response; store it safely. If you lose it, get a new one with POST /v1/webhooks/{groupId}/rotate-secret — the old one stops working at once.How the signature is made
Every delivery carries X-DarkRadar-Signature: t=<unix seconds>,v1=<signature>. The signature is the HMAC-SHA256 (hex) of t, a dot and the raw body, keyed with your secret.
t is older than 5 minutes; this blocks replays of old deliveries.X-DarkRadar-Event carries the event type and X-DarkRadar-Delivery the delivery id, same as id in the body.Node.js
import crypto from 'node:crypto';
// rawBody: raw request body (Buffer or string). header: X-DarkRadar-Signature.
export function darkradarSignatureValid(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(String(header ?? '').split(',').map((p) => p.split('=')).filter((p) => p.length === 2));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1, 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
// Express: app.post('/darkradar', express.raw({ type: 'application/json' }), (req, res) => {
// if (!darkradarSignatureValid(req.body, req.get('X-DarkRadar-Signature'), process.env.DARKRADAR_WEBHOOK_SECRET)) return res.sendStatus(401);
// const event = JSON.parse(req.body); /* deduplicate by event.id */ res.sendStatus(204);
// });Python
import hashlib
import hmac
import time
# raw_body: raw request body (bytes). header: X-DarkRadar-Signature.
def darkradar_signature_valid(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
if not parts.get("t", "").isdigit() or "v1" not in parts:
return False
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
# Flask:
# if not darkradar_signature_valid(request.get_data(), request.headers.get("X-DarkRadar-Signature", ""), os.environ["DARKRADAR_WEBHOOK_SECRET"]):
# abort(401)
# event = request.get_json() # deduplicate by event["id"]Ruby
require "openssl"
# raw_body: raw request body. header: X-DarkRadar-Signature.
def darkradar_signature_valid?(raw_body, header, secret, tolerance: 300)
parts = header.to_s.split(",").map { |p| p.split("=", 2) }.select { |p| p.size == 2 }.to_h
t = Integer(parts["t"], exception: false)
return false if t.nil? || parts["v1"].nil? || (Time.now.to_i - t).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
OpenSSL.fixed_length_secure_compare(expected, parts["v1"])
rescue ArgumentError
false
end
# Rails:
# head :unauthorized unless darkradar_signature_valid?(request.raw_post, request.headers["X-DarkRadar-Signature"], ENV.fetch("DARKRADAR_WEBHOOK_SECRET"))
# event = JSON.parse(request.raw_post) # deduplicate by event["id"]PHP
<?php
// $rawBody: raw request body. $header: X-DarkRadar-Signature.
function darkradar_signature_valid(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $p) {
$kv = explode('=', $p, 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
$t = (int) $parts['t'];
if (abs(time() - $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// Plain PHP:
// $raw = file_get_contents('php://input');
// if (!darkradar_signature_valid($raw, $_SERVER['HTTP_X_DARKRADAR_SIGNATURE'] ?? '', getenv('DARKRADAR_WEBHOOK_SECRET'))) { http_response_code(401); exit; }
// $event = json_decode($raw, true); // deduplicate by $event['id']Test and monitor
A test delivery sends a signed webhook.test event and returns your receiver’s response. The receiver must return 2xx; a request not answered within 10 seconds counts as failed.
curl -X POST "https://api.darkradar.com/api/public/v1/webhooks/64b1f0c2a9e4d21f8c3b9a10/test" \ -H "x-api-key: $DARKRADAR_API_KEY"
On a transient failure (429, 5xx, network error) a delivery is tried 3 times at once, then again after 1 min, 5 min, 30 min, 2 h and 6 h — an event is not lost if your receiver is down for a few hours. 4xx is not retried. The same delivery can arrive more than once: de-duplicate on id in the body. The last 100 deliveries, including pending ones, are listed by GET /v1/webhooks/{groupId}/deliveries.