*REST API › 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

| 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/v1/availability/check`, `POST /api/v1/staff/availability` and `POST /api/v1/vehicles/availability`, 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_scope`](https://docs.filmo.io/rest-api/errors/insufficient_scope) do 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](https://docs.filmo.io/rest-api/guides/oauth#rate-limits).
- If the store that counts requests fails, the API answers 500 [`internal_error`](https://docs.filmo.io/rest-api/errors/internal_error) instead 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](https://docs.filmo.io/mcp/guides/limits-and-context).

## Headers

Every response that consumed quota, which is every response except a 401 and a 403 [`insufficient_scope`](https://docs.filmo.io/rest-api/errors/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

The response is 429 [`rate_limited`](https://docs.filmo.io/rest-api/errors/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.rateLimit`.

```http
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_5f1c2f4e3a5b4f6d8e7a0b1c2d3e4f5a
```

1. **Stop sending requests on that quota**
2. **Wait for `Retry-After` seconds**
3. **Retry the same call with the same Idempotency-Key** The same key keeps a retried write from being duplicated if an earlier attempt did arrive.
4. **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](https://docs.filmo.io/rest-api/guides/errors#retry-helper) already does this.

## 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](https://docs.filmo.io/rest-api/guides/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

Every 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`](https://docs.filmo.io/rest-api/objects/account#response-rateLimits), returned by [`GET /api/v1/account`](https://docs.filmo.io/rest-api/endpoint-reference/account/get-the-account).

*x-rateLimit on an availability check*

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

This 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`.

*A limiter driven by the headers*

**Node**

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

**Python**

```python
# 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](https://www.filmo.io/help/reference/integrations-and-api/rate-limits-explained).

---

Canonical URL: https://docs.filmo.io/rest-api/guides/rate-limits  
Last updated: 2026-10-09