# 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. Source: https://swaglists.com/docs/webhooks Webhooks push [events](https://swaglists.com/docs/events.md) 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](https://www.standardwebhooks.com/)** 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. ```bash title="cURL" 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" }' ``` ```js title="Node.js" const res = await fetch("https://swaglists.com/api/v1/webhook_endpoints", { method: "POST", headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://example.com/webhooks/print", enabled_events: ["*"] }), }); const endpoint = await res.json(); console.log(endpoint.id, endpoint.secret); // store the secret now — it is not shown again ``` ```python title="Python" endpoint = requests.post( "https://swaglists.com/api/v1/webhook_endpoints", headers={"Authorization": f"Bearer {os.environ['API_KEY']}"}, json={"url": "https://example.com/webhooks/print", "enabled_events": ["*"]}, ).json() print(endpoint["id"], endpoint["secret"]) # store the secret now — it is not shown again ``` - `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](#rotation). - 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`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#order) | | [`order.updated`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#order) | | [`order.confirmed`](https://swaglists.com/docs/events.md#order.confirmed) | A draft order was confirmed (`POST /orders/{id}/confirm`). Followed by `order.processing` or `order.on_hold`. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.on_hold`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#order) | | [`order.processing`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#order) | | [`order.in_production`](https://swaglists.com/docs/events.md#order.in_production) | Production started. From now on the order can no longer be canceled. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.partially_shipped`](https://swaglists.com/docs/events.md#order.partially_shipped) | Some items shipped; more parcels follow. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.shipped`](https://swaglists.com/docs/events.md#order.shipped) | Every item shipped. `shipments` has the carrier, tracking number and tracking URL of each parcel. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.delivered`](https://swaglists.com/docs/events.md#order.delivered) | Every shipment was delivered. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.canceled`](https://swaglists.com/docs/events.md#order.canceled) | The order was canceled (by you or by support). Reserved credits were released and spent credits refunded to your wallet. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.failed`](https://swaglists.com/docs/events.md#order.failed) | The order could not be produced. Reserved credits were released; no further events follow. | [order](https://swaglists.com/docs/objects.md#order) | | [`order.refunded`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#order) | | [`shipment.created`](https://swaglists.com/docs/events.md#shipment.created) | A parcel was handed to the carrier and has tracking information. Sent together with `order.partially_shipped` or `order.shipped`. | [shipment](https://swaglists.com/docs/objects.md#shipment) | | [`shipment.updated`](https://swaglists.com/docs/events.md#shipment.updated) | A parcel's tracking status changed (e.g. `in_transit` → `out_for_delivery`, `exception`, `returned`). | [shipment](https://swaglists.com/docs/objects.md#shipment) | | [`shipment.delivered`](https://swaglists.com/docs/events.md#shipment.delivered) | A parcel was delivered. | [shipment](https://swaglists.com/docs/objects.md#shipment) | | [`product.created`](https://swaglists.com/docs/events.md#product.created) | A product was created (API or designer). Mockups are rendered afterwards and announced with `product.updated`. | [product](https://swaglists.com/docs/objects.md#product) | | [`product.updated`](https://swaglists.com/docs/events.md#product.updated) | A product changed: details, designs, variants, or its mockups finished rendering. | [product](https://swaglists.com/docs/objects.md#product) | | [`product.deleted`](https://swaglists.com/docs/events.md#product.deleted) | A product was archived (`DELETE /products/{id}`). Its SKUs stay reserved; restoring it sends `product.updated`. | [product](https://swaglists.com/docs/objects.md#product) | | [`catalog.price_changed`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#catalog_price_change) | | [`wallet.credited`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#transaction) | | [`wallet.low_balance`](https://swaglists.com/docs/events.md#wallet.low_balance) | Available credits fell below your low-balance threshold (sent once until the balance recovers). | [wallet](https://swaglists.com/docs/objects.md#wallet) | | [`topup.succeeded`](https://swaglists.com/docs/events.md#topup.succeeded) | A credit purchase was paid and the credits (plus any bonus) were added. | [topup](https://swaglists.com/docs/objects.md#topup) | | [`topup.failed`](https://swaglists.com/docs/events.md#topup.failed) | A credit purchase failed (e.g. the card was declined or a bank debit was returned). | [topup](https://swaglists.com/docs/objects.md#topup) | | [`file.processed`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#file) | | [`webhook_endpoint.test`](https://swaglists.com/docs/events.md#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](https://swaglists.com/docs/objects.md#webhook_endpoint) | Full descriptions and an example payload for each type are in the [events reference](https://swaglists.com/docs/events.md). ### 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](#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](https://swaglists.com/docs/objects.md#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= ``` ```jsonc title="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,` — several space-separated signatures during a [secret rotation](#rotation). | - `data.object` is the full resource as of the event, in the same shape the API returns (an [order](https://swaglists.com/docs/objects.md#order), [shipment](https://swaglists.com/docs/objects.md#shipment), [product](https://swaglists.com/docs/objects.md#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')`. ### With the official library (recommended) ```js title="Node.js" // 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); ``` ```python title="Python" # pip install standardwebhooks flask import os from flask import Flask, request from standardwebhooks.webhooks import Webhook app = Flask(__name__) wh = Webhook(os.environ["WEBHOOK_SECRET"]) # "whsec_…" @app.post("/webhooks/print") def webhook(): try: event = wh.verify(request.get_data(), dict(request.headers)) # raw bytes except Exception: return "invalid signature", 400 enqueue(event) # process asynchronously return "", 204 ``` ```php title="PHP" $_SERVER['HTTP_WEBHOOK_ID'] ?? '', 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', ]; try { $event = (new Webhook(getenv('WEBHOOK_SECRET')))->verify($payload, $headers); } catch (Exception $e) { http_response_code(400); exit('invalid signature'); } enqueue($event); // process asynchronously http_response_code(204); ``` ```ruby title="Ruby" # gem install standardwebhooks (Rails controller shown) require "standardwebhooks" class PrintWebhooksController < ActionController::API WEBHOOK = StandardWebhooks::Webhook.new(ENV.fetch("WEBHOOK_SECRET")) def create event = WEBHOOK.verify(request.raw_post, { "webhook-id" => request.headers["webhook-id"], "webhook-timestamp" => request.headers["webhook-timestamp"], "webhook-signature" => request.headers["webhook-signature"], }) PrintWebhookJob.perform_later(event) # process asynchronously head :no_content rescue StandardError head :bad_request end end ``` ```go title="Go" // go get github.com/standard-webhooks/standard-webhooks/libraries/go package main import ( "io" "log" "net/http" "os" standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go" ) func main() { wh, err := standardwebhooks.NewWebhook(os.Getenv("WEBHOOK_SECRET")) if err != nil { log.Fatal(err) } http.HandleFunc("/webhooks/print", func(w http.ResponseWriter, r *http.Request) { payload, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if err := wh.Verify(payload, r.Header); err != nil { http.Error(w, "invalid signature", http.StatusBadRequest) return } enqueue(payload) // process asynchronously w.WriteHeader(http.StatusNoContent) }) log.Fatal(http.ListenAndServe(":3000", nil)) } ``` 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, ".."))`. 5. Compare it, in constant time, with each `v1,` entry of `webhook-signature` (space-separated). One match is enough; ignore other versions. ```js title="Node.js" 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 }); } ``` ```python title="Python" import base64, hashlib, hmac, json, time def verify_webhook(raw: bytes, headers, secret: str, tolerance: int = 300) -> dict: h = {k.lower(): v for k, v in headers.items()} msg_id, ts, signatures = h.get("webhook-id"), h.get("webhook-timestamp"), h.get("webhook-signature") if not (msg_id and ts and signatures): raise ValueError("missing webhook headers") if not ts.isdigit() or abs(int(time.time()) - int(ts)) > tolerance: raise ValueError("timestamp outside tolerance") key = base64.b64decode(secret[6:] if secret.startswith("whsec_") else secret) expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw, hashlib.sha256).digest()).decode() for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return json.loads(raw) raise ValueError("invalid signature") # Flask: event = verify_webhook(request.get_data(), request.headers, os.environ["WEBHOOK_SECRET"]) ``` ```php title="PHP" $tolerance) throw new RuntimeException('timestamp outside tolerance'); $key = base64_decode(str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret, true); $expected = base64_encode(hash_hmac('sha256', "{$id}.{$ts}.{$raw}", $key, true)); foreach (explode(' ', $signatures) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) return json_decode($raw, true); } throw new RuntimeException('invalid signature'); } // $event = verify_webhook(file_get_contents('php://input'), getallheaders(), getenv('WEBHOOK_SECRET')); ``` ## 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: ```bash title="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 `id`s (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](#lifecycle-steps) 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.