Webhooks
Order events pushed to your server, signed with Standard Webhooks.
Queek pushes order events to an HTTPS endpoint you own, so you can stop polling. Every delivery is signed with Standard Webhooks — the same scheme a dozen platforms use, so any off-the-shelf verification library works.
Subscriptions are created by the merchant, on the merchant side: the dashboard's own API
(/api/v1/biz/vendor/webhooks, vendor-authenticated) registers a URL, the topics it wants and
returns the signing secret. The secret is shown once, at the call that mints it. The
storefront API documented on this site neither creates nor lists them.
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. |
orders/updated fires alongside the specific topic, so subscribe to it only if you want every
transition. Orders are the whole v1 catalogue: products, inventory and customers are not
emitted yet.
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 (see rotation). |
X-Queek-Topic | orders/paid | The topic that fired. |
X-Queek-Api-Version | v1 | The payload contract version. |
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. That single detail 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';
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 expected =
'v1,' +
crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
const expectedBuffer = Buffer.from(expected);
// Several signatures while a secret rotation is open — any one may match.
return signatureHeader
.split(' ')
.some(
(candidate) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(candidate), expectedBuffer),
);
}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
One JSON object per event — the order, in the shape below. 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 kobo.
| 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. |
currency | string | ISO code, uppercase. |
subtotal, discount_total, shipping_total, tax_total, total | string | Decimal strings. |
customer | object | null | id, name, email, phone. |
shipping_address | object | null | address, latitude, longitude. |
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. |
metadata | object | The opaque values your integration wrote — your own ERP id comes back to you here. |
created_at, updated_at, fulfilled_at | ISO 8601 |
Internal ledger facts — transaction ids, settlement, commission, platform fees, delivery PINs — are never in a webhook payload.
Delivery, retries and failure
Acknowledge with any 2xx within five seconds. Queue the work; do not do it inside the
request.
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| After | — | 5s | 30s | 2m | 10m | 30m | 1h | 2h |
Eight attempts span roughly 3h42m. After the last one the delivery is dead and only a replay moves it. An endpoint that has been failing continuously for 24 hours is disabled automatically and the merchant is notified; one success clears the streak.
Every attempt is recorded — status, response code, truncated response body, duration — and the merchant can inspect or replay a delivery from the dashboard API for 7 days. A replay is sent as a new event id, so your dedupe does not swallow it.
Rotating a secret
Rotation mints a new secret and keeps the old one valid for 24 hours. During that window the
webhook-signature header carries both signatures, space delimited, and a receiver accepts
the message if either verifies — which is why the snippets above loop over the candidates
instead of comparing once. Roll your configuration any time inside the window with zero
dropped events.
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,
and never follows redirects — 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.