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 reaches your endpoint only after it is added to that endpoint’s subscription -
see §8, “Choosing which event types you receive”. It
does not show up on its own. But once a type is added, nothing gates delivery on whether your
parser has caught up: the change takes effect in the admin UI immediately, not behind your next
deploy. If your parser rejects or errors on a type it does not recognize, asking for a new type
before you finish rolling out the code that handles it is enough to break your own integration
the moment that first event arrives. 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. Within your subscribed types, you receive events for every order on your granted sites
Section titled “7. Within your subscribed types, you receive events for every order on your granted sites”Two separate things bound what reaches your endpoint: your granted sites bound which orders,
and your endpoint’s subscription bounds which types (see
§8, “Choosing which event types you receive”). This
section is about the first one. Delivery is site-scoped, not partner-scoped: for any type your
endpoint is subscribed to, 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. Choosing which event types you receive
Section titled “8. Choosing which event types you receive”Every endpoint has a subscription: the set of type values it receives. An event whose type is
not in that set is filtered out before it reaches you - both at fan-out and, authoritatively,
again at the moment of delivery. This is what points 5 and 7 above both refer back to. The one
thing that does not go through this filter is the synthetic test ping, which is sent straight to
your endpoint - see §12, “A synthetic test ping may reach
you”.
When an endpoint is created, its subscription defaults to every event type that existed at that moment, written out as an explicit list. It is not a live “all” that quietly grows - a type shipped afterward is not in that list, no matter how the endpoint was set up.
Concretely: every endpoint registered before subscriptions existed as a concept holds exactly these four types, and only these four:
order.status_changedshipment.shippedshipment.cancelledorder_line.cancelled
A newly shipped fifth type reaches none of those endpoints, and reaches no new endpoint either
unless it is explicitly included. Nobody is enrolled in a new type automatically - not existing
endpoints, not endpoints created after the type shipped by copying an old configuration. If you
want a type your endpoint does not currently receive, ask your Collaterate contact to add it to
your endpoint’s subscription (see §10, “Registration is
admin-managed”) - there is no self-serve way to change it, and
eventTypes cannot be set to an empty list. If you want to stop receiving events entirely, that
is disabling the endpoint, a separate control from its subscription.
9. Verifying the signature
Section titled “9. 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.
10. Registration is admin-managed
Section titled “10. 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. The same applies to changing an endpoint’s event type subscription (see §8, “Choosing which event types you receive”) - there is no self-serve route for either. Self-serve management is deferred, not designed out - it may arrive later as partner-facing routes over the same store.
11. Respond 2xx quickly
Section titled “11. 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.
12. A synthetic test ping may reach you
Section titled “12. 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.
The ping is sent straight to your endpoint and is not subject to the subscription filter in
§8, so it can carry a type your endpoint is not
subscribed to - today it is always order.status_changed. That is deliberate: the ping exists to
prove your URL, your TLS and your signature verification work, and a connectivity check that a
subscription could silently suppress would be no check at all. It is the one exception, and it
carries no real data. Everything the bus actually produces goes through the filter.
- 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.