Guide
Rate limits and idempotency
The four rate limit buckets, what the response headers mean, 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.
That per key detail matters more than it looks. An organization running several integrations on separate keys gets the full allowance on each of them, and a single key can be raised or cut off without disturbing the others. It is a good reason to give every integration its own pair.
The four buckets
A request is bucketed by what it costs upstream, 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
These get the lowest allowance because the systems behind them impose limits of their own that Stockpilot cannot raise for you.
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.
The limits are advisory today. Stockpilot does not return 429. Nothing is rejected. That will not always be true, so build against the headers now rather than discovering them later.
Counters are held per API instance, which means X-RateLimit-Remaining is a lower bound on what you actually have left. It will never overstate your remaining budget.
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
There is no Idempotency-Key header. 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 | Sets an absolute value rather than applying a delta |
PATCH /orders/{order_id}/update-status | Absolute value |
PATCH /orders/{order_id}/update-customer-details | Absolute value |
PATCH /orders/ordered-items/{item_id}/update | Absolute value |
PATCH /customers/{customer_id}/update | Absolute value |
PATCH /bundles/{bundle_id} | Absolute value |
POST /shipping/retrieve-label | Reads the result of an earlier request |
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 |
If a create times out or fails ambiguously, poll before retrying. Read the list endpoint, check whether the record already landed, and only then decide. This is the difference between a resilient integration and one that quietly bills your customer twice.
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.
