Polling GET /api/tests/:id every 15 seconds works until you have 200 senders to watch. Then it's a waste of API quota and a cron that runs for ten minutes of nothing. A webhook receiver flips the direction: you get called the second the verdict is in, no loop required.
Anything long-running, serverless, or user-facing. Dashboards, Slack alerts, PagerDuty integrations, automated reputation monitors — all better served by a callback than a polling job. CI jobs stay on polling; their lifetime is too short to benefit.
The webhook model
You register an HTTPS URL and a secret. We fire signed POSTs to that URL when one of the subscribed events occurs. You respond with 2xx to acknowledge; anything else triggers retries with exponential backoff. Three events exist today:
test.completed— a placement test finished with a verdict.test.failed— a test could not be completed (transient upstream outage, sender unverified, template malformed).monitor.alert— a scheduled monitor tripped one of its thresholds (inbox rate, spam rate, auth regression).
Registering a webhook
Either in Settings → Webhooks in the UI, or via the API:
POST /api/webhooks HTTP/1.1
Authorization: Bearer ic_live_xxx
Content-Type: application/json
{
"url": "https://api.yourapp.com/hooks/inbox-check",
"events": ["test.completed", "monitor.alert"],
"secret": "whsec_… (32+ bytes, generated on our side if omitted)",
"description": "prod deliverability pipeline"
}
HTTP/1.1 201 Created
{
"id": "whk_01HG2...",
"url": "https://api.yourapp.com/hooks/inbox-check",
"events": ["test.completed", "monitor.alert"],
"secret": "whsec_2a8f9b...",
"createdAt": "2026-06-23T11:00:00Z"
}Store the secret in your secret manager the instant the response returns. It's shown once; lose it and you'll rotate.
Payload format
Every event has the same envelope, with data carrying event-specific fields. A test.completed payload looks like this:
POST /hooks/inbox-check HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
X-IC-Event: test.completed
X-IC-Delivery: dlv_01HG2NR4B5F8X9WVQ6Z1A2C4D7
X-IC-Timestamp: 1718712120
X-IC-Signature: sha256=8d9f1b7e3a2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9
{
"event": "test.completed",
"deliveryId":"dlv_01HG2NR4B5F8X9WVQ6Z1A2C4D7",
"createdAt": "2026-06-23T10:17:48.002Z",
"data": {
"testId": "t_01HG2NQ3B5F8X9WVQ6Z1A2C4D7",
"senderDomain":"news.yourbrand.com",
"subject": "Tuesday digest",
"summary": {
"inboxCount": 17,
"promoCount": 2,
"spamCount": 2,
"missingCount": 1,
"total": 22,
"inboxRate": 0.7727
},
"auth": {
"spf": { "result": "pass" },
"dkim": { "result": "pass" },
"dmarc": { "result": "pass", "aligned": true }
},
"providers": [
{ "provider":"gmail", "placement":"inbox" },
{ "provider":"outlook", "placement":"spam" },
{ "provider":"yahoo", "placement":"inbox" }
],
"tags": ["nightly-monitor", "prod"]
}
}monitor.alert payloads follow the same envelope but data carries the monitor context — which monitor tripped, which threshold, what the rolling average was.
HMAC signature verification
The signature is HMAC-SHA256 of the raw request body using your webhook secret, hex-encoded, prefixed sha256=. Verify before doing anything else:
Node.js / Express
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.IC_WEBHOOK_SECRET;
// IMPORTANT: use express.raw, NOT express.json, so we keep the raw body
app.post('/hooks/inbox-check',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('X-IC-Signature') || '';
const expected = 'sha256=' +
crypto.createHmac('sha256', SECRET)
.update(req.body) // req.body is a Buffer here
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
handleEvent(payload).catch(console.error);
res.status(200).send('ok'); // ack fast, do work async
}
);
app.listen(3000);Python / Flask
import hmac, hashlib, os, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['IC_WEBHOOK_SECRET'].encode()
@app.post('/hooks/inbox-check')
def inbox_check_hook():
raw = request.get_data() # raw bytes
sig = request.headers.get('X-IC-Signature', '')
mac = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, sig): # constant time
abort(401)
payload = json.loads(raw)
handle_event(payload) # enqueue async
return 'ok', 200Body parsing destroys the signature. If your framework parses JSON before your handler runs, it reformats whitespace, re-encodes Unicode, and the HMAC won't match even though the payload is semantically identical. Verify on the raw bytes the request came in with, then parse.
Retries and idempotency
If your endpoint doesn't return a 2xx within 10 seconds, or returns a non-2xx status, we retry. Schedule: 30s, 2m, 10m, 1h, 6h — 5 attempts total over roughly 8 hours. After that the delivery is dead-lettered; you can replay from the UI or the GET /api/webhooks/deliveries endpoint.
Two implications for your code:
- Ack fast, work later. Return 200 as soon as you have persisted the raw payload. Do the real work (database writes, Slack post) on a background worker. A slow handler triggers spurious retries.
- Be idempotent.
X-IC-Deliveryis stable across retries of the same event. Use it as the dedup key in your inbox/events table so a replayed webhook doesn't create duplicate alerts.
Routing alerts to Slack / PagerDuty
The simplest production receiver: verify, enqueue, fan out.
async function handleEvent(payload) {
const { event, data } = payload;
if (event === 'test.completed' && data.summary.inboxRate < 0.80) {
await postSlack({
text: `:warning: *${data.senderDomain}* inbox rate ` +
`*${(data.summary.inboxRate * 100).toFixed(1)}%*`,
attachments: [{
fields: [
{ title: 'Inbox', value: data.summary.inboxCount, short: true },
{ title: 'Spam', value: data.summary.spamCount, short: true },
{ title: 'Missing', value: data.summary.missingCount, short: true },
{ title: 'Total', value: data.summary.total, short: true },
],
}],
});
}
if (event === 'monitor.alert' && data.severity === 'page') {
await pagerDuty({
routingKey: process.env.PD_KEY,
summary: `Inbox placement monitor tripped: ${data.monitorName}`,
severity: 'error',
dedupKey: `inbox-check:${data.monitorId}`,
});
}
}Testing your receiver
Three tools you'll want:
- Tunnel —
ngrok http 3000orcloudflared tunnel. Registers a public URL pointing at your local dev server so the webhook system can reach you. - Replay — in the Webhooks UI, every delivery has a “Resend” button. Great for iterating on parsing without triggering new tests.
- Fixture fire —
POST /api/webhooks/:id/testsends a canned payload to your endpoint for each event type. Use in CI to catch signature-verification regressions.
Common pitfalls
- Using
express.json()before verification. Re-parses the body, invalidates the signature. Useexpress.rawon the webhook route only, keepexpress.json()for other routes. - String comparison on the signature. Leaks via timing side-channels. Use
crypto.timingSafeEqual(Node) orhmac.compare_digest(Python). - Holding the connection open for DB writes. Ack at 200 immediately, process on a worker. Long handlers look like timeouts to the delivery system.
- No replay dedup. One dead-lettered payload replayed by hand becomes two Slack alerts. Dedup on
X-IC-Delivery. - Ignoring timestamp drift. Optional
X-IC-Timestampenables replay-window checks. If your server clock skews more than a minute, reject-by-age will start false-firing.