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

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

## 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.salafems.com/ext/v1/orders?limit=100" \
  -H "Authorization: Bearer $SALAF_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.SALAF_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['SALAF_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('SALAF_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.

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

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