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

## The problem document

An error from the API has the content type `application/problem+json`. `code` is stable and meant for control flow; `type` is the same code as a URI, `https://docs.filmo.io/errors/<code>`, that leads to its documentation page, and `title` is its short English name. For a given code, `type`, `title` and `code` never change. Branch on `code`, not on the URI.

*A validation_error with two field errors*

```json
{
  "type": "https://docs.filmo.io/errors/validation_error",
  "title": "Validation error",
  "status": 400,
  "code": "validation_error",
  "detail": "2 validation errors",
  "errors": [
    { "path": "lines[0].itemId", "code": "not_found", "message": "The lines[0].itemId identifier does not exist or does not belong to this account" },
    { "path": "endDate", "code": "must_be_after", "message": "endDate must be after startDate" }
  ],
  "requestId": "req_5f1c2f4e3a5b4f6d8e7a0b1c2d3e4f5a"
}
```

| Property | Meaning |
| --- | --- |
| `type`, `title` | The code as a URI, and its short English name. |
| `status` | The HTTP status of the response. |
| `code` | The stable error code. Map it in your client. |
| `detail` | An explanation of this occurrence, for people. Do not use it for control flow. |
| `requestId` | The identifier of the request; also the `X-Request-Id` header. Quote it when you contact support. |
| `errors` | On `validation_error`, every offending field; on `contact_in_use`, `address_in_use` and `insufficient_stock`, what blocks the operation; on `conflict`, the cause in `errors[0].code`. |
| `requiredScope` | On `insufficient_scope`: the scope the key needs. |
| `requiredAddon` | On `addon_required`: the add-on the module needs. |
| `retryAfter` | On `rate_limited`: the seconds to wait before retrying, the same value as the `Retry-After` header. |

## Index of error codes

| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthorized` | The request has no `Authorization: Bearer` credential. |
| 401 | `api_key_invalid` | The key is not recognized. |
| 401 | `api_key_revoked` | The key was revoked. |
| 401 | `api_key_expired` | The key has expired. |
| 401 | `api_key_owner_inactive` | The administrator who owns the key left the account or was deactivated. |
| 401 | `api_key_owner_not_admin` | The owner of the key is no longer an administrator. |
| 403 | `insufficient_scope` | The key does not carry the scope the operation requires. |
| 403 | `forbidden` | The owner of the key lacks the module, organization or administrator permission the operation requires. |
| 402 | `addon_required` | The account has not contracted, let expire, or lost with the end of the free trial, the add-on of that module. |
| 400 | `validation_error` | The query or the body is invalid. `errors[]` lists every offending field. |
| 422 | `idempotency_key_reused` | The `Idempotency-Key` was already used with a different body. |
| 404 | `not_found` | The resource does not exist or is outside the tenancy of the key. The two are indistinguishable. |
| 409 | `conflict` | A request with that key is still in progress (`in_progress`), a conditional write held the resource for more than 5 seconds (`lock_timeout`), or the state of the resource prevents the operation (`invalid_state`). The cause is in `errors[0].code`. |
| 409 | `contact_in_use` | Other records still use the contact; `errors[]` counts them per type. |
| 409 | `address_in_use` | Orders still use the address as their billing or shipping address. |
| 409 | `insufficient_stock` | There is not enough stock for the request; `errors[]` has one entry per short item. |
| 412 | `precondition_failed` | The `If-Match` tag no longer matches: the resource changed since you read it. |
| 429 | `rate_limited` | A rate limit was exceeded; `Retry-After`, repeated in `retryAfter`, says how long to wait. |
| 500 | `internal_error` | An internal error. It is also the answer if the store that counts rate limits fails: the API does not skip the limit in that case, so retry with backoff. |
| 503 | `service_unavailable` | The feature is switched off for the account. Retrying does not help until it is switched on. |

The 401 codes are checked in a fixed order: no bearer credential ([`unauthorized`](https://docs.filmo.io/rest-api/errors/unauthorized)), then an unrecognized key ([`api_key_invalid`](https://docs.filmo.io/rest-api/errors/api_key_invalid)), then a revoked one, an expired one and finally an owner who is inactive or no longer an administrator. A 401 and a 403 [`insufficient_scope`](https://docs.filmo.io/rest-api/errors/insufficient_scope) do not consume rate-limit quota; every other response of an authenticated key does.

Each code has its own page with the operations that return it and what to do. Authentication codes are also covered in [Authentication](https://docs.filmo.io/rest-api/guides/authentication#auth-errors).

## Three kinds of “you cannot”

Three different rejections look alike and are fixed in three different places. Sending all of them to the same place sends you debugging in the wrong one.

| Status and code | It is about | What is wrong | Who fixes it |
| --- | --- | --- | --- |
| 403 `insufficient_scope` | The key | The key does not carry the scope the endpoint requires; `requiredScope` names it. | Reissue the key with that scope. |
| 403 `forbidden` | The owner of the key | The key is correct, but its owner lacks the module, organization or administrator permission the operation demands. | An administrator of the account grants the permission. |
| 402 `addon_required` | The add-on | The account has not contracted, or let expire, the add-on of the module; `requiredAddon` names it. No role bypasses it. The API and MCP are not an add-on. | Whoever manages the subscription. |

Help Center articles: [API scopes and permissions](https://www.filmo.io/help/reference/integrations-and-api/api-scopes-and-permissions), [Roles and permissions](https://www.filmo.io/help/reference/account-and-settings/sign-in-team-and-permissions/roles-and-permissions) and [Your subscription](https://www.filmo.io/help/reference/account-and-settings/your-filmo-subscription/your-subscription). See also [Scopes](https://docs.filmo.io/rest-api/guides/scopes).

## Details of the errors that carry more

### validation_error

`errors[]` lists every offending field, not only the first. `path` uses dot and bracket notation (`lines[0].itemId`) and is empty when the whole body is the problem. The field `code` is stable. These are the codes seen so far; the list is not exhaustive, so handle an unknown one as a generic invalid field:

- `required`, `must_not_be_null`
- `invalid_type`, `invalid_value`, `invalid_format`
- `too_small`, `too_big`
- `unknown_field`
- `not_found`
- `must_be_after`

A body that references an identifier outside your tenancy, such as the contact of an order, is a 400 naming the field, and nothing is written. The message is the same whether the identifier does not exist or belongs to another account, so it cannot be used to probe either. Reading a resource outside your tenancy is a 404 identical to it not existing.

### conflict

A 409 [`conflict`](https://docs.filmo.io/rest-api/errors/conflict) says why in `errors[0].code`. The cause decides whether waiting helps:

| `errors[0].code` | Cause | What to do |
| --- | --- | --- |
| `in_progress` | Another request with the same `Idempotency-Key` is still running. | Wait and retry with the same key. Do not send a second attempt in parallel. |
| `lock_timeout` | Another conditional (`If-Match`) write held the resource for more than 5 seconds. | Retry with backoff. If it keeps happening, read the resource. |
| `invalid_state` | The state of the resource forbids the operation, or the write broke a uniqueness rule. A line of an atomic batch also reports it when the line is in a wrong state; a missing line or an invalid quantity reports `not_found` or `validation_error`. | Do not retry: waiting does not change the state. Read the resource and decide. |

Retry `in_progress` and `lock_timeout` at most 3 times with exponential backoff and the same key, then read the resource. Treat a 409 conflict with any other `errors[0].code`, or with none, like `invalid_state` after those 3 retries.

### contact_in_use and address_in_use

`errors[]` has one entry per type of record that uses the contact or the address: `path` is the type and `count` how many records of that type use it. The values of `path` come from a closed set, listed in the `x-path-values` of the `path` property in the spec: for `contact_in_use`, `orders`, `projects`, `quotes`, `invoices`, `proformas`, `creditNotes`, `simpleCreditNotes`, `salesReceipts`, `supplierInvoices`, `contracts`, `subrents`, `purchaseOrders`, `sepaMandates`, `receipts` and `consignments`; for `address_in_use`, `orders`. The set may grow, so tolerate a value you do not know. When anything uses the contact, nothing is deleted.

### insufficient_stock

One entry per item, or per space, that is short. `path` is `items` or `spaces`, and each entry has `itemId`, `requested`, `available` and `shortfall`.

## Language of the messages

`detail` and every `errors[].message` come in the language negotiated with the `Accept-Language` header. `es-ES` and `en-US` are guaranteed; any other supported language falls back to English. Without the header, a request made with a key that exists uses the preferred language of the key owner, also when the error is a 401 for a revoked or expired key or a 403 [`insufficient_scope`](https://docs.filmo.io/rest-api/errors/insufficient_scope). A request with no credential, or with a key Filmo does not recognise, has no owner to ask: its 401 comes in Spanish unless you send `Accept-Language`. Always send the header. `Content-Language` tells you which one the response used. Never write logic that matches on those texts. See [Languages](https://docs.filmo.io/further-reading/languages).

## Responses that do not come from the API

Some failures happen before the request reaches the API or after it leaves it: a function that times out (504), a request body larger than the platform accepts (413), a bad gateway (502) or a firewall challenge. They arrive without a problem document, so there is no `code` and no `requestId`. You recognize them because the content type is not `application/problem+json`. Do not try to read `code` from them: decide by the HTTP status.

- 502 and 504: the outcome is unknown. Retry with the same `Idempotency-Key`; on a write without a key, read the resource first.
- 413: the body is too large. Retrying does not help; send less in each request.
- The platform generates 413, 502 and 504; the API never sends them as a problem document. Do not depend on any platform-specific header to recognize these responses: any 5xx without `application/problem+json` is one of them.
- Timeouts: [`POST /api/v1/ai/ask`](https://docs.filmo.io/rest-api/endpoint-reference/ai/ask-the-ai-about-your-data) may run for up to 300 seconds, so give that call a long client timeout. For every other route, Filmo documents no limit; set your own client timeouts and handle a 504.

## Retry decisions

One policy per status is not enough. The same 409 can mean wait and retry or never retry, a 500 can repeat for 24 hours, and a 207 is not fixed by resending the same request. For a 409 conflict, read the cause in `errors[0].code` first (see above). Then find the first row that matches your response.

| Status | Idempotency-Key | Idempotent-Replay: true | What to do |
| --- | --- | --- | --- |
| 409 conflict · in_progress | Yes | No | `errors[0].code` is `in_progress`: another request with the same `Idempotency-Key` is still running. Wait and retry with the same key (exponential backoff, up to 3 times): you get its stored response when it finishes. If the 409 persists for about two minutes, the reservation is treated as abandoned and the next attempt with the same key runs; read the resource before retrying. |
| 409 conflict · lock_timeout | — | No | `errors[0].code` is `lock_timeout`: another write held the resource, or the database aborted the transaction on a transient conflict; nothing was written. Retry with the same key and an exponential backoff, up to 3 times; if it persists, read the resource again. |
| 409 conflict · invalid_state | — | — | `errors[0].code` is `invalid_state`: the state of the resource forbids the operation (a unique violation, a line of an atomic batch, an item deleted while saving). Do not retry unchanged: waiting never fixes it. Read the resource again; if the request still fits, you may send it again. |
| 409 contact_in_use · address_in_use · insufficient_stock | — | — | Do not retry. `errors[]` says what blocks the operation: fix that first. |
| 412 precondition_failed | — | — | Read the resource again, redo your change on top of it and send the new `ETag`. |
| 422 idempotency_key_reused | Yes | — | A bug in the client: the key was used with a different request (a different method, path or body, compared byte by byte). Use a new key only if it really is a different operation. |
| 429 rate_limited | — | — | Wait the seconds in `Retry-After` and retry with the same key. |
| 500 internal_error | Yes | Yes | Stop. The failure happened after the commit and is stored and repeated for 24 h with that key: read the resource to see where it ended up. |
| 500 internal_error | Yes | No | Retry with exponential backoff and the same key. |
| 500 internal_error | No | — | Read before retrying a write: without a key nothing protects you from a duplicate. |
| 503 service_unavailable | — | — | Do not retry: the feature is switched off for the account. |
| 207 (`returnOrderLineItems` with `atomic: false`) | Yes | — | The lines that failed were not returned and the rest were. Resend only the failed ones with a new key; repeating the key would return the same 207. |
| 413 · 502 · 504 (not `application/problem+json`) | Yes | — | An answer from the platform, not from the API: decide by the status and retry with the same key. A 413 is not fixed by retrying (the body is too large). |
| Any validation 4xx | — | — | Fix the request and resend it; never in a loop. |
| 401 · 402 · 403 (key, owner or plan) | — | — | Do not retry: resending the same request fails again. Act on the key, its owner or the plan: use a valid key with the right scope, ask an administrator for the missing permission, or add the add-on. |

The key is what makes retries safe: see [Idempotency](https://docs.filmo.io/rest-api/guides/idempotency). For writes that use `If-Match`, see [Concurrency](https://docs.filmo.io/rest-api/guides/concurrency).

## Reference retry helper

This helper wraps any call and applies the decisions above. Copy it, change the name of the environment variables if you keep your key elsewhere, and call it instead of `fetch`, `requests` or `curl`.

- Every write that declares the `Idempotency-Key` header carries one: the one you pass, or a new UUID. The same key is sent on every retry of that call. The helper sends none on the query POSTs (the availability checks, the contacts lookup and [`POST /api/v1/ai/ask`](https://docs.filmo.io/rest-api/endpoint-reference/ai/ask-the-ai-about-your-data)), which do not declare the header and ignore it.
- 429: wait `Retry-After` seconds and retry, up to 5 times.
- 409 [`conflict`](https://docs.filmo.io/rest-api/errors/conflict): on `in_progress` and `lock_timeout`, up to 3 retries with exponential backoff, then it stops and tells you to read the resource; on `invalid_state` it stops at once and tells you the same.
- 500 with `Idempotent-Replay: true`: stops at once and tells you to read the resource.
- 500 without that header: up to 3 retries with exponential backoff and the same key. On a write sent without a key, it stops and tells you to read the resource.
- 502, 503 and 504 without a problem document, and network failures: handled as a 500 without the replay header.
- Everything else fails immediately, with the status and `code`, and an `action` that says what to do next.

*Retry helper*

**cURL**

```bash
#!/usr/bin/env bash
# filmo-request.sh: one function for any call to the Filmo REST API.
# Needs curl and jq, and FILMO_API_KEY; FILMO_BASE_URL is optional.
#
#   filmo_request METHOD PATH [JSON_BODY]      (put the query string in PATH)
#
# Writes get an Idempotency-Key: IDEMPOTENCY_KEY if set, else a fresh UUID. The
# query POSTs (availability checks, contacts lookup, ai ask) do not declare the header, so they get none.
# Set NO_IDEMPOTENCY_KEY=1 to send none. The body goes to stdout when the call succeeds.
# Optional: ACCEPT_LANGUAGE (e.g. en) sets the language of error messages; IF_MATCH sends an ETag.
# Exit code: 0 done; 10 fix the request; 11 read the resource before anything else;
#            12 read again and redo the change (412); 13 give up.
FILMO_MAX_RETRIES=3

