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"
}'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 againendpoint = 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 againenabled_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 usehttp://. - 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.objectis the current state, as of the end of the update — the same rule as for every event. A back-filledorder.shippedcan therefore already show"status": "delivered". Use the eventtypeto know which step happened anddata.objectfor where the order is now.data.previous_attributes.statusforms a chain across the events of one update:processingonorder.in_production,in_productiononorder.shipped,shippedonorder.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.shippedreceives it even if the order has already been delivered by then. updated_atalways moves forward. Every change to an order — including a parcel-only change such as "out for delivery" — sets a laterupdated_aton the order. The order events of one such burst carry the sameupdated_at; events of later updates always carry a later one.- The order timeline (
GET /orders/{id}/eventsand the dashboard) shows one entry per step as well.
3. The request
We POST the event as JSON:
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={
"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.objectis 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*.updatedevents) holds the old values of fields that changed.- The same event objects are listed by
GET /eventsfor 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)
// 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);# 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
// composer require standard-webhooks/standard-webhooks
require __DIR__ . '/vendor/autoload.php';
use StandardWebhooks\Webhook;
$payload = file_get_contents('php://input');
$headers = [
'webhook-id' => $_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);# 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 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:
- Take
webhook-id,webhook-timestampand the raw body. - Reject the request if the timestamp is not an integer or is more than 300 seconds from now.
- Decode the secret: base64-decode everything after
whsec_— those bytes are the HMAC key. - Compute
base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw body>")). - Compare it, in constant time, with each
v1,<signature>entry ofwebhook-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 });
}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
function verify_webhook(string $raw, array $headers, string $secret, int $tolerance = 300): array {
$h = array_change_key_case($headers, CASE_LOWER);
$id = $h['webhook-id'] ?? '';
$ts = $h['webhook-timestamp'] ?? '';
$signatures = $h['webhook-signature'] ?? '';
if ($id === '' || $ts === '' || $signatures === '') throw new RuntimeException('missing webhook headers');
if (!ctype_digit($ts) || abs(time() - (int) $ts) > $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 Goneto stop deliveries permanently: the endpoint is disabled immediately. - To slow us down, answer
429or503with aRetry-Afterheader; 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 exampleGET /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]= itscreated_atminus 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 latercreated_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 -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 (orwebhook-id) and skip duplicates. - Don't rely on order. Events can arrive out of order. Use
data.object.statusand timestamps (updated_at,shipped_at, …) to decide what is newest — never move an order backwards (for example fromshippedtoin_production). An event whosedata.object.updated_atis older than the one you stored is stale; events of the same update share oneupdated_at, so treat equal values as current. When in doubt, fetch the current object withGET /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 withGET /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.