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.

·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.

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.

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

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-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

There is no Idempotency-Key header. Whether a call is safe to blind retry depends on what it does.

Safe to retry

CallWhy
Any GETReads change nothing
POST /inventory/updateSets an absolute value rather than applying a delta
PATCH /orders/{order_id}/update-statusAbsolute value
PATCH /orders/{order_id}/update-customer-detailsAbsolute value
PATCH /orders/ordered-items/{item_id}/updateAbsolute value
PATCH /customers/{customer_id}/updateAbsolute value
PATCH /bundles/{bundle_id}Absolute value
POST /shipping/retrieve-labelReads 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

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

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.

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.