filmo_request() {
  local method="$1" path="$2" body="$3"
  local base="${FILMO_BASE_URL:-https://app.filmo.io}"
  local is_write=0 key="" retries=0 waits=0
  [ "$method" != "GET" ] && is_write=1
  case "${path%%[?]*}" in
    /api/v1/availability/check | /api/v1/staff/availability | /api/v1/vehicles/availability | /api/v1/contacts/lookup | /api/v1/ai/ask) is_write=0 ;;
  esac
  if [ "$is_write" = 1 ] && [ "$NO_IDEMPOTENCY_KEY" != 1 ]; then
    key="${IDEMPOTENCY_KEY:-$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)}"
  fi
  local hdr out
  hdr="$(mktemp)"
  out="$(mktemp)"

  while :; do
    set -- -sS -X "$method" -D "$hdr" -o "$out" -w '%{http_code}' \
      -H "Authorization: Bearer $FILMO_API_KEY" -H "Accept: application/json"
    [ -n "$key" ] && set -- "$@" -H "Idempotency-Key: $key" # the same key on every retry
    [ -n "$ACCEPT_LANGUAGE" ] && set -- "$@" -H "Accept-Language: $ACCEPT_LANGUAGE"
    [ -n "$IF_MATCH" ] && set -- "$@" -H "If-Match: $IF_MATCH"
    [ -n "$body" ] && set -- "$@" -H "Content-Type: application/json" --data "$body"
    local status
    status="$(curl "$@" "$base$path")" || status=000 # network failure: it may or may not have arrived

    local problem=0 code="" cause="" replay="" wait=""
    if [ "$status" != 000 ]; then
      grep -qi '^content-type:.*application/problem+json' "$hdr" && problem=1
      if [ "$problem" = 1 ]; then
        code="$(jq -r '.code // empty' "$out" 2>/dev/null)"
        cause="$(jq -r '.errors[0].code // empty' "$out" 2>/dev/null)" # why a 409 happened
      fi
      grep -qi '^idempotent-replay: true' "$hdr" && replay=true
      wait="$(grep -i '^retry-after:' "$hdr" | tail -1 | tr -d '\r' | awk '{print $2}')"
    fi

    if [ "$status" != 000 ] && [ "$status" -lt 400 ]; then
      cat "$out"
      rm -f "$hdr" "$out"
      return 0
    fi

    if [ "$status" = 429 ]; then
      # Wait as long as the server says, then retry with the same key.
      [ "$waits" -ge 5 ] && { _filmo_fail "$status" "$code" "$out" "$hdr"; return 13; }
      waits=$((waits + 1))
      sleep "$([ "${wait:-0}" -gt 0 ] 2>/dev/null && echo "$wait" || echo 1)"
      continue
    fi
    if [ "$status" = 409 ] && [ "$code" = conflict ]; then
      # errors[0].code says why. in_progress and lock_timeout clear by waiting;
      # invalid_state (the state of the resource forbids the call) never does.
      if [ "$cause" = invalid_state ] || [ "$retries" -ge "$FILMO_MAX_RETRIES" ]; then
        _filmo_fail "$status" "$code" "$out" "$hdr"
        return 11
      fi
      sleep $((1 << retries)) # 1 s, 2 s, 4 s
      retries=$((retries + 1))
      continue
    fi
    # A 500 from the API, or a platform failure (network error, or a 502/503/504 that is not a problem document).
    if [ "$status" = 500 ] || [ "$status" = 000 ] ||
      { [ "$problem" = 0 ] && { [ "$status" = 502 ] || [ "$status" = 503 ] || [ "$status" = 504 ]; }; }; then
      # The failure happened after the commit and is stored for 24 h: retrying returns it again.
      if [ "$replay" = true ] || { [ "$is_write" = 1 ] && [ -z "$key" ]; }; then
        _filmo_fail "$status" "$code" "$out" "$hdr"
        return 11
      fi
      if [ "$retries" -ge "$FILMO_MAX_RETRIES" ]; then
        _filmo_fail "$status" "$code" "$out" "$hdr"
        return 13
      fi
      sleep $((1 << retries))
      retries=$((retries + 1))
      continue
    fi
    _filmo_fail "$status" "$code" "$out" "$hdr"
    if [ "$status" = 412 ]; then return 12; fi
    if [ "$status" -ge 500 ]; then return 13; fi
    return 10
  done
}

