Breach Alert Webhooks
A webhook alert channel POSTs a JSON event to a URL you control whenever a monitored breach or scan event happens, in addition to (or instead of) email/Slack alerts.
Setting up a channel
From Dashboard → Settings → Alerts, or via the API:
{
"channel": "webhook",
"isEnabled": true,
"config": {
"url": "https://example.com/hooks/cykanner",
"secret": "whsec_your_own_random_string"
},
"notifyOnNewBreach": true,
"notifyOnScanComplete": false,
"notifyOnScanError": true
} url must be public HTTPS (no localhost/private-network addresses
— checked at write time and again immediately before every send). secret is optional but strongly recommended — generate one with,
e.g.,
openssl rand -hex 32. Without a secret, deliveries go out
unsigned (no X-Cykanner-Signature header at all). See
the API reference
for the full alert-config shape (email/Slack channels, the notify* toggles).
Payload shape
Every delivery is a POST with Content-Type: application/json and
the same envelope:
-
id— stable across every retry of the same delivery (see Retries below) — use this, not the signature or the timestamp, to detect a duplicate -
type— one of the three event types below -
timestamp— ISO-8601, when this delivery ATTEMPT was sent (changes on every retry, unlikeid— not necessarily the exact instant the underlying event was detected either) -
data— event-specific, see below
{
"id": 4821,
"type": "breach",
"timestamp": "2026-08-20T09:00:14.128Z",
"data": {
"breaches": [
{
"breachId": 44,
"title": "Example Forum",
"domain": "exampleforum.com",
"dataClasses": ["Email addresses", "Passwords"]
}
]
}
} type: "scan_complete" — data:
{ "scannedCount": 12, "newBreachCount": 1, "failedCount": 0, "totalEmails": 12 } type: "scan_error" — data:
{ "error": "3 of 12 monitored emails could not be scanned this run; their breach status may be stale. They will be retried on the next scan." }
Which types you receive is controlled per-channel by the notifyOnNewBreach/
notifyOnScanComplete/
notifyOnScanError flags on the alert config.
Verifying the signature
When the channel has a secret configured, every delivery carries an X-Cykanner-Signature header: the lowercase-hex HMAC-SHA256 digest
of the exact raw request body, keyed with your secret. There is no scheme prefix (unlike,
e.g., a sha256=... style header) — the header value is the hex
digest itself, nothing else.
Compute your own HMAC over the raw bytes you received — not a value you've
parsed and re-serialized, which can legally produce different bytes than what was signed (key
order, whitespace) — and compare it to the header using a constant-time comparison, never
===/==.
const crypto = require('crypto');
function isValidCykannerSignature(rawBody, signatureHeader, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const providedBuf = Buffer.from(signatureHeader || '', 'hex');
// timingSafeEqual throws on a length mismatch instead of returning false --
// check first so a wrong-length header can't crash the handler.
if (expectedBuf.length !== providedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
// Express -- capture the RAW body before any JSON-parsing middleware touches
// it; verifying a re-serialized copy can disagree with what was actually signed.
app.post('/hooks/cykanner', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Cykanner-Signature');
if (!isValidCykannerSignature(req.body, signature, process.env.CYKANNER_WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body);
// ... handle event.type / event.data
res.status(200).end();
}); import hashlib
import hmac
import os
def is_valid_cykanner_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
# hmac.compare_digest is constant-time and handles the length check itself.
return hmac.compare_digest(expected, signature_header or "")
# Flask -- request.get_data() is the raw, pre-parse body.
@app.route("/hooks/cykanner", methods=["POST"])
def cykanner_webhook():
signature = request.headers.get("X-Cykanner-Signature", "")
secret = os.environ["CYKANNER_WEBHOOK_SECRET"]
if not is_valid_cykanner_signature(request.get_data(), signature, secret):
return "invalid signature", 401
event = request.get_json()
# ... handle event["type"] / event["data"]
return "", 200 Test vector
Known-good input/output to validate your implementation against before wiring up a live endpoint:
whsec_test_fixed_vector_secret {"id":42,"type":"breach","timestamp":"2026-01-01T00:00:00.000Z","data":{"breaches":["example.com"]}} 9d1661684b7854b05051fccdd3f31cf6894324eb349eef6c74a42813096b7e4d Retries & idempotency
Cykanner waits up to 10 seconds for a response and treats anything other than a 2xx status
(including a timeout) as a failed attempt. A failed delivery gets 2 retries after the initial
attempt (3 attempts total), with exponential backoff of ~1s then ~2s between attempts.
Redirects are never followed — point
url at the exact endpoint, not one that 3xx's.
Those 3 quick attempts are only the fast path, not the full recovery window. If your endpoint is still failing once they're exhausted, Cykanner doesn't give up on the event — it's re-dispatched roughly every hour, each re-dispatch getting its own fresh 3 attempts, for up to about 10 hours total (30 attempts) under normal operation before permanent abandonment. An outage on Cykanner's own side can occasionally stretch a re-dispatch later than the hourly cadence, so treat ~10 hours as the typical tolerance window, not a hard guarantee.
You can check each alert's delivery per channel — delivered, pending, retrying, or not delivered, with the number of failed attempts, the last attempt time, and a short reason for any failure — under Recent alert deliveries on Dashboard → Settings → Alerts . Channel details there reflect each channel's current settings, and removing a channel also removes its delivery history.
This means your endpoint may receive the same event more than once, potentially hours apart —
make handling it idempotent by deduplicating on id for as long as
you retain delivery records, not just for a few minutes or hours. id
stays identical across every attempt and every re-dispatch of one delivery.
Don't dedupe on the signature or on timestamp —
both are recomputed fresh on every attempt (a new timestamp means
a different signed body, and therefore a different signature), so neither one identifies a
retry as the same delivery.
Replay protection
A valid signature only proves a payload was signed with your secret at some point — it doesn't
prove it just arrived. There's no separate nonce/replay-window mechanism; instead, after
verifying the signature, also check that timestamp is recent (a
few minutes' tolerance is reasonable) before acting on the payload, and reject or log anything
older.