> ## Documentation Index
> Fetch the complete documentation index at: https://paperplane-justin-winter-s-projects.vercel.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Delivery webhooks

> Get pushed status updates for your letters instead of polling.

## Delivery webhooks

Instead of polling `GET /v1/orders/{id}`, pass a `webhook_url` (https only) when creating an order. paperplane POSTs a signed event on each status transition.

### Events

Fired on the same transitions the email notifications cover:

* `submitted`
* `refused`
* `held`
* `mailed`
* `delivered`
* `failed`

### Request

Every event is a `POST` with a `Webhook-Id` header (for idempotency) and this body:

```json theme={null}
{
  "id": "evt_ord_x_1710000000000_delivered",
  "type": "order.updated",
  "version": 1,
  "created": 1710000000000,
  "data": {
    "order_id": "ord_x",
    "status": "delivered",
    "mail_class": "certified",
    "tracking_number": "9400...",
    "price_cents": 1299,
    "event_url": "https://sendpaperplane.com/v1/orders/ord_x"
  }
}
```

`version` is the payload schema version, currently always `1`. It only
changes if `data`'s shape changes in a way your parser could break on (a
field renamed or removed); we'll bump it and note the change here when that
happens — adding a new optional field does not bump it.

### Verify it came from us

Set `OUTBOUND_WEBHOOK_SECRET` on the server. Every request carries:

```
X-paperplane-Signature: t=<unix ms>,v1=<hex>
```

where `v1 = HMAC-SHA256(secret, t + "." + rawBody)`. Reject events older than a few minutes (replay window). If the secret is unset, paperplane **refuses to send unsigned events** — you can always prove a status push came from us.

A runnable Node example that verifies this exact scheme against a real
payload shape (no dependencies, no network) lives at
[`scripts/verify-webhook-example.mjs`](https://github.com/paperplane/paperplane/blob/main/scripts/verify-webhook-example.mjs)
in the repo:

```bash theme={null}
node scripts/verify-webhook-example.mjs
```

It signs a sample event with a throwaway secret, verifies it the same way
your endpoint should, then shows a tampered body and a stale timestamp both
failing verification — copy the `verify()` function straight into your
handler.

### Delivery

Fire-and-forget with one retry after a short backoff. If your endpoint is down, the reconcile cron re-converges order state and re-fires on the next poll.
