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.
2. Best-effort: deliveries can be dropped
Section titled “2. Best-effort: deliveries can be dropped”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.
3. No ordering guarantee
Section titled “3. No ordering guarantee”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.
4. Dedupe on eventId
Section titled “4. Dedupe on eventId”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.
5. Ignore unknown type values
Section titled “5. Ignore unknown type values”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.
8. Verifying the signature
Section titled “8. Verifying the signature”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=d23c5c4b36984429e515cc63a08de2ccafb8254ebaaf2533e7b646a1268b85cfCollaterate-Event-Id: e8f1c2d3-4a5b-6c7d-8e9f-0a1b2c3d4e5fCollaterate-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
tmore 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.
import hashlibimport hmacimport 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 = 1769694883raw_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_bodydigest = 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.
9. Registration is admin-managed
Section titled “9. Registration is admin-managed”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.
10. Respond 2xx quickly
Section titled “10. Respond 2xx quickly”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.
11. A synthetic test ping may reach you
Section titled “11. A synthetic test ping may reach you”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
/shipmentsand/itemsactually return once you re-read them. - Errors - the problem document your own endpoint should never need to
return to us, since anything but
2xxis simply recorded as a failure.