*REST API › Guides*

# Idempotency and safe retries

> Send an `Idempotency-Key` header with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.

A network timeout does not tell you whether the request arrived. Retrying a create blindly can make the record twice; not retrying can leave it never created. You choose the key, so you can retry as often as you need and still get exactly one effect.

## How the key works

**Idempotency: what each retry gets**
- Your system → API: POST with Idempotency-Key: K One key per business operation, for the first attempt and every retry of that same operation.
- API → Your system: Executes and stores the response
- The retry, within 24 h
- Your system → API: Same key K, same body
- API → Your system: The stored response, Idempotent-Replay: true, nothing runs again Also when the first attempt failed after the write was committed: that error is stored and replayed.
- Your system → API: Same key K, different body
- API → Your system: 422 idempotency_key_reused, the handler never runs
- Your system → API: Same key K while the first attempt is still running
- API → Your system: 409 conflict (errors[0].code: in_progress): wait and retry
- After 24 h the key expires: it is a new request.

Put one value in the `Idempotency-Key` header (up to 255 characters; a UUID works) on the first attempt and on every retry of that same operation. A key identifies one operation, never a batch of them.

| Situation | What Filmo does | What you do |
| --- | --- | --- |
| Same key, same body, within 24 hours | Returns the stored response of the first attempt without running anything again, with the `Idempotent-Replay: true` header. | Treat it as the result of the operation. This is the response you were after. |
| Same key, different body | Answers 422 `idempotency_key_reused`. The handler never runs. The method, the path and the raw body all belong to the fingerprint of the first request, so reordering JSON keys or sending the same key to another path also answers 422. | Fix the client: reusing a key for something else is almost always a bug. Use a new key only for a genuinely different operation. |
| Same key while the first attempt is still running | Answers 409 `conflict`, with `in_progress` in `errors[0].code`. | Wait and retry with the same key. Do not send a second attempt in parallel. |
| The first attempt failed before writing anything | Releases the key. The retry runs again. | Retry with the same key. |
| The first attempt failed after the write was committed (the response could not be built) | Stores that error and replays it like any other response. The retry does not write twice. | Stop retrying and read the resource to see where it stands. |
| More than 24 hours later | The key has expired and the request is new. | If you need a guarantee beyond 24 hours, use a different key and check on your side first. |

## Which operations accept it

