Skip to content

Webhooks

A webhook tells you that something changed. It is not the record of what is true - for that, re-read the resource it names through the authenticated API. Every rule below exists because an integrator got exactly this wrong somewhere else first.

1. A webhook is a notification, not a record

Section titled “1. A webhook is a notification, not a record”

The event names what changed and which resource to re-read. It never carries order contents, amounts, or customer data - read those from the resource itself.

type Re-read
order.status_changed GET /v1/orders/{orderNumber}
shipment.shipped GET /v1/orders/{orderNumber}/shipments
shipment.cancelled GET /v1/orders/{orderNumber}/shipments
order_line.cancelled GET /v1/orders/{orderNumber}/items - the cancelled line shows cancelled: true

A payload looks like this:

{
"eventId": "e8f1c2d3-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"type": "shipment.shipped",
"occurredAt": "2026-07-29T13:34:43.863Z",
"siteId": 1,
"orderNumber": 1687171,
"partnerOrderId": "priced-verify-20260729-083443",
"shipmentNumber": 3
}

shipmentNumber is present only on the two shipment types.

There is no retry queue. A delivery attempt happens once, and if your endpoint is down, slow, or returns a non-2xx, that delivery is gone. cursor plus a status re-poll against GET /v1/orders is how you learn current, authoritative truth regardless of what webhooks did or didn’t arrive.

That safety net is cheap, not just theoretical. Measured in production, orders placed in the last 30 days and not yet closed, per site: p50 21, p90 173. At the 200-row page limit, re-polling your entire open window costs one request at p90. Treat “just re-poll periodically” as a credible reconciliation strategy, not a fallback of last resort.

Deliveries can arrive out of order. Two status changes on the same order can reach you reversed. Never reconstruct order state from the sequence webhooks arrive in - always trust the most recent authenticated read over the most recently received event.

occurredAt is SNS’s publish time, not the moment the underlying change was committed. Use it only to discard information you know is stale if you cache webhook payloads - not as a clock you can order events by.

Best-effort does not mean at-most-once. The underlying transport (SNS to SQS) can redeliver the same event, so the same eventId may reach your endpoint more than once. Key your dedupe table on eventId, not on (type, orderNumber) or any other derived key.

A new event type is an additive change under the v1 rule, exactly like a new optional field. If your parser rejects or errors on a type it does not recognize, it will break the moment a fifth type ships - and it will break for every partner running that parser, not just yours. Log unknown types if you like, but do not fail on them.

6. These four types are not a complete change feed

Section titled “6. These four types are not a complete change feed”

order.status_changed, shipment.shipped, shipment.cancelled and order_line.cancelled cover shipment and cancellation transitions. They do not cover every mutation an order can undergo. Webhooks tell you some things changed; the re-poll in point 2 remains how you learn everything else. Do not build an integration that assumes silence means nothing changed.

7. You receive events for every order on your granted sites

Section titled “7. You receive events for every order on your granted sites”

Delivery is site-scoped, not partner-scoped: you receive an event for every order on a site you are granted, including storefront orders you never submitted - not only the ones you placed through this API. partnerOrderId is present only on orders you submitted yourself (it is your own partnerOrderId from submission); it is absent on every other order. Use its presence to filter to “only my orders” on your side.

Signing is optional per endpoint - your Collaterate contact configures it when you register a URL, and you decide whether you want it. If it’s on, every delivery carries two headers:

Collaterate-Signature: t=1769694883,v1=d23c5c4b36984429e515cc63a08de2ccafb8254ebaaf2533e7b646a1268b85cf
Collaterate-Event-Id: e8f1c2d3-4a5b-6c7d-8e9f-0a1b2c3d4e5f

Collaterate-Event-Id duplicates the body’s own eventId as a header, so you can dedupe or reject before you even parse the body. Collaterate-Signature carries the timestamp (t, seconds since the epoch) and the signature itself (v1, HMAC-SHA256 as lowercase hex).

The signature covers "<t>.<raw body>", not the body alone. Binding the timestamp into the signed string means a captured delivery cannot be replayed forever - the signature itself expires with the timestamp tolerance, not just whenever you happen to check occurredAt.

