How to write a handler that behaves correctly under retries, and what happens when your endpoint starts failing.
How to write a handler that behaves correctly under retries, and what happens when your endpoint starts failing. For the shape of the payload these rules apply to, see The Payload Contract & Worked Examples. For the endpoints that let you inspect and recover a suspended subscription, see Managing Subscriptions.
Idempotency and the shape of your handler
Delivery is at least once. The same logical change can reach you more than once, and that is normal rather than exceptional: retries, a deliberately overlapping scan window, a catch up after a suspension, and a product that left and returned.
So make handlers upsert shaped and idempotent. Key writes on product_id rather than appending, remember processed delivery_ids for a day or two and drop repeats, and never treat a second product.created for a product you already have as an error.
1. verify HMAC and timestamp
2. seen this delivery_id before? -> return 200, drop it
3. return 200 immediately
4. from your own queue: apply
Acknowledge first, work later. We allow 2 seconds to connect and 5 seconds to read, and a timeout counts as a failed attempt. Return 200 as soon as the signature verifies, then apply from your own queue. A slow write on your end must never become a JOOR side timeout.
A 5 second read budget is the hard constraint on your handler design. Signature verification and a queue write have to fit inside it, in the p99 rather than the median, or you accumulate failed attempts and eventually a suspension. Do not put a database round trip on your PIM in the request path.
The apply rule
def apply(evt):
if evt["event"] == "product.deleted":
local_store.delete(evt["data"]["product_id"])
return
incoming = evt["data"]["product"]
product = local_store.get(incoming["id"])
# Discard only STRICTLY OLDER payloads. On a tie, apply.
if product and product.version_at > incoming["version_at"]:
return
product = product or new_product(incoming["id"])
product.version_at = incoming["version_at"]
for section in evt["changes"]:
if section == "core":
product.set_core(incoming)
elif section == "images":
product.images = incoming.get("images", [])
elif section == "skus":
product.colors = incoming.get("colors", [])
product.prices = incoming.get("prices", [])
elif section == "collection_membership":
product.collections = incoming.get("collections", [])
# An unrecognised section name is additive, not an error: log it and
# move on rather than raising, and re walk that product if you care.
# sections NOT in evt["changes"] are left exactly as they were
local_store.put(product)Discard on >, never on >=. Two genuinely different payloads can legitimately share the same version_at. Treat that as a tie and apply it: every section is a complete current set, so re applying one is a harmless upsert. Discarding on >= silently drops exactly the payloads this produces.
Note what that loop does not do: it never clears a section that is not named in changes. If you rewrite this in a framework that maps a payload onto a model object wholesale, you will wipe every section the notification did not carry. The section by section branch is the point.
Delivery, retries and suspension
Every attempt is recorded. Repeated failure suspends the subscription rather than retrying forever.
What counts as a failure: any status outside 2xx, a redirect, a timeout, or a connection error. We never follow redirects: a 3xx is a failed attempt, not a hop. Point us at the final endpoint.
| Rule | Value |
|---|---|
| Timeouts | 2 seconds to connect, 5 seconds to read |
| Retry schedule | 1 minute, then 5 minutes, then 30 minutes. Four attempts in total, then the delivery expires |
410 Gone | Suspends immediately. We read it as "this endpoint is gone for good" |
401 or 403 | Three consecutive suspend. Usually a signature verification bug or a rotation gone wrong |
| Sustained failure | 50 consecutive failures, or everything failing for 60 minutes |
| Resume cooldown | 60 minutes between resumes |
Read that table as a design constraint on your endpoint: never answer 410 from a health check or a maintenance page, never answer 401 or 403 when your own auth layer is confused, and never redirect. Answer 503 if you must fail, and take the paced retries.
An expired delivery is lost data. Four attempts over roughly 36 minutes, and then it is gone from the queue, recorded in GET .../deliveries with status: "expired". The expired_24h counter in stats is the metric to alarm on, and a full reconciliation is the recovery.
While suspended, nothing is lost immediately. Changes keep accumulating, and POST .../resume replays the suspended window, paced. If the suspension was long enough that replaying it is impractical, resume returns full_resync_advised and you re walk with the bulk export instead, which is faster for both sides.
GET .../deliveries gives you the individual failures with their HTTP codes, which is normally enough to find the cause without contacting us.
