Queek docs

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

TopicFires when
orders/createAn order was placed, on any channel.
orders/paidAn order moved to paid.
orders/updatedAn order changed — status, fulfilment stage or any merchant action.
orders/fulfilledAn order was completed or delivered.
orders/cancelledAn order was cancelled or rejected.
products/createA product was created in the store.
products/updateA product changed — visible content, price or availability.
products/deleteA product was deleted, including soft deletion.
customers/createA customer record was created for the store.
customers/updateA customer profile field changed.
customers/data_requestThe merchant asked for the data held about a customer. Ids only; see Customer privacy topics.
customers/redactThe 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/updateA customer's email marketing preference changed.
customers_marketing_consent/updateA customer's SMS marketing consent changed.
inventory_levels/updateA product or variant stock level changed, with the reason.
locales/createA locale was added to the store.
locales/updateA locale was published or unpublished on the store.
locales/destroyA locale was removed from the store, with its translations.
translations/updateTranslations were registered on one resource.
translations/deleteTranslations 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:

TopicsEvent envelope and data schema
orders/*QueekWebhookEvent, with data as OrderResource.
products/*QueekWebhookProductEvent, with data as QueekWebhookProductPayload.
customers/data_request, customers/redactQueekWebhookCustomerPrivacyEvent, with data as QueekWebhookCustomerPrivacyPayload.
customers/create, customers/updateQueekWebhookCustomerEvent, with data as QueekWebhookCustomerPayload.
inventory_levels/updateQueekWebhookInventoryLevelEvent, with data as QueekWebhookInventoryLevelPayload.
customers_email_marketing_consent/updateQueekWebhookCustomerEmailMarketingConsentEvent, with data as QueekWebhookCustomerEmailMarketingConsentPayload.
customers_marketing_consent/updateQueekWebhookCustomerSmsMarketingConsentEvent, 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

HeaderExampleNotes
webhook-id9f0f7a0e-…Unique per event. Dedupe on this — a retry reuses it.
webhook-timestamp1614265330Unix seconds. Reject anything more than 300s from now.
webhook-signaturev1,g0hM9S…=One or more space-delimited signatures.
X-Queek-Topicorders/paidThe topic that fired.
X-Queek-Api-Versionv1The payload contract version.
Content-Typeapplication/json
User-AgentQueek-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.

verify.js
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": "…", "…": "…" }
}
FieldNotes
idThe event id — identical to the webhook-id header. Dedupe on it.
topicOne of the topics above.
api_versionv1. Additive within a major.
created_atISO 8601, when the event was recorded.
dataThe 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.

FieldTypeNotes
idintegerThe order's short id.
uidstringThe order's UUID.
order_numberstringWhat the merchant and customer see.
status, payment_status, payment_methodstringTolerate values you do not know.
fulfillment_statusstringfulfilled or unfulfilled.
cancelled, cancel_reasonboolean, string | null
channel, platform, delivery_methodstringWhere the order came from. platform may be null.
currencystringISO code, uppercase.
subtotal, discount_total, shipping_total, tax_total, totalstringDecimal strings.
customerobject | nullThe 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_addressobject | nulladdress, latitude, longitude.
shipmentobject | nullCarrier 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[]arrayid, product_id, product_uid, title, variant_title, quantity, unit_price, total.
notestring | nullThe merchant's note.
metafieldsobjectTyped fields the merchant defined on the order, keyed namespace.key. See Metafields.
metadataobjectThe opaque values the merchant's integration wrote — your own ERP id comes back to you here.
created_at, updated_at, fulfilled_atISO 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

  1. Verify the signature against the raw request bytes before trusting the payload.
  2. Insert the event id into a durable inbox table with a unique constraint. If it already exists, acknowledge it without running the handler again.
  3. Commit the inbox record or enqueue the work durably before returning 2xx.
  4. 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.
  5. 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 302 to 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.

On this page