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.
Email prompt injection: the channels, and what closes each one
walks the gates that produce these statuses and what the sanitization report records.
Two further statuses exist on the message but are normally withheld from webhooks, because a quarantined message should not become an event your handler has to defend against:
skipped_virus— never delivered by webhook. Always readable on the message itself.skipped_spam— delivered only if the mailbox setsdeliver_spam. If you enable it, handle this fourth value;extractionis null on it.
Both are always stored and readable via GET /api/v1/messages/{id}, so nothing is
lost — it is suppressed from the push surface, not discarded.
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.
Where to next
- Quickstart — point a handler at a live mailbox and receive a real event
- Prompt injection in email — the gates behind
comprehension_status - API reference — reading the same message over HTTP instead of waiting for a push
- Authentication & key scoping — why redrive and secret rotation stay off the API