_filmo_fail() { # status code body-file header-file
  local rid
  rid="$(grep -i '^x-request-id:' "$4" 2>/dev/null | tr -d '\r' | awk '{print $2}')"
  echo "filmo: HTTP $1 ${2:+($2) }request $rid" >&2
  cat "$3" >&2 2>/dev/null
  rm -f "$3" "$4"
}

# 207 (returnOrderLineItems with atomic false): resend only the lines that failed,
# each round with a NEW key, because the same key would replay the same 207.
# Usage: return_lines '[{"lineItemId":"...","checkInQuantity":1,"returnCondition":"good"}]'
return_lines() {
  local pending="$1" round res
  for round in 1 2 3; do
    [ "$(echo "$pending" | jq 'length')" = 0 ] && break
    res="$(IDEMPOTENCY_KEY="$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)" \
      filmo_request POST /api/v1/orders/line-items/returns \
      "$(jq -c '{items: ., atomic: false}' <<<"$pending")")" || return $?
    # Keep only the elements that failed (their index in what we sent).
    pending="$(jq -c --argjson sent "$pending" \
      '[.data[] | select(.status == "failed") | $sent[.index]]' <<<"$res")"
  done
  echo "$pending" # the lines that still failed; [] when everything was returned
}
```

**Node**

```javascript
// filmo-request.mjs: one function for any call to the Filmo REST API.
// Node 22+, no dependencies. Needs FILMO_API_KEY; FILMO_BASE_URL is optional.
const BASE = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const MAX_RETRIES = 3; // conflicts, 500s and platform errors
const MAX_RATE_LIMIT_WAITS = 5;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 0.5 s, 1 s, 2 s... with jitter, capped at 30 s.
const backoff = (retry) => Math.min(30_000, 500 * 2 ** retry) * (0.5 + Math.random() / 2);

