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/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. |
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.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.txtasync 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();
}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
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=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.