SHOP DESKDevelopers
shopdeskbd.com

Rate limits

Per-key buckets, headers, and handling 429.

Requests are counted per API key, in a fixed 60-second window, across two separate buckets.

BucketCountsDefault
ReadsGET, HEAD, OPTIONS120 per minute
WritesEverything else30 per minute

The two buckets are counted separately, so a bulk catalogue import cannot starve the reads a dashboard is making with the same key — and neither can exhaust the other's allowance. The write bucket is the smaller one on purpose: writes cost far more work per request, and 30 a minute is well above what a correctly batched integration needs.

Most requests count as one. The two report endpoints, GET /reports/sales-summary and GET /reports/top-products, count as 5 reads each, because a report runs several queries over up to a year of orders. X-RateLimit-Remaining drops by 5 on each of them.

Headers on every response

You never have to guess where you stand — the numbers are on every response, not just the ones that fail.

HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1786521600
HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window, for this bucket.
X-RateLimit-RemainingHow many are left. Floors at 0.
X-RateLimit-ResetUnix timestamp, in seconds, when the window resets.

A 401 is the exception: a request whose key was missing or refused never reached a key's bucket, so it carries no X-RateLimit-* headers at all. Any X-RateLimit-* header you do receive describes your key.

When you exceed it

HTTP/1.1 429 Too Many Requests
Retry-After: 24
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1786521600
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded: 120 read requests per minute. Retry in 24s.",
    "request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59"
  }
}

Retry-After is exact. It is the number of seconds left in your window, not a suggestion and not a rough backoff hint. Sleeping for less means spending another request to be told the same thing; sleeping for more just wastes your own time.

Paste a key and every snippet on this page switches from the placeholder to your key — and the Try it panel under each endpoint is ready to send. It is stored in this browser only and goes nowhere except, if you press Send, straight to the API host you pick there.

# --retry 5 makes curl honour Retry-After on a 429 by itself.
curl -sS --retry 5 --retry-all-errors \
  "https://api.shopdeskbd.com/ext/v1/orders?limit=100" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -D headers.txt

# The headers on EVERY response tell you how much room is left:
#   X-RateLimit-Limit: 120
#   X-RateLimit-Remaining: 3
#   X-RateLimit-Reset: 1786521600
grep -i '^x-ratelimit' headers.txt

How the window works

The window is fixed, not sliding: the minute is divided into fixed blocks and each one starts your count at zero. One consequence is worth knowing — you can briefly do up to twice the limit across a boundary, by spending your full allowance at the end of one window and again at the start of the next.

That forgiveness is intentional. A sliding window would be stricter and would cost more to run, and nobody has yet demonstrated that the edge behaviour matters here. If that changes, tightening it is a change to our side only.

The store MCP server

Tool calls over the store MCP server use the same key's same two buckets. Every MCP request is an HTTP POST, so the bucket is chosen from the JSON-RPC message inside it, not from the method:

MessageCounts as
initialize, discovery, ping, tools/list1 read
A read-only tool1 read
get_sales_summary, get_customer_summary5 reads
A write tool1 write
A write tool carrying image links (create_product, add_product_images_from_urls, create_category, create_brand)1 write per link, at least 1 (Image downloads)
An unknown tool or method, a tool outside the key's scopes (it is not in the key's tools/list), a batch, anything that is not one JSON-RPC message1 write

The Mcp-Method and Mcp-Name headers play no part in this. Each tool's cost is listed on MCP tools, and the X-RateLimit-* headers on the HTTP response describe the bucket the call counted against.

Image downloads

Writes that carry images — product photos (images[].source_url on POST /products, PATCH /products/{id} and POST /products/{id}/images, or image parts on that last route), category and brand images (by link or as parts), attribute value swatches — make our servers download, check and store them, so they carry extra limits on top of the write bucket:

  • Each image counts as one write. A call carrying ten photos takes ten units from the key's per-minute write bucket (the request itself is one of them). A call whose images would pass the limit is refused with 429 RATE_LIMITED before anything is downloaded, and takes nothing. The X-RateLimit-* headers show the bucket as it stood when the call arrived.
  • Two at a time per key. A third call that downloads links or uploads files while two are still running is refused with 429 RATE_LIMITED (Retry-After: 5); retry when one finishes.
  • 50 MB per call. At most 50 MB downloaded across a call's links, and at most 50 MB of files in one multipart request (413 past it) — 10 MB per image either way. Ten 9 MB photos go over two calls.
  • 2,000 links per company per UTC day, across all its keys. Each link in a call counts once, whether or not it downloads. A call that would pass the limit is refused before anything is downloaded and uses none of it; its Retry-After is the seconds to midnight UTC.

A call with no images (a product write without new photos, a reorder) is not metered here at all.

The store MCP server's link-taking tools — create_product, add_product_images_from_urls, create_category and create_brand — share these limits with REST: a key's two image calls may be any mix of the two, and each link a tool call carries counts as one write, as on REST.

A video cover sent as a link or an uploaded file (POST /videos/{id}/poster) is one image under these limits too. The video itself is not: it goes straight to storage and is limited as below.

Video uploads

Each call on the /videos endpoints counts once against the read or write bucket, like any other call. A part's bytes go to storage, not to the API, so the part PUTs count against nothing. On top of that, the plan's video limits apply:

LimitValuePast it
Uploads started, per store per UTC day50 (imports included)429 RATE_LIMITED, Retry-After to midnight UTC
Imports from a link started, per key per UTC day20429 RATE_LIMITED, Retry-After to midnight UTC
Unfinished uploads and imports4 per key, 6 per store409 TOO_MANY_OPEN_UPLOADS
Video storagethe plan's, reserved from the start403 PLAN_LIMIT_REACHED
Imports copied at onceone per company; an import not started within an hour endsaborted, expired
Import download speedat least 64 KB a second on average, after the first minuteaborted, import_download_failed

Starting again with an Idempotency-Key you already used returns the same upload and does not count as a new start — a retry is answered even when the day's cap is used up. A key made by rotating another counts that key's unfinished uploads against its 4. A finished video that no product shows 48 hours after it is ready stops counting against video storage and is removed 7 days later. See Upload a product video.

Per key, not per company

Each key gets its own buckets. A company key counts as one key, exactly like a store key.

This has a practical implication worth planning around: splitting one integration across several keys multiplies its throughput. That is a legitimate way to run a nightly bulk sync and a real-time listener side by side without one starving the other — and separate keys also mean you can revoke one without touching the other.

There is also a per-IP ceiling in front of everything as a denial-of-service backstop. It is set high enough that ordinary integration traffic never approaches it, including several tenants sharing one office NAT. It sends no X-RateLimit-* headers of its own; if it ever refuses you, the 429 carries Retry-After.

Plan limits

The defaults above are floors, not ceilings. Both limits are plan features (api_rate_limit_reads and api_rate_limit_writes), so a higher tier raises them with no change on your side — your key simply starts reporting a larger X-RateLimit-Limit.

Read the limit from the header rather than hard-coding 120. Your plan may already be higher than the default, and it can change without a release on either side.

Staying under the limit

  • Ask for bigger pages. limit=100 fetches the same 1,000 rows in 10 requests instead of 40. The rate limit counts requests, not rows.
  • Filter with updated_after. A sync that reads only what changed makes a fraction of the calls of one that re-reads everything.
  • Do not poll tightly. Once a minute is plenty for almost every integration; once every few minutes is plenty for most.
  • Watch X-RateLimit-Remaining and slow down before you hit zero. Backing off at 10 remaining is cheaper than a 429 and a retry.
  • Do not expand what you will not read. expand=items on a page you only need totals from is work for both of us.