Skip to content

Webhooks

Get notified when orders are produced, shipped and delivered — endpoints, events, the payload, signature verification (libraries and manual HMAC), retries, replays and best practices.

Webhooks push events to your server as they happen — an order went into production, shipped, was delivered; a product's mockups are ready; credits arrived. Deliveries follow the Standard Webhooks specification, so you can verify them with an official library in any language.

1. Create an endpoint

Register an HTTPS URL in Developers → Webhooks or with the API. Each mode has its own endpoints: test endpoints receive test events, live endpoints live events.

curl -X POST https://swaglists.com/api/v1/webhook_endpoints \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://example.com/webhooks/print",
    "enabled_events": ["order.in_production", "order.shipped", "order.delivered", "order.on_hold", "order.failed", "order.canceled"],
    "description": "Order status sync"
  }'
  • enabled_events — a list of event types or ["*"] for everything (the default). New event types are added over time; with ["*"] your handler must ignore types it does not know.
  • The response includes the signing secret (whsec_…) once. Store it like a password. Lost it? Rotate it.
  • Live endpoints must use https:// and a public host. Test endpoints may use http://.
  • Up to 16 endpoints per mode.

Send a test event to check your handler: POST /webhook_endpoints/{id}/test (or Send test event in the dashboard) delivers a webhook_endpoint.test event to that endpoint only.

2. Events

Event When it fires data.object
order.created An order was created through the API or the dashboard — as a draft (confirm: false) or confirmed. A confirmed order also sends order.processing or order.on_hold. order
order.updated A draft's details changed (recipient, items, shipping method, metadata, external id), or something about the order changed that has no more specific event. order
order.confirmed A draft order was confirmed (POST /orders/{id}/confirm). Followed by order.processing or order.on_hold. order
order.on_hold The order is waiting: not enough credits (hold_reason: insufficient_funds), or held for review, artwork, address or a production issue. order
order.processing Credits were reserved and the order was sent to production — right after confirmation, or when a held order is released (e.g. after you added credits). order
order.in_production Production started. From now on the order can no longer be canceled. order
order.partially_shipped Some items shipped; more parcels follow. order
order.shipped Every item shipped. shipments has the carrier, tracking number and tracking URL of each parcel. order
order.delivered Every shipment was delivered. order
order.canceled The order was canceled (by you or by support). Reserved credits were released and spent credits refunded to your wallet. order
order.failed The order could not be produced. Reserved credits were released; no further events follow. order
order.refunded Credits were refunded to your wallet for this order (full or partial), for example when support accepts a claim for a damaged or misprinted item. order
shipment.created A parcel was handed to the carrier and has tracking information. Sent together with order.partially_shipped or order.shipped. shipment
shipment.updated A parcel's tracking status changed (e.g. in_transit → out_for_delivery, exception, returned). shipment
shipment.delivered A parcel was delivered. shipment
product.created A product was created (API or designer). Mockups are rendered afterwards and announced with product.updated. product
product.updated A product changed: details, designs, variants, or its mockups finished rendering. product
product.deleted A product was archived (DELETE /products/{id}). Its SKUs stay reserved; restoring it sends product.updated. product
catalog.price_changed The wholesale price of catalog variants used by your products changed (our costs or your price list changed). Re-fetch prices and adjust your store if needed. catalog_price_change
wallet.credited Credits were added to the wallet: a top-up and its bonus (one event each), a refund for an order, an adjustment by support, or test credits. transaction
wallet.low_balance Available credits fell below your low-balance threshold (sent once until the balance recovers). wallet
topup.succeeded A credit purchase was paid and the credits (plus any bonus) were added. topup
topup.failed A credit purchase failed (e.g. the card was declined or a bank debit was returned). topup
file.processed An uploaded file finished processing (status is ready, or failed). url is null in the event — fetch the file for a download URL. file
webhook_endpoint.test Sent on request with POST /webhook_endpoints/{id}/test (or the dashboard) to that endpoint only. Use it to check your signature verification. Not listed by GET /events unless you filter for it. webhook_endpoint

Full descriptions and an example payload for each type are in the events reference.

Every lifecycle step, exactly once

An order moves through order.in_production → order.shipped → order.delivered (with order.partially_shipped when it ships in several parcels), and each parcel through shipment.created → shipment.updated (once per tracking change) → shipment.delivered. You receive every one of these events exactly once, even when production moves faster than our status updates — for example when an order is produced, shipped and delivered between two of our status checks. The steps are then recorded together, with that update, in lifecycle order:

