Webhooks
Get told when an order changes instead of polling GET /orders. We POST a signed JSON payload to an HTTPS endpoint you own, and retry until you acknowledge it.
Base URL: https://developer.allthe.com/api/v1
Required scopes: orders:read to list, orders:write to create, change or delete.
Endpoints
| Method | Path | Scope |
| ------ | ----------------- | -------------- |
| GET | /webhooks | orders:read |
| POST | /webhooks | orders:write |
| GET | /webhooks/{id} | orders:read |
| PATCH | /webhooks/{id} | orders:write |
| DELETE | /webhooks/{id} | orders:write |
Create a subscription
POST /webhooks
| Name | Type | Description |
| ------------- | -------- | --------------------------------------------------------------------------- |
| url | string | Required. Must be https. Where we POST. |
| events | string[] | Optional. Omit or send [] to receive everything, including events added later. |
| description | string | Optional label, max 200 characters. |
curl -X POST "https://developer.allthe.com/api/v1/webhooks" \
-H "Authorization: Bearer pat_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/allthe",
"events": ["order.dispatched", "order.cancelled"],
"description": "Production order sync"
}'
{
"id": "cmuk0y584000004l8x3h63vs6",
"url": "https://example.com/hooks/allthe",
"events": ["order.dispatched", "order.cancelled"],
"description": "Production order sync",
"active": true,
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
secretis returned exactly once, here. Every later read omits it. Store it before you close the response — if you lose it, delete the subscription and create a new one.
Unknown event names are rejected with 422 and the error lists the valid ones, so a typo fails at creation rather than silently never firing.
Events
| Event | Fires when |
| --- | --- |
| order.created | The order was placed and paid for. |
| order.payment_failed | The charge failed. The order exists but will not move until it is settled. |
| order.in_production | At least one line has started moving through production. |
| order.shipment_created | A shipment went out for part of an order, with its tracking. |
| order.dispatched | Every non-cancelled line has now shipped. |
| order.cancelled | Cancelled — through the API inside the cancellation window, or by our ops team. |
| order.on_hold | Held before production by ops. Paid for, and stopped moving. |
Subscribe to order.shipment_created if you ship multi-line orders. order.dispatched only fires once every line has gone, so an order that ships in two parcels a week apart produces one dispatched at the end. Without shipment_created your customer has half their goods and you have heard nothing.
There is deliberately no order.delivered or order.returned: nothing in our pipeline advances an order past dispatched today, and promising an event we never send would be worse than omitting it.
Status vocabulary
The status field speaks a public six-value vocabulary — pending, confirmed, in_production, dispatched, delivered, cancelled — the same one GET /orders returns. Our internal states are more granular and describe which print partner holds the work; those collapse into the six above and never reach you.
Read status as "where the order is now", not "what just happened" — that is what event is for. They can differ: an order.shipment_created for the first of two lines carries status: "pending" or "in_production", not "dispatched".
The payload
Every delivery has the same envelope. Event-specific fields live under data.
{
"id": "cmukf31qa000104l4h2n8xkzc",
"event": "order.dispatched",
"created_at": "2026-09-27T21:52:40.361Z",
"data": {
"reference": "SMP-ALGQS4ATP",
"status": "dispatched",
"carrier": "Royal Mail",
"tracking_numbers": ["WEBHOOKTEST0001GB", "WEBHOOKTEST0002GB"]
}
}
data.reference is on every event and is the human order reference you also see in GET /orders. Selected shapes:
// order.created
{ "order_id": "cmuk...", "reference": "SMP-ALGQS4ATP", "external_reference": "your-id-42",
"status": "confirmed", "total": 28.27, "currency": "GBP", "item_count": 2 }
// order.shipment_created — one per line
{ "reference": "SMP-ALGQS4ATP", "status": "in_production",
"shipment": { "tracking_number": "WEBHOOKTEST0002GB", "carrier": "Royal Mail", "tracking_url": null,
"items": [{ "variant_sku": "YP023BKWH", "name": "Retro trucker cap (6606)", "quantity": 1 }] } }
// order.cancelled
{ "order_id": "cmuk...", "reference": "SMP-0YMUT8PLA", "status": "cancelled",
"refunded_gbp": 15.98, "cancelled_by": "api" }
// order.payment_failed
{ "order_id": "cmuk...", "reference": "SMP-...", "amount_gbp": 28.27, "paid_gbp": 0,
"remaining_gbp": 28.27, "failed_method": "card", "reason": "card_declined" }
// order.on_hold
{ "reference": "SMP-ERRHR3TTU", "status": "pending", "reason": "Awaiting artwork rework" }
Headers
| Header | Meaning |
| --- | --- |
| allthe-signature | Hex HMAC-SHA256. See below. |
| allthe-timestamp | Unix seconds, part of the signed material. |
| allthe-event | Event name, so you can route before parsing. |
| allthe-webhook-id | Which subscription this came from. |
| allthe-delivery-id | Unique per attempt-set. Use it to deduplicate. |
Requests carry User-Agent: allthe-webhooks/1. Redirects are not followed — point the subscription at the final URL.
Verifying the signature
The signature is HMAC-SHA256(secret, "{timestamp}.{rawBody}"), hex-encoded.
Verify against the raw request body, before any JSON parsing. Parsing and re-serialising changes key order and whitespace, and the signature will never match. This is the single most common webhook integration bug.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody: string, headers: Headers, secret: string): boolean {
const signature = headers.get('allthe-signature')
const timestamp = headers.get('allthe-timestamp')
if (!signature || !timestamp) return false
// Reject replays. The timestamp is inside the signed material precisely so
// a captured delivery cannot be replayed at you forever.
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(age) || age > 300) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(signature, 'utf8')
// Compare in constant time — a plain === leaks the secret a byte at a time.
return a.length === b.length && timingSafeEqual(a, b)
}
Our tolerance when signing is 5 minutes; 300 seconds above matches it. In Next.js, read the raw body with await req.text() and parse only after verifying.
Responding, retries and back-off
Return a 2xx and return it quickly. Anything else — including a 3xx — counts as a failure. We give each attempt 10 seconds, so acknowledge first and do your real work afterwards.
A failed delivery is retried up to 6 times, backing off roughly 1m, 5m, 25m, 2h, 10h. After the sixth it is abandoned.
If a subscription accumulates 15 consecutive failures across deliveries it is disabled: active flips to false and disabled_reason explains why. Nothing is delivered while disabled, and events that occur meanwhile are not backfilled when you re-enable. Re-enable with PATCH /webhooks/{id} and {"active": true}, which also resets the failure count.
What we do and do not guarantee
- At-least-once, not exactly-once. A timeout after you committed means we retry something you already handled. Deduplicate on the envelope's
id, which is stable across retries. - Delivery is not instant. Events are queued and flushed by a worker on a 5-minute cycle, so expect up to ~5 minutes between the transition and the POST. Do not build a UI that assumes a webhook lands the moment an API call returns.
- Order is not guaranteed. Two events generated seconds apart can arrive in either order. Use
created_atif sequence matters, and treat each event as a statement about the order rather than a delta. - Re-dispatching a line does not re-send
order.shipment_created. Corrected tracking arrives with the order-levelorder.dispatched, which always carries the final set.
Managing subscriptions
GET /webhooks/{id} includes the last few attempts, which is usually enough to diagnose a broken endpoint without adding logging on your side:
{
"id": "cmuk0y584000004l8x3h63vs6",
"url": "https://example.com/hooks/allthe",
"events": ["order.dispatched"],
"active": true,
"failure_count": 0,
"disabled_reason": null,
"recent_deliveries": [
{ "event": "order.dispatched", "status": "succeeded", "attempts": 1,
"response_code": 200, "error": null, "delivered_at": "2026-09-27T21:55:30.895Z" }
]
}
PATCH /webhooks/{id} accepts url, events, description and active. DELETE /webhooks/{id} removes it permanently — to pause instead, set active to false.
Testing
There is no sandbox, so a webhook test is a real order. The cheapest honest one is a sample order with quantity 1 sent to your own address, which you can then cancel inside the one-hour window for a full refund. That alone exercises order.created and order.cancelled.