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:

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.