Webhook Reliability
Webhooks are the production interface between mailbot and your code. Reliability work pays back every day: a flaky endpoint silently loses events. A solid one becomes invisible.
This page covers what your endpoint needs to do, how to verify signatures, how mailbot handles retries, and how to debug deliveries when they fail.
Endpoint design checklist
A reliable mailbot webhook endpoint:
- responds with 2xx within 5 seconds to acknowledge receipt
- does not depend on synchronous downstream work to return a response
- verifies the signature before trusting the payload
- treats
event_idas idempotent — duplicate deliveries should not change state - handles all expected event types and ignores unknown ones gracefully
- logs the raw body and the signature header for debugging
If your handler does heavy processing, push the event to a queue and return 200 immediately. mailbot's timeout is short by design; long synchronous handlers cause retries that compound load.
Signature verification
Every webhook delivery includes an X-mailbot-Signature header. The signature is HMAC-SHA256(secret, raw_body), hex-encoded.
import crypto from 'node:crypto';
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// constant-time compare to resist timing attacks
const a = Buffer.from(expected, 'utf-8');
const b = Buffer.from(signatureHeader || '', 'utf-8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Two important details:
- Verify against the raw body, not the JSON-parsed object. Re-stringifying after parse changes whitespace and breaks the HMAC.
- Use constant-time comparison (
timingSafeEqual). Plain===leaks timing information.
In Express, capture the raw body before JSON parsing:
import express from 'express';
const app = express();
app.use('/webhooks/mailbot', express.raw({ type: 'application/json' }));
app.post('/webhooks/mailbot', (req, res) => {
const raw = req.body; // Buffer
const signature = req.header('X-mailbot-Signature');
if (!verifySignature(raw, signature, process.env.MAILBOT_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw.toString('utf-8'));
// handle event ...
res.status(200).end();
});
Retry behavior
mailbot retries failed deliveries with exponential backoff. See Event delivery model for the full schedule. Summary:
- 6 attempts total
- spread across 6 hours
- 2xx is success; everything else is failure
- 3xx redirects are treated as failure (mailbot does not follow them)
After all retries fail, the delivery is marked failed. The event itself remains replayable. See Event replay troubleshooting.
Idempotency in your handler
Because retries are at-least-once, your handler will sometimes see the same event_id twice. The cheapest defense is a deduplication table:
async function handle(event) {
const inserted = await db.processed_events.insert(
{ event_id: event.event_id, processed_at: new Date() },
{ onConflict: 'do nothing' },
);
if (!inserted.rowCount) return; // already processed
await applyEvent(event);
}
processed_events becomes a permanent record of every delivery you accepted. Index on event_id and prune older than your retention window.
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 from your endpoint | Signature verification mismatch | Check secret value, verify against raw body, use constant-time compare |
| Timeouts | Sync work in handler | Move work to a queue; return 200 immediately |
| Duplicate state changes | Handler not idempotent | Add event_id dedupe table |
| Endpoint is up but 4xx | Strict body validation rejecting unknown fields | Loosen schema; ignore unknown fields |
| Endpoint returns 200 but downstream stays empty | Silent error after the response | Add structured logging on the actual handler logic |
Debugging a specific delivery
You have two views to combine:
- mailbot side: list deliveries for the inbox via the deliveries log endpoint. Each row shows
event_id, attempt count, last response status, last response body. - Your side: structured logs keyed by
event_id.
When a delivery looks failed on mailbot but processed on your side (or vice versa), you immediately know which half is lying.
Practical tips
- Use one secret per webhook destination. If you compromise one, rotate just that one.
- Test with replay first. Before changing handler code in production, replay a known event and verify the response.
- Alert on failed deliveries, not on attempts. mailbot's retry schedule is generous. A single retry is not an incident; six failed attempts is.
- Keep
Content-Type: application/jsonstrict. Anything else is suspicious. - Do not run untrusted code based on payload contents. Treat payload like any other user-controlled input.