Only operations that write: 93 of them, covering every create, update, delete and action. Reads ignore the header. So do the query-by-body POSTs, such as [`POST /api/v1/availability/check`](https://docs.filmo.io/rest-api/endpoint-reference/availability/check-item-availability) and [`POST /api/v1/ai/ask`](https://docs.filmo.io/rest-api/endpoint-reference/ai/ask-the-ai-about-your-data): they are POST because the question does not fit in a query string, not because they change anything. Each operation that accepts the key declares the header in its own contract, so a generated client already knows about it.

| Operation | Scope |
| --- | --- |
| `POST /api/v1/contacts` | `contacts:write` |
| `PATCH /api/v1/contacts/{contactId}` | `contacts:write` |
| `DELETE /api/v1/contacts/{contactId}` | `contacts:write` |
| `PATCH /api/v1/contacts/{contactId}/preferences` | `contacts:write` |
| `POST /api/v1/contacts/{contactId}/addresses` | `contacts:write` |
| `PATCH /api/v1/contacts/{contactId}/addresses/{addressId}` | `contacts:write` |
| `DELETE /api/v1/contacts/{contactId}/addresses/{addressId}` | `contacts:write` |
| `POST /api/v1/contacts/{contactId}/related-contacts` | `contacts:write` |
| `PATCH /api/v1/contacts/{contactId}/related-contacts/{relatedContactId}` | `contacts:write` |
| `DELETE /api/v1/contacts/{contactId}/related-contacts/{relatedContactId}` | `contacts:write` |
| `POST /api/v1/items` | `items:write` |
| `PATCH /api/v1/items/{itemId}` | `items:write` |
| `DELETE /api/v1/items/{itemId}` | `items:write` |
| `PATCH /api/v1/items/{itemId}/physical-properties` | `items:write` |
| `PATCH /api/v1/items/{itemId}/pricing` | `items:write` |
| `POST /api/v1/orders` | `orders:write` |
| `PATCH /api/v1/orders/{orderId}` | `orders:write` |
| `POST /api/v1/projects` | `projects:write` |
| `PUT /api/v1/projects/{projectId}/archive` | `projects:write` |
| `PATCH /api/v1/projects/{projectId}` | `projects:write` |
| `PATCH /api/v1/serialized-items/{serializedItemId}` | `items:write` |
| `PATCH /api/v1/spaces/{spaceId}` | `spaces:write` |
| `DELETE /api/v1/spaces/{spaceId}` | `spaces:write` |
| `PUT /api/v1/spaces/{spaceId}/amenities` | `spaces:write` |
| `PATCH /api/v1/spaces/{spaceId}/pricing` | `spaces:write` |
| `POST /api/v1/repair-tickets` | `repair-tickets:write` |
| `PATCH /api/v1/repair-tickets/{ticketId}` | `repair-tickets:write` |
| `POST /api/v1/vehicles` | `vehicles:write` |
| `PATCH /api/v1/vehicles/{vehicleId}` | `vehicles:write` |
| `DELETE /api/v1/vehicles/{vehicleId}` | `vehicles:write` |
| `POST /api/v1/staff` | `staff:write` |
| `PATCH /api/v1/staff/{staffId}` | `staff:write` |
| `DELETE /api/v1/staff/{staffId}` | `staff:write` |
| `PATCH /api/v1/subrents/{subrentId}` | `subrents:write` |
| `PATCH /api/v1/purchase-orders/{purchaseOrderId}` | `purchase-orders:write` |
| `POST /api/v1/tags` | `tags:write` |
| `DELETE /api/v1/tags/{tagId}` | `tags:write` |
| `POST /api/v1/tags/{tagId}/assignments` | `tags:write` |
| `DELETE /api/v1/tags/{tagId}/assignments` | `tags:write` |
| `POST /api/v1/kits` | `kits:write` |
| `PATCH /api/v1/kits/{kitId}` | `kits:write` |
| `POST /api/v1/collections` | `collections:write` |
| `PATCH /api/v1/collections/{collectionId}` | `collections:write` |
| `POST /api/v1/services` | `services:write` |
| `PATCH /api/v1/services/{serviceId}` | `services:write` |
| `POST /api/v1/internal-services` | `internal-services:write` |
| `PATCH /api/v1/internal-services/{internalServiceId}` | `internal-services:write` |
| `POST /api/v1/planner/assignments` | `planner:write` |
| `POST /api/v1/planner/assignments/remove` | `planner:write` |
| `POST /api/v1/availability/unavailabilities` | `availability:write` |
| `PATCH /api/v1/availability/unavailabilities/{unavailabilityId}` | `availability:write` |
| `DELETE /api/v1/availability/unavailabilities/{unavailabilityId}` | `availability:write` |
| `POST /api/v1/incidents` | `incidents:write` |
| `PATCH /api/v1/incidents/{incidentId}` | `incidents:write` |
| `POST /api/v1/incidents/{incidentId}/comments` | `incidents:write` |
| `POST /api/v1/notes` | `notes:write` |
| `POST /api/v1/tasks` | `tasks:write` |
| `PATCH /api/v1/tasks/{taskId}` | `tasks:write` |
| `DELETE /api/v1/tasks/{taskId}` | `tasks:write` |
| `POST /api/v1/crm/opportunities` | `crm:write` |
| `PATCH /api/v1/crm/opportunities/{opportunityId}` | `crm:write` |
| `PATCH /api/v1/crm/opportunities/{opportunityId}/stage` | `crm:write` |
| `POST /api/v1/crm/opportunities/{opportunityId}/close` | `crm:write` |
| `POST /api/v1/crm/opportunities/{opportunityId}/links` | `crm:write` |
| `POST /api/v1/purchase-orders/{purchaseOrderId}/approve` | `purchase-orders:write` |
| `POST /api/v1/purchase-orders/{purchaseOrderId}/receive` | `purchase-orders:write` |
| `POST /api/v1/treasury/payments` | `treasury:write` |
| `PATCH /api/v1/treasury/payments/{paymentId}` | `treasury:write` |
| `POST /api/v1/treasury/payments/{paymentId}/links` | `treasury:write` |
| `POST /api/v1/orders/{orderId}/cancel` | `orders:write` |
| `PUT /api/v1/orders/{orderId}/archive` | `orders:write` |
| `PUT /api/v1/items/{itemId}/accessories` | `items:write` |
| `PUT /api/v1/items/{itemId}/alternatives` | `items:write` |
| `PATCH /api/v1/items/{itemId}/variants/{variantId}` | `items:write` |
| `POST /api/v1/orders/line-items/returns` | `orders:write` |
| `POST /api/v1/workflows/{workflowId}/runs` | `workflows:run` |
| `POST /api/v1/orders/{orderId}/reserve` | `orders:write` |
| `POST /api/v1/orders/{orderId}/unreserve` | `orders:write` |
| `POST /api/v1/projects/{projectId}/orders/{orderId}` | `projects:write` |
| `DELETE /api/v1/projects/{projectId}/orders/{orderId}` | `projects:write` |
| `POST /api/v1/invoices/{invoiceId}/validate` | `invoices:write` |
| `POST /api/v1/invoices/{invoiceId}/void` | `invoices:write` |
| `PUT /api/v1/invoices/external/{originSystem}/{originExternalId}` | `invoices:write` |
| `POST /api/v1/credit-notes/{creditNoteId}/validate` | `invoices:write` |
| `POST /api/v1/credit-notes/{creditNoteId}/void` | `invoices:write` |
| `PATCH /api/v1/proformas/{proformaId}/status` | `invoices:write` |
| `PATCH /api/v1/quotes/{quoteId}/status` | `quotes:write` |
| `POST /api/v1/contracts/{contractId}/validate` | `contracts:write` |
| `POST /api/v1/contracts/{contractId}/sign` | `contracts:write` |
| `POST /api/v1/contracts/{contractId}/void` | `contracts:write` |
| `POST /api/v1/contracts/{contractId}/cancel` | `contracts:write` |
| `POST /api/v1/supplier-invoices/{supplierInvoiceId}/validate` | `supplier-invoices:write` |
| `POST /api/v1/supplier-invoices/{supplierInvoiceId}/void` | `supplier-invoices:write` |

A replayed response is not a second execution, so it emits no events: a repeated `Idempotency-Key` does not start the account's workflows again. The key does not make every operation a no-op, though. Do not rely on a repeated archive being a no-op: archiving an order or a project that is already archived, under a new key, moves its `updatedAt` again and fires the archive event again, even though the operation's description promises no additional effect. Check the state before you archive twice.

## Four operations that need your own key

For most writes a retry without a key is a nuisance. For these four it duplicates something that cannot be undone, so always send a key you generated and persisted, and resend the same one if you retry.

| Operation | What a blind retry duplicates |
| --- | --- |
| `POST /api/v1/orders/line-items/returns` | The return: the same material goes back into the pool twice. |
| `POST /api/v1/purchase-orders/{purchaseOrderId}/receive` | The stock: `newStockReceived` registers new inventory each time. |
| `POST /api/v1/treasury/payments` | The payment: it is recorded twice. |
| `POST /api/v1/workflows/{workflowId}/runs` | The run: a second run is enqueued instead of returning the first. |

## Generate and store keys

- Generate one UUID per business operation (for example, one per record you are about to create), not one per HTTP attempt.
- Store the key with your own record before the first attempt. If your process crashes between the request and the response, the next run must reuse the same key.
- Reuse the key on every retry of that operation, with exactly the same body. A retry with an edited body is a 422.
- Never generate the key inside the retry loop.
- Remember the 24-hour window. A queue that may retry after a day should read the resource first instead of relying on the key.

## Create a record with safe retries

This example creates a contact and retries on network failures, 409, 429, 500 and 502, 503 or 504 (a gateway failure without a problem document, treated like a 500) with the same key, waits for `Retry-After` when the response carries it, and stops when a 500 comes back marked `Idempotent-Replay: true` or a 409 reports `invalid_state`.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
KEY="$(uuidgen)"   # store this with your own record BEFORE the first attempt

for attempt in 1 2 3 4; do
  rm -f headers.txt
  status=$(curl -sS -o response.json -D headers.txt -w '%{http_code}' \
    -X POST "$BASE/api/v1/contacts" \
    -H "Authorization: Bearer $FILMO_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $KEY" \
    -d '{"name":"Acme Rentals","contactType":"company","role":"client","email":"hello@example.com"}') || status=000

  case "$status" in
    2??) break ;;
    500)
      if grep -qi '^idempotent-replay: true' headers.txt; then
        echo "Stored 500: stop and read the resource before doing anything else" >&2
        exit 1
      fi ;;
    409)
      # in_progress or lock_timeout: wait and retry. invalid_state never clears by waiting.
      if grep -q '"invalid_state"' response.json; then
        echo "Conflict with the state of the resource: read it before doing anything else" >&2
        cat response.json >&2
        exit 1
      fi ;;
    000|429|502|503|504) ;;  # 502, 503, 504 without a problem document: treat like a 500, same key
    *) echo "Not retryable: HTTP $status" >&2; cat response.json >&2; exit 1 ;;
  esac

  wait=$(grep -i '^retry-after:' headers.txt 2>/dev/null | tr -dc '0-9')
  sleep "${wait:-$((2 ** attempt))}"
