Skip to content

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:

POST /api/v1/alert-configs
{
  "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, unlike id — not necessarily the exact instant the underlying event was detected either)
  • data — event-specific, see below
type: "breach" — one or more new breaches found for a monitored email
{
  "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 ===/==.

Node.js
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();
});
Python
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:

secret
whsec_test_fixed_vector_secret
raw request body
{"id":42,"type":"breach","timestamp":"2026-01-01T00:00:00.000Z","data":{"breaches":["example.com"]}}
X-Cykanner-Signature
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.