Order statuses and webhooks
Every order moves through the same few statuses, and each change sends your endpoint a signed webhook with the whole order in it.
Statuses
- received
- in_production
- shipped
- needs_attention pauses an order until the problem is fixed
- cancelled ends it before production, by you
- rejected ends it when we can't make it
| Status | What it means | Cancel | Change address |
|---|---|---|---|
received | We have the order and it's waiting to be printed. | Yes | Yes |
in_production | Printing, binding and packing. | No | Until the label prints |
shipped | The carrier has it. The order's shipping object has the carrier and tracking numbers. | No | No |
needs_attention | Something stopped the order, such as a file that won't download. status_reason says what; once it's fixed the order carries on. | Yes | Yes |
cancelled | Cancelled before production. The whole charge came back as credit. | No | No |
rejected | Quantum can't make it; status_reason says why. The whole charge comes back as credit. | No | No |
Shipped, cancelled and rejected are final. Every change is recorded in the order's status_history.
Events
Each status has an event named after it: order.received, order.in_production, order.shipped, order.needs_attention, order.cancelled and order.rejected. The body carries the order as it stood when the event happened, in the same shape GET /orders/{order_id} returns.
Example: order.shipped
{
"id": "0b8e7f4a-2c1d-4e3f-8a9b-7c6d5e4f3a2b",
"type": "order.shipped",
"created_at": "2026-10-15T16:47:22.000Z",
"mode": "live",
"data": {
"order": {
"id": "6f2d4c1e-8a3b-4e5f-9c7d-1b2a3c4d5e6f",
"external_id": "1042",
"mode": "live",
"proof": false,
"brand": {
"id": "9a4e2b7c-1d3f-4a5b-8c6d-2e1f0a9b8c7d",
"name": "Acme Books"
},
"status": "shipped",
"status_reason": null,
"status_history": [
{
"status": "received",
"at": "2026-10-13T15:04:05.000Z"
},
{
"status": "in_production",
"at": "2026-10-14T09:12:40.000Z"
},
{
"status": "shipped",
"at": "2026-10-15T16:47:22.000Z"
}
],
"ship_to": {
"name": "Ada Lovelace",
"line1": "12 Market St",
"city": "Philadelphia",
"state": "PA",
"postal_code": "19106",
"country": "US"
},
"shipping": {
"method": "standard",
"carrier": "UPS",
"tracking_numbers": [
"1Z4A6E290312345678"
],
"shipped_at": "2026-10-15T16:47:22.000Z"
},
"items": [
{
"item_id": null,
"sku": "FAMILY-ALBUM-9X9",
"quantity": 1,
"page_count": 40
}
],
"charges": {
"items": {
"amount": 1000,
"currency": "USD"
},
"discount": {
"amount": 0,
"currency": "USD"
},
"handling": {
"amount": 150,
"currency": "USD"
},
"shipping": {
"amount": 595,
"currency": "USD"
},
"tax": {
"amount": 0,
"currency": "USD"
},
"total": {
"amount": 1745,
"currency": "USD"
}
},
"ledger": [
{
"type": "order_charge",
"amount": {
"amount": -1745,
"currency": "USD"
},
"at": "2026-10-13T15:04:05.000Z"
}
],
"created_at": "2026-10-13T15:04:05.000Z",
"updated_at": "2026-10-15T16:47:22.000Z"
}
}
}Receiving events
Add an endpoint on the dashboard's Webhooks page. It must be an https:// address on a public host, and each endpoint receives events for one mode, sandbox or live. Its signing secret is shown once when you add it.
- Answer with any 2xx within 10 seconds, then do the work. Redirects aren't followed.
- Anything else is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours. Retries stop 24 hours after the event.
- Delivery is at least once. Skip an event whose
idyou have already handled; a retry keeps the sameid. - Events can arrive out of order, since a retried event can land after a later one. Go by the order's
statusandupdated_at, or fetch the order, rather than by arrival.
Checking signatures
Every request carries a Quantum-Signature header such as t=1790000000,v1=5f2c…. v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your endpoint's secret. Check it against the raw body, before any JSON parsing, and refuse a t more than 5 minutes from now. While a secret is being rotated the header carries two v1 values; accept either.
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the Quantum-Signature header, e.g. "t=1790000000,v1=5f2c…"
// rawBody: the request body exactly as received, before any JSON parsing
export function verifyQuantumSignature(rawBody, header, secret) {
const pairs = header.split(",").map((part) => part.trim().split("="));
const t = Number(pairs.find(([k]) => k === "t")?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
// During a secret rotation the header carries two v1 values; either may match.
return pairs
.filter(([k]) => k === "v1")
.some(([, v]) => {
const given = Buffer.from(v, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import hashlib
import hmac
import time
def verify_quantum_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""header: the Quantum-Signature header. raw_body: the request body exactly as received."""
pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
stamps = [v for k, v in pairs if k == "t"]
if not stamps or not stamps[0].isdigit() or abs(time.time() - int(stamps[0])) > 300:
return False
signed = stamps[0].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# During a secret rotation the header carries two v1 values; either may match.
return any(hmac.compare_digest(v, expected) for k, v in pairs if k == "v1")Testing in the sandbox
Sandbox orders move on their own: to in_production about 2 minutes after they arrive, and to shipped about 5 minutes after that, with a made-up tracking number. An order in needs_attention waits, as it would live. To move one yourself, or to try the other statuses, advance it:
curl -X POST "https://api.quantum-production.com/v1/sandbox/orders/$ORDER_ID/advance" \
-H "Authorization: Bearer $QUANTUM_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "needs_attention" }'See the API reference for every target it takes.
