Blacklist webhooks
When a target you monitor is listed on a blocklist — or comes off one — we POST that transition to a URL you choose, as it happens. One request per event, signed, no polling and no batching.
Turning it on
Section titled “Turning it on”Open the target in the dashboard and set two things:
- Destination URL — an
https://endpoint you control. - Signing secret — any string up to 256 characters. Generate it the way you
would any shared secret, e.g.
openssl rand -base64 32.
The secret is never returned by the API and never appears in a log line, so store your copy somewhere you can read it back. Clearing the destination clears the secret with it.
What fires
Section titled “What fires”One delivery per transition, per blocklist:
| Event | When |
|---|---|
blacklist.listed |
A blocklist that was not listing this target now is. |
blacklist.delisted |
A blocklist that was listing it no longer is. |
A target listed by three blocklists in one check produces three deliveries — one per list — so a receiver can act on each list independently.
A list that refuses or fails a query produces no event in either direction. It has told us nothing about your standing, and reporting that as a delisting is how an automation concludes you are clean while your mail is being rejected.
The payload
Section titled “The payload”{ "event": "blacklist.listed", "event_id": "665f1c2a9b4e7d0012abcdef", "occurred_at": "2026-08-21T11:30:02.000Z", "critical": true, "provider": "spamhaus_zen", "target": { "id": "665f1c2a9b4e7d0012ab34cd", "kind": "ipv4", "value": "203.0.113.24", "label": "Primary sending IP", "status": "listed", "listed_on": ["spamhaus_zen"] }, "detail": { "zone": "zen.spamhaus.org", "codes": ["127.0.0.4"], "txt": ["https://check.spamhaus.org/query/ip/203.0.113.24"] }}| Field | Description |
|---|---|
event |
blacklist.listed or blacklist.delisted. |
event_id |
Stable id for this transition. Also sent as X-Event-Id. Dedupe on it. |
occurred_at |
ISO-8601 instant of the check that established the transition. |
critical |
A new listing on a blocklist that stops mail outright. Page someone. |
provider |
The blocklist that moved, as a stable id, e.g. spamhaus_zen. |
target |
The target as the check left it — listed_on is the full current set, not just this provider. |
detail.zone |
The DNS zone that answered. |
detail.codes |
The return codes the list answered with, e.g. 127.0.0.4. |
detail.txt |
The list’s own reason strings — usually including its removal URL. This is the actionable part. |
detail.codes and detail.txt are empty on a blacklist.delisted event: there
is no listing left to explain.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type |
application/json |
X-Timestamp |
Unix seconds at the moment this attempt was signed. |
X-Event-Id |
The event id. Always equal to event_id in the body. |
X-Key-Id |
Which key signed: custom for your per-target secret, v1 / v2 for a platform key. |
X-Signature |
Lowercase-hex HMAC-SHA256. |
The signature covers a string built from the timestamp, the event id and a hash of the exact bytes we posted:
signing_string = "{X-Timestamp}.{X-Event-Id}." + sha256_hex(raw_request_body)X-Signature = hmac_sha256(secret, signing_string) // lowercase hexVerifying a delivery
Section titled “Verifying a delivery”import crypto from "node:crypto";
const REPLAY_WINDOW_SECONDS = 60;
export function verifyBlacklistWebhook(rawBody, headers, secret) { const timestamp = headers["x-timestamp"]; const eventId = headers["x-event-id"]; const signature = headers["x-signature"]; if (!timestamp || !eventId || !signature) return false;
// Reject anything outside the replay window before spending a hash on it. const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > REPLAY_WINDOW_SECONDS) return false;
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex"); const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${eventId}.${bodyHash}`) .digest("hex");
const a = Buffer.from(expected, "hex"); const b = Buffer.from(signature.trim().toLowerCase(), "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b);}import express from "express";
const app = express();
app.post("/hooks/blacklist", express.raw({ type: "application/json" }), (req, res) => { if (!verifyBlacklistWebhook(req.body, req.headers, process.env.BLACKLIST_WEBHOOK_SECRET)) { return res.sendStatus(401); }
const event = JSON.parse(req.body.toString("utf8")); // Acknowledge first, work afterwards — see "Answering" below. res.sendStatus(204); handle(event);});import hashlibimport hmacimport time
REPLAY_WINDOW_SECONDS = 60
def verify_blacklist_webhook(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers.get("X-Timestamp") event_id = headers.get("X-Event-Id") signature = headers.get("X-Signature") if not (timestamp and event_id and signature): return False
try: age = abs(int(time.time()) - int(timestamp)) except ValueError: return False if age > REPLAY_WINDOW_SECONDS: return False
body_hash = hashlib.sha256(raw_body).hexdigest() signing_string = f"{timestamp}.{event_id}.{body_hash}" expected = hmac.new( secret.encode("utf-8"), signing_string.encode("utf-8"), hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected, signature.strip().lower())import jsonimport os
from fastapi import FastAPI, Request, Response
app = FastAPI()SECRET = os.environ["BLACKLIST_WEBHOOK_SECRET"]
@app.post("/hooks/blacklist")async def blacklist_hook(request: Request): raw = await request.body() if not verify_blacklist_webhook(raw, request.headers, SECRET): return Response(status_code=401)
handle(json.loads(raw)) return Response(status_code=204)Compare in constant time (crypto.timingSafeEqual, hmac.compare_digest) rather
than with ==. A byte-by-byte comparison that returns early leaks where the
first mismatch was, which is enough to recover a signature one byte at a time.
The replay window
Section titled “The replay window”Every attempt carries a fresh X-Timestamp and a fresh signature, so a retry
never arrives already expired. Reject deliveries whose timestamp is more than
60 seconds from your clock — without that check, a captured request stays
replayable forever.
Key rotation
Section titled “Key rotation”X-Key-Id names the key that signed, so a rotation is never a flag day: accept
both keys for as long as you need, then drop the old one.
custom— your per-target secret. Rotating is: add the new secret in the dashboard, keep verifying against both for a few minutes, retire the old one.v1,v2, … — a platform key. The number goes up when we rotate ours; a receiver that keys its lookup onX-Key-Idneeds no change when it does.
Answering a delivery
Section titled “Answering a delivery”Answer any 2xx as soon as you have the bytes safely stored. Do the real work afterwards: we give a request 10 seconds, and a receiver that verifies, writes to a database and calls three APIs before answering is a receiver that times out.
| Your response | What we do |
|---|---|
2xx |
Done. The event is marked delivered. |
429 |
Retried. |
5xx, connection error, timeout |
Retried. |
Any other 4xx |
Not retried. |
A 401 or a 404 means the destination is misconfigured — a wrong secret, a
route that no longer exists — and retrying a misconfiguration just sends the
same broken request twice more. Fix the endpoint; the next transition will
arrive.
Retries: up to three attempts, roughly one second and then three seconds apart. After that the delivery is abandoned. The transition is still recorded, still visible on the target, and still included in your weekly summary.