Guides
Rate limits
Each API key has 120 requests per minute, and the three availability checks share their own quota of 30 per minute. Past a limit the API answers 429 with Retry-After.
The quotas
Link to this section: The quotas| Quota | Limit | Applies to |
|---|---|---|
| Global | 120 requests per minute, fixed window | Every route, counted per API key. |
| Availability | 30 requests per minute, fixed window | POST /api, POST /api and POST /api, which share one quota. It counts in addition to the global one, because these are the more expensive queries: each of these requests also uses one of the 120 per minute. |
- The limits are the same for every plan.
- A 401 and a 403
insufficient_scopedo not consume quota; every other response of an authenticated key does. - OAuth has its own limits, per IP address: 5 requests per minute to register a client and 120 to the token endpoint. See OAuth.
- If the store that counts requests fails, the API answers 500
internal_errorinstead of skipping the limit: retry with backoff. - The quota belongs to the key, not to the process: every worker and every server that uses the same key spends the same 120.
- Plan on availability calls counting against both quotas.
- A batch availability query takes at most 100 elements. Group elements into one call instead of making many.
- MCP tools run with the same key and the same limits. See MCP limits.
Every response that consumed quota, which is every response except a 401 and a 403 insufficient_scope, successful or not, carries the IETF rate-limit headers. They reflect the tighter of the quotas the request consumed.
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed per minute by that quota. |
RateLimit-Remaining | Requests left in the current one-minute window. |
RateLimit-Reset | Seconds until the window resets. |
Retry-After | On 429 only: seconds until the next window. The 429 problem repeats it in retryAfter. |
When you exceed a limit
Link to this section: When you exceed a limitThe response is 429 rate_limited, a problem document with Retry-After and the RateLimit-* headers, which also tell you which quota ran out. The body carries the same wait in retryAfter (seconds), and detail says it too. Over MCP there are no headers: the problem is in the tool result, with retryAfter, and the quota state is in meta..
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 23
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 23
X-Request-Id: req_5f1c2f4e3a5b4f6d8e7a0b1c2d3e4f5aStop sending requests on that quota
Wait for
Retry-AftersecondsRetry the same call with the same Idempotency-Key
The same key keeps a retried write from being duplicated if an earlier attempt did arrive.
Back off if it keeps happening
Increase the wait exponentially and add jitter, so several workers do not retry in lockstep, and lower your steady rate.
The reference helper in Errors already does this.
Budget your calls
Link to this section: Budget your calls- Read in big pages. At the maximum page size of 500, 10,000 records take 20 requests; one detail read per record would take 10,000 requests, more than 80 minutes at 120 per minute.
- Do not re-read what has not changed. On the lists that declare
updatedFrom, filter by time and read only the delta: see Syncing data. - Cache data that rarely changes, such as the account and the catalog.
- Leave headroom. Aim for 80 percent of the limit in steady state, so a retry storm or a second worker does not push you over.
- Share one limiter between the workers that use a key, or give each integration its own key.
- Call the availability checks from your server, not once per visitor of a web page. Cache their answers for a short time, group up to 100 elements per query, and when you receive a 429 show a fallback such as a quote request instead of an error.
Read the limits from the API
Link to this section: Read the limits from the APIEvery operation publishes its quotas in the x-rateLimit extension of the OpenAPI document, and the limits of the key you are using are in rateLimits, returned by GET /api.
{ "global": { "perMinute": 120 }, "availability": { "perMinute": 30 } }An operation outside the availability group lists only global. Tooling can read these extensions to size its queues.
A client that respects the headers
Link to this section: A client that respects the headersThis limiter keeps one bucket for the global quota and one for the availability checks; an availability check waits on, and counts against, both. It waits when RateLimit-Remaining reaches zero until RateLimit-Reset has passed, serializes the requests of a bucket, and on a 429 sleeps for Retry-After before it tries again. It does not retry writes safely on its own: combine it with the helper in the Errors guide, which keeps the Idempotency-Key.
// limiter.mjs: spaces calls out using the RateLimit-* headers. Node 22+.
const BASE = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const AVAILABILITY = ["/api/v1/availability/check", "/api/v1/staff/availability", "/api/v1/vehicles/availability"];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class Bucket {
remaining = Infinity; // from RateLimit-Remaining
resetAt = 0; // when the window resets, from RateLimit-Reset
tail = Promise.resolve(); // one request at a time per bucket
async wait() {
const pause = this.resetAt - Date.now();
if (this.remaining <= 0 && pause > 0) await sleep(pause);
}
update(headers) {
const remaining = Number(headers.get("ratelimit-remaining"));
const reset = Number(headers.get("ratelimit-reset"));
if (!headers.has("ratelimit-remaining") || Number.isNaN(remaining) || Number.isNaN(reset)) return;
this.remaining = remaining;
this.resetAt = Date.now() + reset * 1000;
}
}
// The key has 120/min for everything. The three availability checks also count against
// their own 30/min quota, so they consume both buckets.
const buckets = { global: new Bucket(), availability: new Bucket() };
export function filmoCall(method, path, body) {
const isAvailability = AVAILABILITY.includes(path);
const used = isAvailability ? [buckets.global, buckets.availability] : [buckets.global];
const bucket = buckets.global;
const run = Promise.all(used.map((b) => b.tail)).then(async () => {
for (let attempt = 0; ; attempt++) {
for (const b of used) await b.wait();
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${process.env.FILMO_API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
// The headers describe the tighter quota. For an availability check that is
// usually its own; the global bucket just counts the call it consumed.
if (isAvailability) {
buckets.availability.update(res.headers);
buckets.global.remaining -= 1;
} else {
bucket.update(res.headers);
}
if (res.status !== 429 || attempt >= 5) return res;
// Over the limit: wait for Retry-After (seconds), then try the same call again.
await sleep((Number(res.headers.get("retry-after")) || 1) * 1000);
}
});
for (const b of used) b.tail = run.then(() => {}, () => {});
return run;
}
// Example: 300 reads in a row never trigger a 429.
for (let i = 0; i < 300; i++) {
const res = await filmoCall("GET", "/api/v1/orders?limit=1");
if (!res.ok) throw new Error(String(res.status));
}# limiter.py: spaces calls out using the RateLimit-* headers. Python 3 with requests.
import os
import threading
import time
from contextlib import ExitStack
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
AVAILABILITY = {"/api/v1/availability/check", "/api/v1/staff/availability", "/api/v1/vehicles/availability"}
class Bucket:
def __init__(self):
self.remaining = float("inf") # from RateLimit-Remaining
self.reset_at = 0.0 # when the window resets, from RateLimit-Reset
self.lock = threading.Lock() # one request at a time per bucket
def wait(self):
pause = self.reset_at - time.time()
if self.remaining <= 0 and pause > 0:
time.sleep(pause)
def update(self, headers):
try:
self.remaining = int(headers["RateLimit-Remaining"])
self.reset_at = time.time() + int(headers["RateLimit-Reset"])
except (KeyError, ValueError):
pass
# The key has 120/min for everything. The three availability checks also count against
# their own 30/min quota, so they consume both buckets.
BUCKETS = {"global": Bucket(), "availability": Bucket()}
def filmo_call(method, path, body=None):
is_availability = path.split("?")[0] in AVAILABILITY
used = [BUCKETS["global"], BUCKETS["availability"]] if is_availability else [BUCKETS["global"]]
with ExitStack() as stack:
for b in used:
stack.enter_context(b.lock)
for attempt in range(6):
for b in used:
b.wait()
res = requests.request(
method,
BASE + path,
json=body,
headers={"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}"},
timeout=30,
)
# The headers describe the tighter quota. For an availability check that is
# usually its own; the global bucket just counts the call it consumed.
if is_availability:
BUCKETS["availability"].update(res.headers)
BUCKETS["global"].remaining -= 1
else:
BUCKETS["global"].update(res.headers)
if res.status_code != 429 or attempt == 5:
return res
# Over the limit: wait for Retry-After (seconds), then try the same call again.
time.sleep(float(res.headers.get("Retry-After") or 1))
# Example: 300 reads in a row never trigger a 429.
for _ in range(300):
filmo_call("GET", "/api/v1/orders?limit=1").raise_for_status()Help Center: Rate limits explained.
Related pages
Link to this section: Related pagesGuides
Errors
Errors are RFC 9457 problem documents. Branch on the stable
code, never on the text, and use the retry table to decide whether to try again.Guides
Pagination
Lists return one page at a time. Ask for a page with
limit, and whilepagination.ishasMore truecall again with thenextCursoryou were given.Guides
Syncing data
Filmo v1 does not send webhooks, so you keep another system in step by polling: read what changed in half-open time windows, and run a separate sweep to find what was deleted. Do not follow the pagination cursor to decide what changed or what disappeared.
Guides
Limits and context
The catalogue is large. Choose scopes so the client lists only the tools it needs, and ask for small pages.
Was this page helpful?