View as markdown ↓

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_virusnever delivered by webhook. Always readable on the message itself.
  • skipped_spam — delivered only if the mailbox sets deliver_spam. If you enable it, handle this fourth value; extraction is 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 — hex HMAC-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:

  1. Read the raw request body (exact bytes — do not re-serialize parsed JSON).
  2. Recompute HMAC-SHA256(secret, x-postfleet-timestamp + "." + rawBody) as hex.
  3. Constant-time compare it to x-postfleet-signature.
  4. Reject if x-postfleet-timestamp is 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.received is 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