Update received Events, in order
processing → delivered order.in_production, shipment.created, order.shipped, shipment.delivered, order.delivered
processing → shipped order.in_production, shipment.created, order.shipped
in_production → delivered shipment.created, order.shipped, shipment.delivered, order.delivered
processing → one of two parcels shipped order.in_production, shipment.created, order.partially_shipped
shipped → out for delivery shipment.updated
  • data.object is the current state, as of the end of the update — the same rule as for every event. A back-filled order.shipped can therefore already show "status": "delivered". Use the event type to know which step happened and data.object for where the order is now.
  • data.previous_attributes.status forms a chain across the events of one update: processing on order.in_production, in_production on order.shipped, shipped on order.delivered.
  • Deliveries are spaced about one second apart, so a healthy endpoint receives such a burst in lifecycle order. Retries can still reorder events (see best practices).
  • Subscriptions are respected per event. An endpoint subscribed only to order.shipped receives it even if the order has already been delivered by then.
  • updated_at always moves forward. Every change to an order — including a parcel-only change such as "out for delivery" — sets a later updated_at on the order. The order events of one such burst carry the same updated_at; events of later updates always carry a later one.
  • The order timeline (GET /orders/{id}/events and the dashboard) shows one entry per step as well.

3. The request

We POST the event as JSON:

HTTP
POST /webhooks/print HTTP/1.1
Content-Type: application/json
User-Agent: Swaglists-Webhooks/1.0
webhook-id: evt_01k8x4b2r1t3y5u7i9o1p3a5s7
webhook-timestamp: 1761057125
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Body (abridged)
{
  "id": "evt_01k8x4b2r1t3y5u7i9o1p3a5s7",
  "object": "event",
  "type": "order.shipped",
  "api_version": "2026-10-01",
  "created_at": "2026-10-21T14:32:05.000Z",
  "livemode": true,
  "data": {
    "object": { "object": "order", "id": "ord_01k8x4b2q9w7e3r5t1y8u6i0pa", "external_id": "1042", "status": "shipped", "shipments": [ … ] },
    "previous_attributes": { "status": "in_production" }
  }
}
Header Value
webhook-id The event id. Identical on every retry — use it to deduplicate.
webhook-timestamp Unix time (seconds) of this delivery attempt.
webhook-signature v1,<base64 signature> — several space-separated signatures during a secret rotation.
  • data.object is the full resource as of the event, in the same shape the API returns (an order, shipment, product, …).
  • data.previous_attributes (on status changes and *.updated events) holds the old values of fields that changed.
  • The same event objects are listed by GET /events for 30 days.

4. Verify signatures

Every delivery is signed with your endpoint's secret: an HMAC-SHA256 over webhook-id, webhook-timestamp and the raw request body. Always verify before trusting a webhook — anyone can send a request to your URL.

Important

Verify the raw body bytes exactly as received. Parsing the JSON first and re-serializing it changes the bytes and the signature will not match. In Express use express.raw(); in Next.js await request.text(); in Flask request.get_data(); in PHP file_get_contents('php://input').

// npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.WEBHOOK_SECRET); // "whsec_…"

app.post("/webhooks/print", express.raw({ type: "*/*" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString("utf8"), req.headers); // throws if invalid or too old
  } catch {
    return res.status(400).send("invalid signature");
  }
  enqueue(event); // process asynchronously
  res.sendStatus(204);
});

app.listen(3000);

The libraries also reject deliveries whose webhook-timestamp is more than 5 minutes away from your clock (replay protection) — keep your server's clock in sync.

Manually (HMAC-SHA256)

If you cannot add a dependency, verification is a few lines:

  1. Take webhook-id, webhook-timestamp and the raw body.
  2. Reject the request if the timestamp is not an integer or is more than 300 seconds from now.
  3. Decode the secret: base64-decode everything after whsec_ — those bytes are the HMAC key.
  4. Compute base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")).
  5. Compare it, in constant time, with each v1,<signature> entry of webhook-signature (space-separated). One match is enough; ignore other versions.
import { createHmac, timingSafeEqual } from "node:crypto";

/** headers: a Fetch `Headers` object or Node's lowercase `req.headers`. Returns the parsed event or throws. */
export function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const get = (name) => (typeof headers.get === "function" ? headers.get(name) : headers[name]);
  const id = get("webhook-id");
  const timestamp = get("webhook-timestamp");
  const signatures = get("webhook-signature");
  if (!id || !timestamp || !signatures) throw new Error("missing webhook headers");
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    throw new Error("timestamp outside tolerance");
  }
  const key = Buffer.from(secret.startsWith("whsec_") ? secret.slice(6) : secret, "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  for (const entry of signatures.split(" ")) {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) continue;
    const received = Buffer.from(signature, "base64");
    if (received.length === expected.length && timingSafeEqual(received, expected)) return JSON.parse(rawBody);
  }
  throw new Error("invalid signature");
}

