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

> **Note — You get these headers even on a 403**
>
> The rate-limit check runs before the scope check, deliberately. A caller
> hammering an endpoint their key is not allowed to touch is still consuming
> our capacity, so they are still counted — and they still get the headers
> telling them so.

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
HTTP/1.1 429 Too Many Requests
Retry-After: 24
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1786521600
```

```json
{
  "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.

**cURL**

```bash
# --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
```

**JavaScript**

```javascript
async 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();
}
```

**Python**

```python
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**

```php
<?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](https://www.shopdeskbd.com/developers/ai.md#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](#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](https://www.shopdeskbd.com/developers/mcp-tools.md), 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 `PUT`s 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](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md#quotas-and-limits).

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