Guides

Receiving webhooks

Verify signatures, survive retries, and stay idempotent.

Polling the event log tells you what happened. Webhooks tell you sooner, and cost you no requests.

Register an endpoint

curl -X POST "$MAILYTE_BASE/api/v1/webhooks" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://yourapp.com/hooks/mailyte",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'

Subscribe only to what you will act on. Every event you accept and ignore is a request your server handles for nothing, and it makes the ones that matter harder to see in your logs.

The envelope

Every delivery has the same shape:

{
  "id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
  "event": "email.bounced",
  "created_at": "2026-09-17T10:04:11.000000Z",
  "data": {
    "message_id": "<01JBT8XQ2M@yourdomain.com>",
    "recipient": "ada@example.com",
    "dsn": "5.1.1",
    "reason": "Mailbox does not exist"
  }
}

The full catalogue, with each event's payload, is on the webhook events page.

Verify the signature. Always.

Your endpoint is a public URL. Anyone can post to it, and a forged email.complained that your code acts on is a way for a stranger to unsubscribe your customers.

Each request carries a signature computed over the raw body with your webhook's signing secret. Verify it before parsing, and compare in constant time.

import crypto from 'node:crypto';

export function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  // Buffers must be equal length or timingSafeEqual throws.
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}

Use the raw request body, not a re-serialised object. JSON.parse followed by JSON.stringify will not reproduce the original bytes — key order and whitespace change — and the signature will never match. In Express that means express.raw() on this route, not express.json().

Delivery is at least once

The same event can arrive twice. A network timeout after your server committed but before we received the 200 looks identical to a failure from our side, so we retry.

Make handling idempotent by keying on the event id:

app.post('/hooks/mailyte', async (req, res) => {
  if (!verify(req.body, req.get('X-Mailyte-Signature'), SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Cheap insurance. A unique index on event_id makes this exact.
  const inserted = await db.insertIgnore('webhook_events', { id: event.id });
  if (!inserted) return res.sendStatus(200);

  await queue.push(event);   // Do the work elsewhere.
  res.sendStatus(200);
});

Order is not guaranteed

A delivered can arrive before the accepted that preceded it. Events are produced by different parts of the pipeline and travel independently.

So do not build a state machine that assumes arrival order. Use created_at to decide which event is newer, and write state transitions that tolerate arriving out of sequence — if you already recorded delivered, a late accepted should be ignored rather than overwriting it.

Respond fast, work later

Acknowledge with a 2xx as soon as you have stored the event. Do the real work on a queue.

An endpoint that takes eight seconds because it renders a PDF will time out, get retried, render the PDF again, and eventually be marked unhealthy. Repeated failures move deliveries to a dead-letter queue and eventually disable the endpoint — which is the correct behaviour, and an unpleasant way to discover your handler was slow.

Retries

A failed delivery is retried with exponential backoff over several hours. Anything still failing after that is dead-lettered.

If your endpoint was down, fix it and check what you missed against GET /api/v1/email-logs — the event log is the durable record, and webhooks are a notification on top of it. Reconciling against the log after an outage is much easier than trying to have the deliveries replayed.

Testing locally

Point a webhook at a tunnel (ngrok, cloudflared) while developing. Send yourself a message and watch the events arrive in order — or, more usefully, watch them arrive out of order, which is exactly the case your handler needs to survive.

Next

Handling delivery events — what each event means, and which ones you can trust.