Skip to content

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.

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.

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.

{
"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.

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 hex
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);
});

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.

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.

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 on X-Key-Id needs no change when it does.

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.