done

case "$status" in
  2??) cat response.json ;;
  *) echo "Gave up (last status $status): read the resource before retrying" >&2; exit 1 ;;
esac
```

**Node**

```javascript
import { randomUUID } from "node:crypto";

const base = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

// `key` is one UUID per business operation, stored with your own record
// before the first attempt and reused on every retry of that operation.
export async function createContact(body, key) {
  for (let attempt = 0; attempt < 4; attempt++) {
    let res;
    try {
      res = await fetch(base + "/api/v1/contacts", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.FILMO_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": key,
        },
        body: JSON.stringify(body),
      });
    } catch {
      await sleep(2 ** attempt); // network failure: retry with the SAME key
      continue;
    }

    if (res.ok) {
      const replayed = res.headers.get("Idempotent-Replay") === "true";
      return { contact: (await res.json()).data, replayed };
    }
    if (res.status === 500 && res.headers.get("Idempotent-Replay") === "true") {
      throw new Error("Stored 500: stop and read the resource before doing anything else");
    }
    if (res.status === 409) {
      const problem = await res.json();
      // "in_progress" and "lock_timeout" clear by waiting; "invalid_state" never does.
      if (problem.errors?.[0]?.code === "invalid_state") {
        throw new Error("Conflict with the state of the resource: " + problem.detail);
      }
    } else if (![429, 500, 502, 503, 504].includes(res.status)) {
      throw new Error("Not retryable: HTTP " + res.status + " " + (await res.text()));
    }
    await sleep(Number(res.headers.get("Retry-After")) || 2 ** attempt);
  }
  throw new Error("Gave up: read the resource before retrying");
}