A copy-pasted verification snippet is exactly what most integrators ship verbatim, so three things are not optional in yours:

  • Verify against the raw request body bytes, never a re-serialization of the parsed JSON. A re-serialized body can differ from what was actually signed by nothing more than key order or float formatting, and would fail verification for a payload that was never tampered with.
  • Reject a t more than 5 minutes (300 seconds) from your own clock. This is the replay window. A verification snippet that checks the signature but skips this check will happily accept a captured, unmodified delivery replayed at any point in the future.
  • Compare digests in constant time, not with ==/!=. A branching comparison leaks how many leading bytes matched through timing, which is exactly the side channel an online forgery attempt needs.
verify_webhook.py
import hashlib
import hmac
import time
SIGNATURE_HEADER = "Collaterate-Signature" # t=<seconds>,v1=<hex>
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook(secret: str, signature_header: str, raw_body: bytes) -> None:
"""Raises ValueError on any failure. Call this BEFORE you parse or act on the body."""
parts = dict(part.split("=", 1) for part in signature_header.split(",") if "=" in part)
if "t" not in parts or "v1" not in parts:
raise ValueError(f"malformed {SIGNATURE_HEADER} header: {signature_header!r}")
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
raise ValueError(f"timestamp {timestamp} is outside the {TOLERANCE_SECONDS}s tolerance")
# Sign the RAW bytes exactly as received. Never re-serialize the parsed JSON here: a
# different key order, whitespace, or float formatting produces a different signature
# over an identical, valid payload, and would reject good deliveries for the wrong reason.
signed_string = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest()
# Constant-time compare - see the "not optional" list above.
if not hmac.compare_digest(expected, parts["v1"]):
raise ValueError("signature does not match")

A known-good vector, so you can check your own digest math

Section titled “A known-good vector, so you can check your own digest math”

This is a fixed test vector, not a live capture - it exists so you can confirm your implementation produces the exact same digest we do, independent of the tolerance check above (which would of course reject this fixed, long-past timestamp against a real clock):

secret = "whsec_T3stSecret_do_not_use_in_production"
timestamp = 1769694883
raw_body = (
b'{"eventId":"e8f1c2d3-4a5b-6c7d-8e9f-0a1b2c3d4e5f","type":"shipment.shipped",'
b'"occurredAt":"2026-07-29T13:34:43.863Z","siteId":1,"orderNumber":1687171,'
b'"partnerOrderId":"priced-verify-20260729-083443","shipmentNumber":3}'
)
signed_string = f"{timestamp}.".encode() + raw_body
digest = hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest()
assert digest == "d23c5c4b36984429e515cc63a08de2ccafb8254ebaaf2533e7b646a1268b85cf"

If your implementation produces anything else against these exact three inputs, the bug is in your signing code, not ours - check first whether you are signing the raw bytes or a re-serialization, and whether the timestamp is really bound inside the signed string rather than appended after it.

An endpoint registered without a secret receives no Collaterate-Signature header at all. An unsigned delivery is a hint to go look, not evidence - re-reading the resource through the authenticated API is how you confirm anything it implies.

You cannot register or manage your own webhook endpoints through this API today. Ask your Collaterate contact to register a URL (and, optionally, a signing secret) for you. Self-serve registration is deferred, not designed out - it may arrive later as partner-facing routes over the same store.

Return any 2xx status within 5 seconds and the delivery counts as successful. Anything else - a non-2xx status, a connection refusal, or no response inside that 5 seconds - is recorded as a failed delivery and is not retried. There is no backoff and no second attempt for a single delivery.

Because of that timeout, your endpoint must not do the real work inline. Accept the request, enqueue whatever processing you need, and return 2xx immediately. An endpoint that validates against a database, calls another service, or does anything else that can occasionally run long will fail deliveries under its own slowness - and every failure is final.

Your Collaterate contact can send a synthetic test event through the same delivery path real events use, to confirm your endpoint (and, if configured, your signature verification) works before real events ever flow. You may receive one of these without warning, separately from anything you did.

It is unmistakably synthetic and never worth reconciling against your own data: eventId is prefixed test_, and siteId and orderNumber are both 0 - there is no real order 0 and no real site 0. Verify its signature exactly like any other event if signing is configured, then discard it.

  • Getting started - the pagination loop a webhook tells you to run again.
  • Tracking and line items - what /shipments and /items actually return once you re-read them.
  • Errors - the problem document your own endpoint should never need to return to us, since anything but 2xx is simply recorded as a failure.