Testing email webhooks — verify the signature, then trust the payload
Polling an inbox is fine in a test. It is the wrong tool for a service that needs to react when mail arrives — a support desk that ingests replies, a system that watches for a bounce, a pipeline that waits on an inbound document.
For that, InboxDome pushes. You register an HTTPS endpoint, and every message that lands in one of your inboxes is POSTed to it.
Register the endpoint
curl -X POST https://inboxdome.com/v1/webhooks \
-H "Authorization: Bearer $INBOXDOME_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.example.com/hooks/inboxdome"}'
{
"webhook": { "id": "wh_...", "url": "https://…", "events": ["message.received"] },
"secret": "whsec_…"
}
The secret is returned once, at creation. Store it now; it is not retrievable later.
The URL must be https://, which means a tunnel (ngrok, cloudflared) if you are testing
against localhost.
What arrives
A message.received POST, with these headers:
| Header | Value |
|---|---|
X-InboxDome-Event |
message.received |
X-InboxDome-Signature |
hex HMAC-SHA256 of the raw body, keyed with your secret |
User-Agent |
InboxDome-Webhooks/1.0 |
and this body:
{
"event": "message.received",
"data": {
"inboxId": "inb_…",
"message": {
"id": "msg_…",
"from": { "name": "Acme", "address": "no-reply@acme.example" },
"subject": "Your receipt",
"hasOtp": false,
"hasAttachments": true,
"receivedAt": "2026-07-14T12:00:00.000Z"
}
},
"sentAt": "2026-07-14T12:00:01.000Z"
}
The payload carries metadata, not the body of the email. Fetch the content with
GET /v1/inboxes/{inboxId}/messages/{id} when you actually need it — that way a webhook
log, an error tracker, or a proxy never ends up holding somebody’s message body.
Verify the signature — on the raw body
This is the part that goes wrong. The signature is computed over the exact bytes we sent. If your framework has already parsed the JSON and you re-serialize it to check the signature, you are hashing a different string — key order, whitespace, and unicode escaping will not survive the round trip. It usually still works in testing, and then breaks on the first payload with a non-ASCII subject line.
Express, keeping the raw body:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.INBOXDOME_WEBHOOK_SECRET;
// express.raw, NOT express.json — we need the bytes as sent.
app.post('/hooks/inboxdome', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const got = req.get('X-InboxDome-Signature') ?? '';
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(got, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const { data } = JSON.parse(req.body.toString('utf8'));
console.log('mail in', data.inboxId, data.message.subject);
res.status(200).end(); // ack fast; do the work after
});
Two details that matter:
- Compare in constant time.
===on a signature leaks, through timing, how much of the prefix you got right.timingSafeEqualdoes not — but it throws on a length mismatch, so check the length first. - Acknowledge fast, work later. We wait 10 seconds for a response. Anything slower is a failed delivery, no matter how correct your handler is.
The same check on a Cloudflare Worker:
const enc = new TextEncoder();
const raw = await request.text(); // raw first, parse second
const key = await crypto.subtle.importKey(
'raw',
enc.encode(SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const sig = request.headers.get('X-InboxDome-Signature') ?? '';
const bytes = Uint8Array.from(sig.match(/../g) ?? [], (h) => parseInt(h, 16));
if (!(await crypto.subtle.verify('HMAC', key, bytes, enc.encode(raw)))) {
return new Response(null, { status: 401 });
}
const { data } = JSON.parse(raw);
Retries, and what counts as delivered
Any 2xx is a success. Anything else — a timeout, a 500, a connection reset — is a failure,
and we retry, up to 5 attempts total. After that the delivery is marked failed and
stays that way.
Because a retry can land after your handler already ran (you did the work, then your
response got lost), handlers must be idempotent. Key off data.message.id, which is
stable across retries, and make the second delivery of the same message a no-op.
To see what actually happened:
curl https://inboxdome.com/v1/webhooks/deliveries \
-H "Authorization: Bearer $INBOXDOME_KEY"
That returns the delivery log — attempt count and the HTTP status we saw — which is the first place to look when your endpoint “never got the webhook”.
Testing the handler itself
You do not need real mail to test your verification logic. Sign a fixture with your secret and POST it at yourself; assert that a tampered body is rejected. That test is worth more than the happy path, because a signature check that accepts everything passes the happy path perfectly.
See also: API reference · Node client · Test a password reset email
Get a free API key — 50 inboxes a day, no card. Orgrab a temporary inbox without signing up at all.