export class FilmoError extends Error {
  // action: "fix-request" | "read-resource" | "reread-and-redo" | "give-up"
  constructor(message, { action, status, problem, requestId }) {
    super(message);
    Object.assign(this, { action, status, problem, requestId });
  }
}

// Query POSTs do not declare Idempotency-Key, so they never get one.
const QUERY_POSTS = [
  "/api/v1/availability/check",
  "/api/v1/staff/availability",
  "/api/v1/vehicles/availability",
  "/api/v1/contacts/lookup",
  "/api/v1/ai/ask",
];

export async function filmoRequest(method, path, { query, body, idempotencyKey, ifMatch, language, headers: extra } = {}) {
  const isWrite = method !== "GET" && !QUERY_POSTS.includes(path.split("?")[0]);
  // Every write that declares the header carries a key. Pass idempotencyKey: null to send none on purpose.
  const key = !isWrite ? null : idempotencyKey === undefined ? crypto.randomUUID() : idempotencyKey;

  const url = new URL(path, BASE);
  for (const [name, value] of Object.entries(query ?? {})) {
    for (const v of [].concat(value)) url.searchParams.append(name, String(v));
  }
  const headers = { Authorization: `Bearer ${process.env.FILMO_API_KEY}`, Accept: "application/json" };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (key) headers["Idempotency-Key"] = key; // the same key on every retry of this call
  if (ifMatch) headers["If-Match"] = ifMatch; // the ETag you read
  if (language) headers["Accept-Language"] = language; // language of error messages, e.g. "en"
  Object.assign(headers, extra);

  let retries = 0;
  let waits = 0;
  for (;;) {
    let res = null;
    try {
      res = await fetch(url, {
        method,
        headers,
        body: body === undefined ? undefined : JSON.stringify(body),
      });
    } catch {
      // Network failure: the request may or may not have arrived.
    }
    const status = res ? res.status : 0;
    const text = res ? await res.text() : "";
    if (res && status < 400) {
      return { status, headers: res.headers, body: text ? JSON.parse(text) : null };
    }

    let problem = null;
    if (res?.headers.get("content-type")?.includes("application/problem+json")) {
      try {
        problem = JSON.parse(text);
      } catch {}
    }
    const fail = (action) =>
      new FilmoError(method + " " + path + " -> " + (status || "network error") + (problem ? " " + problem.code : ""), {
        action,
        status,
        problem,
        requestId: res?.headers.get("x-request-id") ?? problem?.requestId,
      });

    if (status === 429) {
      // Wait as long as the server says, then retry with the same key.
      if (waits++ >= MAX_RATE_LIMIT_WAITS) throw fail("give-up");
      const seconds = Number(res.headers.get("retry-after"));
      await sleep((seconds > 0 ? seconds : 1) * 1000);
      continue;
    }
    if (status === 409 && problem?.code === "conflict") {
      // errors[0].code says why. "in_progress" and "lock_timeout" clear by waiting;
      // "invalid_state" (the state of the resource forbids the call) never does.
      const cause = problem.errors?.[0]?.code;
      if (cause === "invalid_state" || retries >= MAX_RETRIES) throw fail("read-resource");
      await sleep(backoff(retries++));
      continue;
    }
    // A 500 from the API, or a platform failure (network error, or a 502/503/504 that is not a problem document).
    const platform = status === 0 || (!problem && [502, 503, 504].includes(status));
    if (status === 500 || platform) {
      // The failure happened after the commit and is stored for 24 h: retrying returns it again.
      if (res?.headers.get("idempotent-replay") === "true") throw fail("read-resource");
      // Without a key a retry could write twice.
      if (isWrite && !key) throw fail("read-resource");
      if (retries >= MAX_RETRIES) throw fail("give-up");
      await sleep(backoff(retries++));
      continue;
    }
    if (status === 412) throw fail("reread-and-redo");
    throw fail(status >= 500 ? "give-up" : "fix-request"); // validation, 413, 422, 503 service_unavailable...
  }
}

