Webhooks

Updated 9/17/2026

Webhooks

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=live
PUT /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

EventFires when
order.paidAn order's payment is confirmed (both credit- and Stripe-funded).
order.printingThe print shop marks the order as printing.
order.postedA 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/json
X-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:
ts
import { 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);
}
python
import hashlib
import hmac
​
def 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=test endpoint, and vice versa.
  • Respond with any 2xx status to acknowledge. Non-2xx and timeouts are recorded but not retried in v1.

Errors

StatusMeaning
400 Bad Requestmode query param missing/invalid, or url isn't a valid URL.
401 UnauthorizedMissing/invalid API key or session.