API8 min read

Webhooks: get pinged the second placement drops

Polling works. Webhooks work better. Register a callback, let us signal when a test completes or a monitor trips an alert, and skip the cron.

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.

When to webhook

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', 200
The one bug everyone writes

Body 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:

  1. 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.
  2. Be idempotent. X-IC-Delivery is 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:

  • Tunnelngrok http 3000 or cloudflared 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 firePOST /api/webhooks/:id/test sends 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. Use express.raw on the webhook route only, keep express.json() for other routes.
  • String comparison on the signature. Leaks via timing side-channels. Use crypto.timingSafeEqual (Node) or hmac.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-Timestamp enables replay-window checks. If your server clock skews more than a minute, reject-by-age will start false-firing.

Frequently asked questions

Does webhook delivery use my API quota?

No. Webhooks are outbound from us to you — they don't count against the per-minute or per-month test quotas. You only pay for the tests themselves.

Can I have multiple webhook URLs for different events?

Yes. Register multiple webhook configs, each subscribed to a different subset of events. A common split: one URL for test.completed → a database logger, a different URL for monitor.alert → PagerDuty.

What happens if my endpoint is down for a day?

Five retries over ~8 hours cover most outages. Beyond that, deliveries are dead-lettered and visible in the UI for 30 days. Replay manually once your service recovers, or call GET /api/webhooks/deliveries?status=failed and replay programmatically.

Is there any way to test signature verification without a live test?

Yes — the fixture endpoint POST /api/webhooks/:id/test fires a canned payload signed with your real secret at your real URL. Perfect for CI: run it, assert your endpoint returns 200, fail the deploy if it doesn't.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required