Email Testing

Testing email webhooks: verify the signature, then trust the payload

Get a push notification the moment mail arrives, verify the HMAC signature correctly, and avoid the raw-body bug that makes signature checks silently useless.

On this page
  1. Register the endpoint
  2. What arrives
  3. Verify the signature, on the raw body
  4. Retries, and what counts as delivered
  5. Testing the handler itself

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. timingSafeEqual does 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, with 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

Disclosure: InboxDome publishes this blog and makes the products it mentions. We describe what they do rather than promise results.

InboxDome TeamEmail infrastructure builders

The InboxDome team runs the temporary-email service and email-testing API this blog belongs to. We write about disposable inboxes, email privacy and testing transactional email, based on operating the infrastructure ourselves.