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.