const { contact, replayed } = await createContact(
  { name: "Acme Rentals", contactType: "company", role: "client", email: "hello@example.com" },
  randomUUID(),
);
console.log(contact.id, replayed ? "(replayed)" : "(created)");
```

**Python**

```python
import os
import time
import uuid

import requests

BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
AUTH = {"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}"}


def create_contact(body, key):
    """key: one UUID per business operation, stored before the first attempt."""
    for attempt in range(4):
        try:
            res = requests.post(
                BASE + "/api/v1/contacts",
                json=body,
                headers={**AUTH, "Idempotency-Key": key},
                timeout=30,
            )
        except requests.RequestException:
            time.sleep(2**attempt)  # network failure: retry with the SAME key
            continue

        if res.ok:
            return res.json()["data"], res.headers.get("Idempotent-Replay") == "true"
        if res.status_code == 500 and res.headers.get("Idempotent-Replay") == "true":
            raise RuntimeError("Stored 500: stop and read the resource before doing anything else")
        if res.status_code == 409:
            # "in_progress" and "lock_timeout" clear by waiting; "invalid_state" never does.
            cause = ((res.json().get("errors") or [{}])[0]).get("code")
            if cause == "invalid_state":
                raise RuntimeError("Conflict with the state of the resource: " + res.json()["detail"])
        elif res.status_code not in (429, 500, 502, 503, 504):
            raise RuntimeError(f"Not retryable: HTTP {res.status_code} {res.text}")
        time.sleep(float(res.headers.get("Retry-After", 2**attempt)))
    raise RuntimeError("Gave up: read the resource before retrying")


