---
title: Webhooks
description: The message.received event, event_id dedup, the custom HMAC signature recipe with a copy-pasteable verifier, at-least-once retries, and dead-letter redrive.
---

# 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:

```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)

```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:

```js
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.
