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"
}

secret is 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_at if 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-level order.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.