Webhooks
Updated 9/17/2026Webhooks
Subscribe to order lifecycle events instead of polling
GET /api/v1/orders/{id}.Registering an endpoint
Each account has one webhook endpoint per mode (test and live are independent):
GET /api/v1/account/webhooks?mode=livePUT /api/v1/account/webhooks?mode=live
json// PUT body{ "url": "https://example.com/webhooks/letterapi" }
json// response — the signing secret is only ever returned here, on create/replace{"id": "c9a1...","ownerId": "5e2f...","mode": "live","url": "https://example.com/webhooks/letterapi","isActive": true,"createdAt": "2026-01-14T10:00:00.000Z","secret": "6e1f3b...redacted"}
Calling
PUT again replaces the endpoint and rotates the secret — update your verification code accordingly.Events
| Event | Fires when |
|---|---|
order.paid | An order's payment is confirmed (both credit- and Stripe-funded). |
order.printing | The print shop marks the order as printing. |
order.posted | A postal order is handed to Royal Mail. |
Pickup orders don't emit a
posted event — poll or watch for printing and treat ready_for_pickup (from GET /orders/{id}) as the terminal state, or ask your shop workflow to notify you separately.Payload
json{"type": "order.paid","data": { /* full PrintOrder object, see Creating orders */ }}
Delivered as:
POST <your url>Content-Type: application/jsonX-LetterAPI-Signature: <hex-encoded HMAC-SHA256>
Verifying the signature
The signature is an HMAC-SHA256 of the raw request body, keyed with your endpoint's secret:
tsimport { createHmac, timingSafeEqual } from "node:crypto";function isValidLetterApiWebhook(rawBody: string, signatureHeader: string, secret: string) {const expected = createHmac("sha256", secret).update(rawBody).digest("hex");const a = Buffer.from(expected, "hex");const b = Buffer.from(signatureHeader, "hex");return a.length === b.length && timingSafeEqual(a, b);}
pythonimport hashlibimport hmacdef is_valid_letterapi_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()return hmac.compare_digest(expected, signature_header)
Always verify against the raw, unparsed body — re-serializing parsed JSON can change byte-for-byte formatting and break the signature check.
Delivery semantics
- Fire-and-forget, no retry queue. A delivery attempt has a 5-second timeout; if your endpoint is slow, times out, or returns a non-2xx status, the event is logged as failed but not retried automatically. Design your endpoint to respond quickly (acknowledge, then process asynchronously) and treat
GET /api/v1/orders/{id}as the source of truth if you suspect a missed event. - Test and live are isolated. A test-mode order only ever triggers your
mode=testendpoint, and vice versa. - Respond with any
2xxstatus to acknowledge. Non-2xx and timeouts are recorded but not retried in v1.
Errors
| Status | Meaning |
|---|---|
400 Bad Request | mode query param missing/invalid, or url isn't a valid URL. |
401 Unauthorized | Missing/invalid API key or session. |
LetterAPIPrint and post letters in the UK, from one letter to thousands.
© 2026 LetterAPI. All rights reserved.