// Next.js route handler (app/api/print-webhooks/route.js)
export async function POST(request) {
  const raw = await request.text(); // raw body BEFORE parsing
  let event;
  try {
    event = verifyWebhook(raw, request.headers, process.env.WEBHOOK_SECRET);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
  await enqueue(event);
  return new Response(null, { status: 204 });
}

5. Respond quickly, process asynchronously

  • Return any 2xx status within 15 seconds. Anything else — 3xx (redirects are not followed), 4xx, 5xx, a timeout or a connection error — is a failure and will be retried.
  • Do the work after responding: put the event on a queue or into a database table and process it in the background.
  • Answer 410 Gone to stop deliveries permanently: the endpoint is disabled immediately.
  • To slow us down, answer 429 or 503 with a Retry-After header; we wait at least that long before the next attempt.

6. Retries

Failed deliveries are retried with increasing delays:

Attempt Delay after the previous failure Time since the event (approx.)
1 — (immediately) 0
2 5 seconds 5 seconds
3 5 minutes 5 minutes
4 30 minutes 35 minutes
5 2 hours 2.6 hours
6 5 hours 7.6 hours
7 10 hours 17.6 hours
8 10 hours 27.6 hours

That is 8 attempts over about 28 hours. Each attempt times out after 15 seconds. If your endpoint answers with a Retry-After header longer than the next delay, we wait that long instead (up to 24 hours). An endpoint whose deliveries all fail for 5 days is disabled automatically (you get an email); an endpoint that answers 410 Gone is disabled immediately. Events are kept for 30 days.

You can see every attempt with its response status in Developers → Webhooks (per endpoint) and Developers → Events (per event).

7. Replay and reconcile

  • Resend from the dashboard — open the event in Developers → Events and resend it to every subscribed endpoint, or resend a single delivery. Resends use the same event id, so your deduplication still works.
  • Catch up after downtime — list what you missed with GET /events (newest first, last 30 days), for example GET /events?type=order.*&created[gte]=2026-10-20T00:00:00Z. The events are the same objects as the webhook bodies. Process them with the same idempotent handler. Start a little before the last event you processed — created[gte] = its created_at minus 10 minutes — and skip the event ids you already have: an event is listed only once the change that created it is saved, so it can appear slightly after events with a later created_at.

8. Rotate the secret

POST /webhook_endpoints/{id}/rotate_secret (or Rotate secret on the endpoint page) returns a new secret, once. With expire_old_in_seconds (up to 86,400) we sign with both secrets until the old one expires, so you can deploy without dropping deliveries:

cURL
curl -X POST https://swaglists.com/api/v1/webhook_endpoints/we_01k8x2m4n6b8v0c2x4z6a8s0d2/rotate_secret \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "expire_old_in_seconds": 3600 }'

During the overlap webhook-signature carries two v1, signatures; verification succeeds with either secret. Deploy the new secret, then let the old one expire.

Best practices

  • Deduplicate by event id. Deliveries are at least once: the same event can arrive more than once (retries, resends). Store processed ids (or webhook-id) and skip duplicates.
  • Don't rely on order. Events can arrive out of order. Use data.object.status and timestamps (updated_at, shipped_at, …) to decide what is newest — never move an order backwards (for example from shipped to in_production). An event whose data.object.updated_at is older than the one you stored is stale; events of the same update share one updated_at, so treat equal values as current. When in doubt, fetch the current object with GET /orders/{id}.
  • Ignore what you don't know. New event types and new fields can appear at any time.
  • Verify every request and reject unsigned or stale ones.
  • Subscribe narrowly in production (only the types you handle) and keep separate endpoints for test and live.
  • Monitor failures. We email your team when an endpoint is disabled; re-enable it with PATCH /webhook_endpoints/{id} {"status": "enabled"} after fixing it, then reconcile with GET /events.

Managing endpoints

Request What it does
GET /webhook_endpoints List endpoints of the key's mode (status filter).
GET /webhook_endpoints/{id} Retrieve an endpoint (without its secret).
PATCH /webhook_endpoints/{id} Change url, enabled_events, description; disable or re-enable with status.
DELETE /webhook_endpoints/{id} Delete the endpoint and its delivery history.
POST /webhook_endpoints/{id}/rotate_secret New signing secret (optional overlap).
POST /webhook_endpoints/{id}/test Send a webhook_endpoint.test event to this endpoint.
GET /events, GET /events/{id} Events of the last 30 days.

Scopes: webhooks:read to read, webhooks:write to manage.