Guide
Rate limits and idempotency
The four rate limit buckets, what the response headers mean, which three endpoints take an Idempotency-Key, and exactly which calls are safe to retry after a timeout.
Limits are applied per API key, per X-CLIENT-ID and X-CLIENT-SECRET pair, in fixed 60 second windows.
Each key gets its own allowance, so give every integration its own pair. One key can then be adjusted without affecting the others.
The four buckets
A request is bucketed by what it costs to serve, not by its HTTP verb alone.
| Bucket | Limit per minute | What lands here |
|---|---|---|
read-single | 300 | Fetching one record by id, sku or barcode |
read-list | 120 | Paginated list endpoints |
write | 60 | Creates, updates, deletes |
heavy | 20 | Anything that reaches a carrier, a marketplace or a mail provider |
The heavy routes, specifically:
GET /analytics/items/salesGET /analytics/product-order-historyGET /analytics/sales-summaryPOST /purchase-orders/recommendationsPOST /shipping/request-labelPOST /shipping/retrieve-labelGET /shipping/label-suggestionPOST /sales-channels/sync-listingsPOST /invoices/sendPOST /webhooks/{webhook_id}/test
Plan any job that touches these around the 20 per minute ceiling.
The headers
Every response carries the current state of your budget:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 43
X-RateLimit-Policy: read-list
X-RateLimit-Reset is seconds until the current window rolls over.
Read X-RateLimit-Remaining and slow down as it falls. Treat it as a lower bound on what you have left rather than an exact figure.
Pacing an integration
Four habits keep a client comfortably inside the limits:
- Prefer one paginated
read-listcall over Nread-singlecalls. Fetching 500 items as a page costs one request against a 120 per minute bucket. Fetching them one at a time costs 500 against a 300 per minute bucket. - Back off between polls on async operations. Exponential, capped at something like 15 seconds.
- Spread scheduled jobs. If a nightly sync fires for every merchant at midnight, it is your own key that suffers when they share one.
- Watch
X-RateLimit-Remainingand slow down before you need to.
def with_backoff(fn, attempts=5):
delay = 1.0
for i in range(attempts):
resp = fn()
if resp.status_code != 429:
return resp
wait = float(resp.headers.get("X-RateLimit-Reset", delay))
time.sleep(wait)
delay = min(delay * 2, 30.0)
raise RuntimeError("rate limited")
Idempotency
Three endpoints honour an Idempotency-Key request header:
| Endpoint | Why it matters |
|---|---|
POST /assemblies/{item_id}/assemble | A retry without one builds again, consuming component stock twice |
POST /assemblies/{item_id}/disassemble | A retry without one takes apart a second batch |
POST /suppliers/create | A retry without one creates a duplicate supplier |
Send the same key when retrying a request whose outcome you did not see. Use a fresh key per logical operation, a UUID for example. See assemblies.
No other endpoint accepts it. For everything else, whether a call is safe to blind retry depends on what it does.
Safe to retry
| Call | Why |
|---|---|
Any GET | Reads change nothing |
POST /inventory/update, POST /inventory/bulk-update | Set absolute values rather than applying deltas, so replaying converges on the same state |
The PATCH updates on orders, customers and bundles | Absolute values |
POST /suppliers/{supplier_id}/products/add | Re-linking an already-linked product overwrites the variant terms with the same values |
POST /shipping/retrieve-label | Reads the result of an earlier request |
POST /suppliers/create | Only with an Idempotency-Key. Without one a retry creates a second supplier |
POST /assemblies/{item_id}/assemble, .../disassemble | Only with an Idempotency-Key. Without one a retry moves stock twice |
The pattern: if a call sets a value rather than adjusting one, sending it twice lands in the same place as sending it once.
Not safe to retry
| Call | What a blind retry costs you |
|---|---|
POST /orders/create | A duplicate order |
POST /purchase-orders | A duplicate purchase order |
POST /bundles/create | A duplicate bundle |
POST /orders/{order_pk}/items/add | A duplicate line item |
POST /orders/{order_id}/move-to-backorder | Compounding state change |
POST /orders/{order_id}/move-from-backorder | Compounding state change |
POST /shipping/request-label | A second label, and a second carrier charge |
POST /purchase-orders/{order_id}/parcels/create | A duplicate parcel |
POST /purchase-orders | A duplicate draft |
POST /purchase-orders/{cart_id}/publish | Rejected with 409, but check before resending |
If a create times out or fails ambiguously, poll before retrying. Read the list endpoint filtered to the customer and window, and only resend if the record is genuinely absent.
There is no equivalent of a webhook delivery_id on the REST creates, so the check is on you.
A worked example for the most expensive case, requesting a shipping label:
def request_label_once(client, order_pk, service):
# if a previous attempt already queued a label, find it before creating another
existing = client.get("/orders/fulfillment", params={"order_id": order_pk}).json()
if existing.get("label_entity_id"):
return existing["label_entity_id"]
queued = client.post("/shipping/request-label",
json={"order_pk": order_pk, "service": service}).json()
persist(order_pk, queued["entity_id"]) # write it down before anything else
return queued["entity_id"]
Persisting the entity_id the moment you receive it is what makes a crashed run recoverable.
