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.
| Bucket | Counts | Default |
|---|---|---|
| Reads | GET, HEAD, OPTIONS | 120 per minute |
| Writes | Everything else | 30 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| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window, for this bucket. |
X-RateLimit-Remaining | How many are left. Floors at 0. |
X-RateLimit-Reset | Unix 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.txtasync function call(url, attempt = 0) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}` },
});
if (response.status === 429 && attempt < 5) {
// Retry-After is authoritative — it is the exact number of seconds
// until this key's window resets. Guessing a backoff just means
// retrying too early and burning another request on a 429.
const wait = Number(response.headers.get("Retry-After") ?? 1);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return call(url, attempt + 1);
}
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}
return response.json();
}import os
import time
import requests
HEADERS = {"Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}"}
def call(url, params=None, attempts=5):
for attempt in range(attempts):
response = requests.get(url, params=params, headers=HEADERS, timeout=30)
if response.status_code == 429:
# Retry-After is exact — it is the seconds left in this key's
# fixed window. Sleeping less just spends another request.
time.sleep(int(response.headers.get("Retry-After", "1")))
continue
if not response.ok:
error = response.json()["error"]
raise RuntimeError(f"{error['code']}: {error['message']}")
return response.json()
raise RuntimeError("Still rate limited after retrying.")<?php
function call(string $url, int $attempts = 5): array
{
$key = getenv('SHOPDESK_API_KEY');
for ($attempt = 0; $attempt < $attempts; $attempt++) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$headers = substr($raw, 0, $headerSize);
$payload = json_decode(substr($raw, $headerSize), true);
if ($status === 429) {
// Retry-After is the exact seconds left in the window.
preg_match('/retry-after:\s*(\d+)/i', $headers, $m);
sleep((int) ($m[1] ?? 1));
continue;
}
if ($status >= 400) {
throw new RuntimeException("{$payload['error']['code']}: {$payload['error']['message']}");
}
return $payload;
}
throw new RuntimeException('Still rate limited after retrying.');
}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:
| Message | Counts as |
|---|---|
initialize, discovery, ping, tools/list | 1 read |
| A read-only tool | 1 read |
get_sales_summary, get_customer_summary | 5 reads |
| A write tool | 1 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 message | 1 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_LIMITEDbefore anything is downloaded, and takes nothing. TheX-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 (
413past 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-Afteris 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:
| Limit | Value | Past it |
|---|---|---|
| Uploads started, per store per UTC day | 50 (imports included) | 429 RATE_LIMITED, Retry-After to midnight UTC |
| Imports from a link started, per key per UTC day | 20 | 429 RATE_LIMITED, Retry-After to midnight UTC |
| Unfinished uploads and imports | 4 per key, 6 per store | 409 TOO_MANY_OPEN_UPLOADS |
| Video storage | the plan's, reserved from the start | 403 PLAN_LIMIT_REACHED |
| Imports copied at once | one per company; an import not started within an hour ends | aborted, expired |
| Import download speed | at least 64 KB a second on average, after the first minute | aborted, 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=100fetches 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-Remainingand slow down before you hit zero. Backing off at 10 remaining is cheaper than a429and a retry. - Do not expand what you will not read.
expand=itemson a page you only need totals from is work for both of us.