// 207 (returnOrderLineItems with atomic: false): resend only the lines that failed,
// each round with a NEW key, because the same key would replay the same 207.
export async function returnLines(items, rounds = 3) {
  let pending = items;
  const returned = [];
  for (let round = 0; round < rounds && pending.length > 0; round++) {
    const res = await filmoRequest("POST", "/api/v1/orders/line-items/returns", {
      body: { items: pending, atomic: false },
      idempotencyKey: crypto.randomUUID(),
    });
    const failed = [];
    for (const result of res.body.data) {
      if (result.status === "returned") returned.push(result.id);
      else failed.push(pending[result.index]);
    }
    pending = failed;
  }
  return { returned, failed: pending };
}
```

**Python**

```python
# filmo_request.py: one function for any call to the Filmo REST API.
# Python 3 with requests. Needs FILMO_API_KEY; FILMO_BASE_URL is optional.
import os
import random
import time
import uuid

import requests

BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
MAX_RETRIES = 3  # conflicts, 500s and platform errors
MAX_RATE_LIMIT_WAITS = 5
_UNSET = object()


class FilmoError(Exception):
    """action: fix-request | read-resource | reread-and-redo | give-up"""

    def __init__(self, message, action, status, problem, request_id):
        super().__init__(message)
        self.action, self.status = action, status
        self.problem, self.request_id = problem, request_id


