Tutorial
Consuming webhooks
Build a small service that receives Corsair's events and acts on them. About forty-five minutes, and most of that is getting the signature verification right — which is the part that matters, because an unverified endpoint is one anybody who learns the URL can write to.
Event hooks is the reference. This is the build.
What we are building#
A service that receives message.received and message.bounced, verifies the signature, deduplicates retries, and writes a line to a log. Small enough to read; complete enough that the failure modes show up.
1. Write the receiver#
import { createHmac, timingSafeEqual } from "node:crypto"
const SECRET = process.env.CORSAIR_WEBHOOK_SECRET!
const TOLERANCE_SECONDS = 300
const verify = (headers: Headers, body: string): boolean => {
const id = headers.get("webhook-id")
const timestamp = headers.get("webhook-timestamp")
const signature = headers.get("webhook-signature")
if (!id || !timestamp || !signature) return false
// Without the age check, a captured delivery replays forever.
const ts = Number(timestamp)
if (!Number.isFinite(ts)) return false
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false
const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64")
const expected =
"v1," + createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64")
// The header may carry several space-separated signatures during a secret
// rotation. Any one matching is a pass.
return signature.split(" ").some((candidate) => {
const a = Buffer.from(expected)
const b = Buffer.from(candidate.trim())
return a.length === b.length && timingSafeEqual(a, b)
})
}
const seen = new Set<string>()
Bun.serve({
port: 8080,
async fetch(req) {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 })
// Read the body verbatim. The signature covers the exact bytes.
const body = await req.text()
if (!verify(req.headers, body)) {
return new Response("Bad signature", { status: 401 })
}
const id = req.headers.get("webhook-id")!
if (seen.has(id)) return new Response("ok") // a retry of something we did
seen.add(id)
const event = JSON.parse(body)
console.log(`[${event.type}]`, JSON.stringify(event.data))
// Answer immediately. Do the slow part elsewhere.
queueMicrotask(() => handle(event))
return new Response("ok")
},
})
const handle = (event: { type: string; data: Record<string, unknown> }) => {
switch (event.type) {
case "message.received":
console.log(` → ${event.data.sender} to ${event.data.recipient}`)
break
case "message.bounced":
console.log(` → bounced: ${event.data.recipient}`)
break
}
}CORSAIR_WEBHOOK_SECRET=whsec_... bun run receiver.ts2. Expose it#
The endpoint must be reachable from the public internet. Private, loopback, and link-local addresses are refused at creation, because the URL is customer-supplied and Corsair is what fetches it — an open one is a server-side request forgery primitive.
For development, tunnel it:
ssh -R 80:localhost:8080 nokey@localhost.runFor a self-hosted setup where the consumer really is on the same private network as Corsair, set WEBHOOK_ALLOW_PRIVATE=true on the server. It is off by default because the safe case is the rarer one.
3. Register the endpoint#
Webhooks → New endpoint in the panel.
| Field | Value |
|---|---|
| URL | your public HTTPS URL |
| Description | something you will recognise in a list |
| Events | message.received, message.bounced |
The signing secret is shown once, at creation. It is a credential; an endpoint that has lost it should rotate rather than read it back. Copy it into CORSAIR_WEBHOOK_SECRET now.
Subscribe to a family with message. or to everything with . A family wildcard is usually right — it picks up new event types in that family without you changing anything.
4. Test it#
Press Send test. It delivers a signed sample synchronously and shows you the exact status and body that came back, which is far more useful than watching a queue.
If it fails:
| What you see | Cause |
|---|---|
| Connection refused | Tunnel down, or the URL is wrong |
| 401 from your service | Secret mismatch, or you parsed before verifying |
| Timeout | Your handler is doing the work inline. Answer first |
5. Make it survive retries#
Corsair guarantees at least once, not exactly once. Your endpoint must be idempotent.
Retries reuse the same webhook-id, so recording the id is enough to tell a retry from a new event. The Set above works for a demo; use a table with a unique index in anything real:
CREATE TABLE webhook_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);const fresh = await db.query(
"INSERT INTO webhook_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id",
[id, event.type],
)
if (!fresh.rows.length) return new Response("ok") // already handledThe insert is the deduplication. Checking first and inserting after is a race two concurrent retries will win.
6. Answer fast#
Answer with any 2xx, quickly. The body is ignored.
Anything else is retried after 5s, 30s, 5m, 30m, 2h, 5h, 10h, and 24h, then given up on. An endpoint that fails twenty times in a row is disabled automatically, and the panel says so — Corsair will not hammer a dead endpoint forever.
So: verify, deduplicate, enqueue, return. Never do the work inside the request.
7. Watch it in the panel#
Webhooks → your endpoint → Events lists every delivery with its status, attempt count, and next attempt time. Open one to see the payload that was sent and replay it if you need to.
Replay redelivers the same event with the same id — which is exactly the case your deduplication is for. Test it: replay something you have already handled and confirm nothing happens twice.
The events you get#
| Event | When |
|---|---|
message.received |
Accepted and delivered to a mailbox |
message.spam |
Accepted, scored as spam, filed in Junk |
message.rejected |
Refused at SMTP time |
message.sent |
Accepted from one of your mailboxes for delivery |
message.delivered |
The receiving server accepted it |
message.deferred |
Temporarily refused; Corsair will retry |
message.bounced |
Permanently failed, or retries ran out |
address.created, address.deleted, address.password_changed |
Mailbox lifecycle |
domain.created, domain.verified, domain.verification_failed, domain.deleted |
Domain lifecycle |
quota.warning, quota.exceeded |
Storage |
transfer.completed, transfer.failed |
Mailbox migration |
A message.received payload:
{
"type": "message.received",
"created_at": "2026-08-10T12:00:00.000Z",
"data": {
"recipient": "me@example.com",
"sender": "someone@elsewhere.com",
"subject": "Hello",
"message_id": "<abc@elsewhere.com>",
"size": 4821,
"spam_score": 1.5,
"authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
"remote_ip": "203.0.113.9"
}
}Things worth building on this#
- A bounce suppression list. Record every
message.bouncedrecipient and stop sending to it. Repeatedly delivering to an address that hard-bounces is one of the fastest ways to damage a sending reputation. - A quota alarm.
quota.warninginto whatever pages you. - An archive.
message.receivedinto cold storage, keyed bymessage_id. - Deployment gating.
domain.verification_failedis usually someone editing DNS. Find out from a webhook rather than from a customer.
Using an off-the-shelf verifier#
The scheme is Standard Webhooks, and the svix-id, svix-timestamp, and svix-signature aliases go out alongside the standard headers. A Svix verification library works unchanged:
import { Webhook } from "svix"
const wh = new Webhook(process.env.CORSAIR_WEBHOOK_SECRET!)
const event = wh.verify(body, Object.fromEntries(req.headers))Do not invent a different scheme, and do not skip verification because the URL is hard to guess. It is not a secret; it is in your logs, your proxy's logs, and anywhere the URL has ever been pasted.