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.

·7 min read ·Updated 3 Sep 2026

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.

BucketLimit per minuteWhat lands here
read-single300Fetching one record by id, sku or barcode
read-list120Paginated list endpoints
write60Creates, updates, deletes
heavy20Anything that reaches a carrier, a marketplace or a mail provider

The heavy routes, specifically:

  • GET /analytics/items/sales
  • GET /analytics/product-order-history
  • GET /analytics/sales-summary
  • POST /purchase-orders/recommendations
  • POST /shipping/request-label
  • POST /shipping/retrieve-label
  • GET /shipping/label-suggestion
  • POST /sales-channels/sync-listings
  • POST /invoices/send
  • POST /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-list call over N read-single calls. 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-Remaining and 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:

EndpointWhy it matters
POST /assemblies/{item_id}/assembleA retry without one builds again, consuming component stock twice
POST /assemblies/{item_id}/disassembleA retry without one takes apart a second batch
POST /suppliers/createA 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

CallWhy
Any GETReads change nothing
POST /inventory/update, POST /inventory/bulk-updateSet absolute values rather than applying deltas, so replaying converges on the same state
The PATCH updates on orders, customers and bundlesAbsolute values
POST /suppliers/{supplier_id}/products/addRe-linking an already-linked product overwrites the variant terms with the same values
POST /shipping/retrieve-labelReads the result of an earlier request
POST /suppliers/createOnly with an Idempotency-Key. Without one a retry creates a second supplier
POST /assemblies/{item_id}/assemble, .../disassembleOnly 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

CallWhat a blind retry costs you
POST /orders/createA duplicate order
POST /purchase-ordersA duplicate purchase order
POST /bundles/createA duplicate bundle
POST /orders/{order_pk}/items/addA duplicate line item
POST /orders/{order_id}/move-to-backorderCompounding state change
POST /orders/{order_id}/move-from-backorderCompounding state change
POST /shipping/request-labelA second label, and a second carrier charge
POST /purchase-orders/{order_id}/parcels/createA duplicate parcel
POST /purchase-ordersA duplicate draft
POST /purchase-orders/{cart_id}/publishRejected 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.

A Stockpilot engineer talking through an integration with a developer

Stuck on something

Ask a person, not a search box

The API surface is wide, and some of it only makes sense once someone explains why it works that way. If a payload is not doing what you expect, or you are weighing two approaches, say so and we will look at it with you.