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.

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.