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

StatusWhat it meansCancelChange address
receivedWe have the order and it's waiting to be printed.YesYes
in_productionPrinting, binding and packing.NoUntil the label prints
shippedThe carrier has it. The order's shipping object has the carrier and tracking numbers.NoNo
needs_attentionSomething stopped the order, such as a file that won't download. status_reason says what; once it's fixed the order carries on.YesYes
cancelledCancelled before production. The whole charge came back as credit.NoNo
rejectedQuantum can't make it; status_reason says why. The whole charge comes back as credit.NoNo

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 id you have already handled; a retry keeps the same id.
  • Events can arrive out of order, since a retried event can land after a later one. Go by the order's status and updated_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.

Verify a webhook
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);
    });
}

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:

Advance a sandbox order
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.

Order statuses and webhooks · Quantum Production