contact, replayed = create_contact(
    {"name": "Acme Rentals", "contactType": "company", "role": "client", "email": "hello@example.com"},
    str(uuid.uuid4()),
)
print(contact["id"], "(replayed)" if replayed else "(created)")
```

## The 500 that is replayed for 24 hours

A 500 can happen before the write or after it. If it happened after the write was committed, Filmo stores that 500 and replays it for 24 hours for that key. Retrying with exponential backoff then returns the same 500 every time, and the record already exists.

The signal is the `Idempotent-Replay: true` header on a 500. When you see it, stop retrying and read the resource. If a retry of a 500 comes back without that header, the first attempt failed before writing and the retry has run for real.

To read the resource after a failed create, look for it with a list filtered by a creation window or by your own reference, or read it by id if you already have one. Never create it again with a new key before you have looked.

> **A 500 on a write without a key:** Without an `Idempotency-Key`, nothing protects a retried write from creating a duplicate. Read before you retry.

## Retry decision table

One rule per status code is not enough: the same 409 can mean wait and retry or never retry, and a 500 can repeat for 24 hours. The decision table, with the reference retry helper, is in [Errors](https://docs.filmo.io/rest-api/guides/errors#retry). For the cases that depend on the key, in short:

- 409 [`conflict`](https://docs.filmo.io/rest-api/errors/conflict) with `in_progress` in `errors[0].code`: your key is still running. Wait and retry with the same key.
- 409 [`conflict`](https://docs.filmo.io/rest-api/errors/conflict) with `lock_timeout`: another conditional write held the resource for more than five seconds. Retry with backoff.
- 409 [`conflict`](https://docs.filmo.io/rest-api/errors/conflict) with `invalid_state`: the state of the resource forbids the operation. Do not retry; read the resource.
- 500 [`internal_error`](https://docs.filmo.io/rest-api/errors/internal_error) with `Idempotent-Replay: true`: stop and read the resource.
- 422 [`idempotency_key_reused`](https://docs.filmo.io/rest-api/errors/idempotency_key_reused): a client bug. Use a new key only for a genuinely different operation.

Reads need no key. Retry them freely, within the limits described in the rate limits guide.

## Over MCP

Write tools take an `idempotencyKey` argument with the same rules. If you omit it, nothing protects a retry, except on four tools where the server generates one and returns it in `meta.idempotencyKey`, on success and on error; resend that value when you retry. Even then it cannot protect a call whose response never reached you, so for `filmo_orders_line_items_return`, `filmo_purchase_orders_receive`, `filmo_treasury_payments_create` and `filmo_workflows_run` (the four that generate one) always send your own UUID, and send one on every other write tool too.

Where REST sends `Idempotent-Replay: true`, a tool result carries `meta.idempotentReplay: true` when it is the stored response of an earlier call with the same key. If a write fails with `internal_error` and the result is a replay, stop and read the resource, as with a stored 500 over REST.

---

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