def backoff(retry):
    # 0.5 s, 1 s, 2 s... with jitter, capped at 30 s.
    return min(30, 0.5 * 2**retry) * (0.5 + random.random() / 2)


# Query POSTs do not declare Idempotency-Key, so they never get one.
QUERY_POSTS = {
    "/api/v1/availability/check",
    "/api/v1/staff/availability",
    "/api/v1/vehicles/availability",
    "/api/v1/contacts/lookup",
    "/api/v1/ai/ask",
}


def filmo_request(
    method, path, query=None, body=None, idempotency_key=_UNSET, if_match=None, language=None, extra_headers=None
):
    is_write = method != "GET" and path.split("?")[0] not in QUERY_POSTS
    # Every write that declares the header carries a key. Pass idempotency_key=None to send none on purpose.
    if not is_write:
        key = None
    else:
        key = str(uuid.uuid4()) if idempotency_key is _UNSET else idempotency_key
    headers = {
        "Authorization": f"Bearer {os.environ['FILMO_API_KEY']}",
        "Accept": "application/json",
    }
    if key:
        headers["Idempotency-Key"] = key  # the same key on every retry of this call
    if if_match:
        headers["If-Match"] = if_match  # the ETag you read
    if language:
        headers["Accept-Language"] = language  # language of error messages, e.g. "en"
    headers.update(extra_headers or {})

    retries = waits = 0
    while True:
        try:
            res = requests.request(
                method, BASE + path, params=query, json=body, headers=headers, timeout=30
            )
        except requests.RequestException:
            res = None  # Network failure: the request may or may not have arrived.
        status = res.status_code if res is not None else 0
        if res is not None and status < 400:
            return status, res.headers, (res.json() if res.content else None)

        problem = None
        if res is not None and "application/problem+json" in res.headers.get("Content-Type", ""):
            try:
                problem = res.json()
            except ValueError:
                pass

        def fail(action):
            label = "network error" if status == 0 else str(status)
            if problem:
                label += " " + problem["code"]
            request_id = (res.headers.get("X-Request-Id") if res is not None else None) or (
                problem or {}
            ).get("requestId")
            return FilmoError(method + " " + path + " -> " + label, action, status, problem, request_id)

        if status == 429:
            # Wait as long as the server says, then retry with the same key.
            if waits >= MAX_RATE_LIMIT_WAITS:
                raise fail("give-up")
            waits += 1
            try:
                seconds = float(res.headers.get("Retry-After", ""))
            except ValueError:
                seconds = 0
            time.sleep(seconds if seconds > 0 else 1)
            continue
        if status == 409 and problem and problem["code"] == "conflict":
            # errors[0].code says why. in_progress and lock_timeout clear by waiting;
            # invalid_state (the state of the resource forbids the call) never does.
            cause = ((problem.get("errors") or [{}])[0]).get("code")
            if cause == "invalid_state" or retries >= MAX_RETRIES:
                raise fail("read-resource")
            time.sleep(backoff(retries))
            retries += 1
            continue
        # A 500 from the API, or a platform failure (network error, or a 502/503/504 that is not a problem document).
        platform = status == 0 or (problem is None and status in (502, 503, 504))
        if status == 500 or platform:
            # The failure happened after the commit and is stored for 24 h: retrying returns it again.
            if res is not None and res.headers.get("Idempotent-Replay") == "true":
                raise fail("read-resource")
            # Without a key a retry could write twice.
            if is_write and not key:
                raise fail("read-resource")
            if retries >= MAX_RETRIES:
                raise fail("give-up")
            time.sleep(backoff(retries))
            retries += 1
            continue
        if status == 412:
            raise fail("reread-and-redo")
        raise fail("give-up" if status >= 500 else "fix-request")  # validation, 413, 422, 503 service_unavailable...


