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.

QuotaLimitApplies to
Global120 requests per minute, fixed windowEvery route, counted per API key.
Availability30 requests per minute, fixed windowPOST /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 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.
  • If the store that counts requests fails, the API answers 500 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.

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.

HeaderMeaning
RateLimit-LimitRequests allowed per minute by that quota.
RateLimit-RemainingRequests left in the current one-minute window.
RateLimit-ResetSeconds until the window resets.
Retry-AfterOn 429 only: seconds until the next window. The 429 problem repeats it in retryAfter.

The 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.rateLimit.

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 already does this.

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

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, returned by GET /api/v1/account.

x-rateLimit on an availability check
{ "global": { "perMinute": 120 }, "availability": { "perMinute": 30 } }

An operation outside the availability group lists only global. Tooling can read these extensions to size its queues.

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.

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

Help Center: Rate limits explained.

  • Guides

    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 while pagination.hasMore is true call again with the nextCursor you 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.