Webhooks
Order events pushed to your server, signed with Standard Webhooks.
Queek pushes signed events to an HTTPS endpoint when something changes in a store: an order is paid, a product is edited, stock moves. You receive the event instead of polling for it.
Deliveries use Standard Webhooks signatures. The public Merchant API specification defines the topic catalogue and the payload schemas. A store can have up to 10 webhook endpoints.
How to subscribe. An app lists the topics it wants under
[webhooks] in queek.app.toml, and the SDK verifies and dispatches each delivery for you. The
Merchant API has no operation for creating or listing webhook endpoints, so do not look for one
in the reference.
Webhooks fire from a dev store exactly as they do from a live one, so you can prove your receiver before real orders reach it.
Topics
| Topic | Fires when |
|---|---|
orders/create | An order was placed, on any channel. |
orders/paid | An order moved to paid. |
orders/updated | An order changed — status, fulfilment stage or any merchant action. |
orders/fulfilled | An order was completed or delivered. |
orders/cancelled | An order was cancelled or rejected. |
products/create | A product was created in the store. |
products/update | A product changed — visible content, price or availability. |
products/delete | A product was deleted, including soft deletion. |
customers/create | A customer record was created for the store. |
customers/update | A customer profile field changed. |
customers/data_request | The merchant asked for the data held about a customer. Ids only; see Customer privacy topics. |
customers/redact | The merchant asked for a customer's personal data to be erased, and the cancellation window passed. Ids only; see Customer privacy topics. |
customers_email_marketing_consent/update | A customer's email marketing preference changed. |
customers_marketing_consent/update | A customer's SMS marketing consent changed. |
inventory_levels/update | A product or variant stock level changed, with the reason. |
locales/create | A locale was added to the store. |
locales/update | A locale was published or unpublished on the store. |
locales/destroy | A locale was removed from the store, with its translations. |
translations/update | Translations were registered on one resource. |
translations/delete | Translations were removed from one resource. |
orders/updated fires alongside the specific topic, so subscribe to it only if you want every
transition. An order created already paid or already fulfilled emits orders/create and
the matching specific topic.
Payload schemas
The envelope schemas and topic enum are published in the Merchant API OpenAPI document:
| Topics | Event envelope and data schema |
|---|---|
orders/* | QueekWebhookEvent, with data as OrderResource. |
products/* | QueekWebhookProductEvent, with data as QueekWebhookProductPayload. |
customers/data_request, customers/redact | QueekWebhookCustomerPrivacyEvent, with data as QueekWebhookCustomerPrivacyPayload. |
customers/create, customers/update | QueekWebhookCustomerEvent, with data as QueekWebhookCustomerPayload. |
inventory_levels/update | QueekWebhookInventoryLevelEvent, with data as QueekWebhookInventoryLevelPayload. |
customers_email_marketing_consent/update | QueekWebhookCustomerEmailMarketingConsentEvent, with data as QueekWebhookCustomerEmailMarketingConsentPayload. |
customers_marketing_consent/update | QueekWebhookCustomerSmsMarketingConsentEvent, with data as QueekWebhookCustomerSmsMarketingConsentPayload. |
locales/*, translations/* | Listed in QueekWebhookTopic. The public document defines no event data schema for these topics, so the fields of data are not part of the contract: treat the event as a signal that something changed and re-read the languages or the resource's translations from the API. See Translate store content. |
For an order event, the SDK handler receives the full envelope and the order is envelope.data.
There is no envelope.data.order wrapper. The data field is the order object itself, matching
the QueekWebhookEvent schema.
orders/updated also fires when the merchant changes an order's metafields on the
merchant API, even though no ordinary order column changed — an integrator keying on an ERP id
stored there would otherwise never learn it moved. It is emitted once: when the same request
also wrote metadata or created the order, the delivery that write already produced carries
the new values and no second one is sent.
products/* and customers/create / customers/update carry custom fields too: a product's
metafields (the fields marked storefront_visible), metafields on every variant in
variants[], and a customer's metafields. A change to a value alone sends products/update or
customers/update once. Collections and blog posts have no webhook topics. See
Custom fields.
The headers on a delivery
| Header | Example | Notes |
|---|---|---|
webhook-id | 9f0f7a0e-… | Unique per event. Dedupe on this — a retry reuses it. |
webhook-timestamp | 1614265330 | Unix seconds. Reject anything more than 300s from now. |
webhook-signature | v1,g0hM9S…= | One or more space-delimited signatures. |
X-Queek-Topic | orders/paid | The topic that fired. |
X-Queek-Api-Version | v1 | The payload contract version. |
Content-Type | application/json | |
User-Agent | Queek-Webhooks/1.0 |
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The MAC is HMAC-SHA256,
and the key is the decoded bytes of the secret's base64 half — the part after whsec_ —
not the printable string. Using the printable string as the key is the most common integration
bug.
Verify before you trust
Sign against the raw request body, byte for byte. Any framework that re-serialises JSON for you will break verification, which is exactly what it is there to do.
import crypto from 'node:crypto';
/**
* @param {string} secret The endpoint secret, `whsec_…`.
* @param {Record<string, string | undefined>} headers Lower-cased request headers.
* @param {Buffer} rawBody The request body exactly as received.
*/
export function verifyQueekWebhook(secret, headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader) return false;
// Replay window: 5 minutes.
if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300)) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const expected = Buffer.from(
'v1,' + crypto.createHmac('sha256', key).update(signed).digest('base64'),
);
// Several signatures while a secret rotation is open: any one may match.
return signatureHeader.split(' ').some((candidate) => {
const candidateBuffer = Buffer.from(candidate);
return (
candidateBuffer.length === expected.length &&
crypto.timingSafeEqual(candidateBuffer, expected)
);
});
}With Express, keep the raw bytes: express.raw({ type: 'application/json' }) on the webhook
route, and parse only after the signature checks out.
Check your implementation against this vector
Secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, id msg_p5jXN8AQM9LWM0D4loKWxJek,
timestamp 1614265330, body {"test": 2432232314} must produce
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=. It is the Standard Webhooks published
vector, and Queek's own test suite pins the same one.
The payload
The body is one JSON envelope per event. Its exact fields and data type depend on the topic;
the example below is an order event:
{
"id": "9f0f7a0e-3c1e-4c7b-9a4b-2d8f1e6c0a11",
"topic": "orders/paid",
"api_version": "v1",
"created_at": "2026-09-18T10:42:07+01:00",
"data": { "id": 48213, "uid": "019a…", "order_number": "…", "…": "…" }
}| Field | Notes |
|---|---|
id | The event id — identical to the webhook-id header. Dedupe on it. |
topic | One of the topics above. |
api_version | v1. Additive within a major. |
created_at | ISO 8601, when the event was recorded. |
data | The order, in the shape below. |
For this order event, data is the order. Its id is the short integer id you can quote to a human or a model; the
UUID rides along as uid. Money is a decimal string, never a JSON number, because a binary
float loses minor units.
| Field | Type | Notes |
|---|---|---|
id | integer | The order's short id. |
uid | string | The order's UUID. |
order_number | string | What the merchant and customer see. |
status, payment_status, payment_method | string | Tolerate values you do not know. |
fulfillment_status | string | fulfilled or unfulfilled. |
cancelled, cancel_reason | boolean, string | null | |
channel, platform, delivery_method | string | Where the order came from. platform may be null. |
currency | string | ISO code, uppercase. |
subtotal, discount_total, shipping_total, tax_total, total | string | Decimal strings. |
customer | object | null | The store's own record of the buyer: id, name, email, phone. id is the store customer id — the same id GET /customers/{customer} and the customers/* events use. Null when the order has no store customer record (marketplace orders, imports placed under a guest). |
shipping_address | object | null | address, latitude, longitude. |
shipment | object | null | Carrier tracking on a shipping order: carrier_name, tracking_number, tracking_url, zone_name, shipped_at, estimated_delivery_at, delivered_at. Null when the order has none. |
line_items[] | array | id, product_id, product_uid, title, variant_title, quantity, unit_price, total. |
note | string | null | The merchant's note. |
metafields | object | Typed fields the merchant defined on the order, keyed namespace.key. See Metafields. |
metadata | object | The opaque values the merchant's integration wrote — your own ERP id comes back to you here. |
created_at, updated_at, fulfilled_at | ISO 8601 | null |
Internal ledger facts — transaction ids, settlement, commission, platform fees, delivery PINs — are never in a webhook payload.
Delivery, retries and failure
Webhook delivery uses an at-least-once attempt model: a receiver can see the same event more
than once, and events have no ordering guarantee. Delivery can still end unsuccessfully when
its retries are exhausted, so keep a reconciliation path. A retry of the same delivery keeps
its webhook-id and envelope id; use that event id as a durable dedupe key. A manual replay
from the webhook delivery log creates a new delivery with a new event id, so event-id dedupe
alone does not make your business operation idempotent.
Return any 2xx response to acknowledge a delivery. A non-2xx response, connection failure,
or timeout is a failed attempt. Queek does not follow redirects: a 3xx response is a failure
and is retried like other non-2xx responses. 5xx responses and timeouts are retried on the
same schedule. The receiver has a 5-second HTTP timeout (with a 3-second connection timeout),
so verify the signature, durably record or enqueue the event, and acknowledge promptly. Do
long-running work after the acknowledgement.
There are up to 8 attempts total: the first attempt and 7 retries, delayed by 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, and 2 hours. An endpoint whose failure streak reaches 24 hours is disabled and the merchant is notified. A successful delivery resets that failure streak. A webhook host that resolves to a private or blocked address is refused without a delivery attempt; host addresses are checked again at send time. An unresolvable DNS name is retried on the normal schedule.
The webhook delivery log can replay a retained delivery when its endpoint is active and its topic is still supported. Replay creates a new event id. The Merchant API has no webhook replay operation. Delivery history is retained for a configurable period (7 days by default), so build your own durable event record and reconciliation path.
Make the receiver idempotent
- Verify the signature against the raw request bytes before trusting the payload.
- Insert the event id into a durable inbox table with a unique constraint. If it already exists, acknowledge it without running the handler again.
- Commit the inbox record or enqueue the work durably before returning
2xx. - Make side effects idempotent by resource and operation as well. A manual replay has a new event id even when it represents the same underlying change.
- Treat events as change notifications. Re-read the resource when your local state needs to converge with the current store state.
Reconcile after a missed delivery
Use the resource id from the event to fetch the current object, then walk the resource list
with its cursor to catch changes that arrived while your receiver was unavailable. For example,
read an order with GET /orders/{order}, then scan GET /orders?limit=100 and continue with
each response's next_cursor as the next request's starting_after. The same list pattern is
available for GET /products and GET /customers. Upsert by resource id and compare
updated_at; repeat list walks periodically because these endpoints expose current resources,
not a historical event log.
Secret rotation
After secret rotation, Queek keeps the previous secret valid for 24 hours and sends signatures for both secrets during that period, separated by spaces. Accept a delivery when any supplied signature verifies. Keep both secrets configured during the grace period, then remove the old secret.
Before you point it at production
- HTTPS only. Plain
http://is accepted outside production so you can hit your own machine while building. - Queek refuses endpoints that resolve to loopback, private, link-local or metadata addresses
— checked when the URL is saved and again on every attempt — and never follows redirects, so
a
302to an internal address is inert. - The payload contains customer personal data (name, email, phone, address). The merchant is its controller; treat it accordingly on your side.