def return_lines(items, rounds=3):
    """207 (returnOrderLineItems with atomic False): resend only the lines that failed,
    each round with a NEW key, because the same key would replay the same 207."""
    pending, returned = items, []
    for _ in range(rounds):
        if not pending:
            break
        _, _, res = filmo_request(
            "POST",
            "/api/v1/orders/line-items/returns",
            body={"items": pending, "atomic": False},
            idempotency_key=str(uuid.uuid4()),
        )
        failed = []
        for result in res["data"]:
            if result["status"] == "returned":
                returned.append(result["id"])
            else:
                failed.append(pending[result["index"]])
        pending = failed
    return returned, pending
```

| `action` (exit code in bash) | Meaning |
| --- | --- |
| `fix-request` (10) | The request is wrong. Correct it; do not retry as is. |
| `read-resource` (11) | The outcome is unknown or stored. Read the resource to see where it stands before you do anything else. |
| `reread-and-redo` (12) | 412: read the resource again and redo the change with the new `ETag`. |
| `give-up` (13) | The helper ran out of retries, or the failure will not clear by retrying. |

To resume a write after a restart, store the key you generated and pass it again: a new key would create a second resource. The 207 case applies only to [`POST /api/v1/orders/line-items/returns`](https://docs.filmo.io/rest-api/endpoint-reference/orders/return-order-lines) sent with `atomic: false`; `returnLines` (`return_lines` in bash and Python) resends the failed lines with a new key on each round.

## Errors over MCP

An MCP tool reports a failure as a result with `isError: true`, and the same RFC 9457 problem as JSON text in `content[0].text`. There is no `structuredContent` on an error. Parse the text and branch on `code` as you do with REST. The 429 problem carries `retryAfter`. What REST sends as headers, a tool result carries in `meta`: `meta.idempotencyKey`, `meta.idempotentReplay` and `meta.rateLimit` (`limit`, `remaining`, `reset`).

## Get help

When you contact support, include the `requestId` of the failing response. See [Support](https://docs.filmo.io/further-reading/support) or the Help Center article [Contact support](https://www.filmo.io/help/reference/support/contact-support).

---

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