Webhooks
Postfleet delivers inbound-mail events to a URL you configure in the dashboard, signed with a custom HMAC (this is Postfleet's own scheme — it is not Svix). Configure the endpoint URL and a signing secret in the dashboard; both are required before any event is sent.
Event shape
Currently the one outbound event is message.received, fired when mail arrives in a
mailbox. It is POSTed as JSON:
{
"event_id": "evt_9f8c...",
"type": "message.received",
"message_id": "m_123...",
"mailbox_id": "b1a2...",
"thread_id": "t_456...",
"from": "sender@example.com",
"subject": "Re: your message",
"body": "cleaned, sanitized message body",
"truncated": false,
"sanitization": { "...": "trust report of what was stripped" },
"comprehension_status": "complete",
"classification": { "...": "..." },
"extraction": { "...": "schema-extracted fields" },
"extraction_error": null,
"confidence": 0.97
}
body is the cleaned, sanitized text — the pre-sanitization original is never sent.
comprehension_status is one of complete, partial, or skipped_injection_risk (never ok);
on a partial result extraction/classification may be null and extraction_error is set.
Deduplicate on event_id
Delivery is at-least-once: a retry (or a redrive) re-POSTs the same payload with the
same event_id. Treat event_id as the idempotency key and ignore an event you have
already processed.
Verifying the signature
Every request carries two headers:
x-postfleet-signature— hexHMAC-SHA256(secret, "{timestamp}.{rawBody}")x-postfleet-timestamp— the millisecond epoch used in the signature
The signed string is the timestamp and the raw request body joined by a literal . — the
timestamp is inside the MAC (Stripe-style) so a captured request can't be replayed forever.
To verify:
- Read the raw request body (exact bytes — do not re-serialize parsed JSON).
- Recompute
HMAC-SHA256(secret, x-postfleet-timestamp + "." + rawBody)as hex. - Constant-time compare it to
x-postfleet-signature. - Reject if
x-postfleet-timestampis more than 5 minutes old.
Copy-pasteable verifier (Node.js)
const crypto = require('node:crypto');
const FIVE_MINUTES_MS = 5 * 60 * 1000;
// secret: your webhook signing secret (from the dashboard)
// rawBody: the raw request body STRING (not a parsed object)
// signature: the x-postfleet-signature header
// timestamp: the x-postfleet-timestamp header
function verifyPostfleetWebhook(secret, rawBody, signature, timestamp) {
// 1. Reject stale timestamps (replay protection).
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Date.now() - ts > FIVE_MINUTES_MS) return false;
// 2. Recompute the HMAC over `${timestamp}.${rawBody}`.
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// 3. Constant-time compare.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Example handler wiring:
app.post('/webhooks/postfleet', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const ok = verifyPostfleetWebhook(
process.env.POSTFLEET_WEBHOOK_SECRET,
rawBody,
req.get('x-postfleet-signature'),
req.get('x-postfleet-timestamp'),
);
if (!ok) return res.status(401).end();
const event = JSON.parse(rawBody);
// Dedupe on event.event_id, then process.
res.status(200).end();
});
Return any 2xx to acknowledge. Any non-2xx (or a connection/timeout error) is treated as a
failure and the delivery is retried.
Retries and the retry window
The first attempt fires inline when the event is enqueued; failures are retried by a cron with exponential backoff plus ±20% jitter:
| Retry | Delay after previous failure |
|---|---|
| 1 | ~1 minute |
| 2 | ~5 minutes |
| 3 | ~30 minutes |
| 4 | ~2 hours |
| 5 | ~12 hours |
That's up to 6 attempts total (1 inline + 5 retries) over roughly 15 hours. After the
last retry fails, the delivery is dead-lettered. A 429 response with a sane numeric
Retry-After (seconds, up to 24h) overrides the step for that attempt.
Dead-letter redrive
A dead-lettered delivery can be redriven from the dashboard (control-plane). The redrive
re-POSTs the same payload with the same event_id, so your dedup logic still holds. There is
no API endpoint for redrive — it is a dashboard-only action.
Current limitations
message.receivedis the only outbound event. There are no delivery/bounce/complaint or draft-lifecycle webhooks yet.- Redrive is dashboard-only — no programmatic replay of dead-lettered deliveries.