*REST API › Guides*

# Concurrency and optimistic locking

> Every detail read returns an `ETag`. Send it back in the `If-Match` header of a write and Filmo rejects the write with 412 if the record changed since you read it, so two integrations cannot silently overwrite each other.

## ETag and If-Match

**Concurrency: ETag and If-Match**
- Your system → API: GET /api/v1/<resource>/{id}
- API → Your system: 200 with a strong ETag
- Your system → API: PATCH / PUT / DELETE / action with If-Match: <ETag> The comparison and the write run under a per-resource lock.
- API → Your system: 200: nothing changed since you read it
- If the resource changed in between
- API → Your system: 412 precondition_failed, before doing anything: read it again and retry
- API → Your system: 409 conflict (lock_timeout) if the write waited more than 5 s for the lock: retry
- If-Match: * only requires that the resource exists. Without the header the write always proceeds.

The `ETag` is a strong tag: a hash of the representation, so it changes whenever anything you can see in the record changes. The flow has four steps.

1. **Read the record** Call the detail read (for example [`GET /api/v1/contacts/{contactId}`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/get-a-contact)) and keep the `ETag` response header.
2. **Change it in your code** Work out the new values from what you read.
3. **Write with If-Match** Send the PATCH, PUT, DELETE or action (such as [`POST /api/v1/orders/{orderId}/reserve`](https://docs.filmo.io/rest-api/endpoint-reference/orders/reserve-an-order)) with `If-Match: <the ETag you read>`.
4. **Handle the answer** On success, an operation that returns the record gives you its new `ETag` in the response. On 412 [`precondition_failed`](https://docs.filmo.io/rest-api/errors/precondition_failed), nothing was written: read the record again, redo the change and retry.

The comparison and the write run under a per-resource lock, so two concurrent writes carrying the same tag end 200 and 412, exactly as if they had arrived one after the other. A write that waits more than five seconds for that lock is answered 409 [`conflict`](https://docs.filmo.io/rest-api/errors/conflict) with `lock_timeout` in `errors[0].code`, and can be retried.

- Without `If-Match` the write always proceeds: the last write wins.
- `If-Match: *` only requires that the resource exists. It does not protect against concurrent changes.
- Expansions you request on a read, such as the stage history of an opportunity, are not part of the tag. The tag of an expanded read still matches.

*Read, then write with If-Match*

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# CONTACT_ID: the id of a contact in your account

# 1. Read the contact and keep its ETag.
ETAG=$(curl -sS -D - -o contact.json "$BASE/api/v1/contacts/$CONTACT_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')

# 2. Write with If-Match. HTTP 412 means someone changed the contact since step 1.
curl -sS -w '\nHTTP %{http_code}\n' -X PATCH "$BASE/api/v1/contacts/$CONTACT_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "If-Match: $ETAG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"position":"Rental manager"}'
```

## Which operations accept If-Match

Writes on a resource that has a detail read accept it. The table lists every operation that declares the header.

| 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/kits` | `kits:write` |
| `PATCH /api/v1/kits/{kitId}` | `kits: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/incidents` | `incidents:write` |
| `PATCH /api/v1/incidents/{incidentId}` | `incidents:write` |
| `POST /api/v1/incidents/{incidentId}/comments` | `incidents: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/{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` |
| `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` |

Writes on resources that have no detail read yet do not accept `If-Match`: tags, notes, unavailabilities, collections, planner assignments and workflow runs. Neither does the line-item returns batch, because it spans several orders. On a create (a POST to a collection) the header is not a precondition: only `If-Match: *` passes, and any other value answers 412. For those, protect against duplicates with an idempotency key and read before you write.

## What each answer means

| Response | Meaning | What to do |
| --- | --- | --- |
| 412 `precondition_failed` | The `ETag` you sent no longer matches. Nothing was written. | Read the record again, redo the change on the new version and retry. |
| 409 `conflict` | `errors[0].code` says why. `lock_timeout`: the write waited more than five seconds for the per-resource lock. `in_progress`: a request with the same `Idempotency-Key` is still running. `invalid_state`: the state of the record forbids the operation. | Retry `lock_timeout` and `in_progress` with backoff. For `invalid_state`, read the record and check its state; waiting does not help. See the retry decisions in the Errors guide. |
| 404 `not_found` | The record does not exist or is outside your key's tenancy. | Check the id. |

## A read-modify-write loop

Lists in a PATCH body often replace the whole list. This loop adds a phone number to a contact with [`PATCH /api/v1/contacts/{contactId}`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/update-a-contact): it reads the contact, appends the number, writes with `If-Match`, and starts again on 412 or on a 409 `lock_timeout`. Set `CONTACT_ID` to a contact in your account.

**Node**

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

const base = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const auth = { Authorization: `Bearer ${process.env.FILMO_API_KEY}` };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Adds a phone number without overwriting a change someone else made in between.
export async function addPhone(contactId, number) {
  const url = base + "/api/v1/contacts/" + contactId;
  for (let attempt = 0; attempt < 5; attempt++) {
    const read = await fetch(url, { headers: auth });
    if (!read.ok) throw new Error("Read failed: HTTP " + read.status);
    const etag = read.headers.get("ETag");
    const { data } = await read.json();
    if (data.phoneNumbers.some((p) => p.number === number)) return data;

    // phoneNumbers replaces the whole list, so send the existing entries back too.
    const phoneNumbers = data.phoneNumbers.map((p) => ({
      number: p.number,
      isPrimary: p.isPrimary,
      ...(p.label ? { label: p.label } : {}),
      ...(p.countryCode ? { countryCode: p.countryCode } : {}),
    }));
    phoneNumbers.push({ number, isPrimary: false });

    const write = await fetch(url, {
      method: "PATCH",
      headers: {
        ...auth,
        "Content-Type": "application/json",
        "If-Match": etag,
        // A new key per attempt: each attempt sends a different body.
        "Idempotency-Key": randomUUID(),
      },
      body: JSON.stringify({ phoneNumbers }),
    });
    if (write.ok) return (await write.json()).data;

    const failure = await write.text();
    const cause = write.status === 409 ? JSON.parse(failure).errors?.[0]?.code : null;
    // 412: the contact changed since the read. 409 "lock_timeout": another write held the lock for 5 seconds.
    if (write.status === 412 || cause === "lock_timeout") {
      await sleep(100 * 2 ** attempt);
      continue;
    }
    throw new Error("Write failed: HTTP " + write.status + " " + failure);
  }
  throw new Error("Too many concurrent changes; try again later");
}

const contact = await addPhone(process.env.CONTACT_ID, "+34 600 000 000");
console.log(contact.phoneNumbers.length, "phone numbers");
```

**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 add_phone(contact_id, number):
    """Adds a phone number without overwriting a change made in between."""
    url = BASE + "/api/v1/contacts/" + contact_id
    for attempt in range(5):
        read = requests.get(url, headers=AUTH, timeout=30)
        read.raise_for_status()
        etag = read.headers["ETag"]
        data = read.json()["data"]
        if any(p["number"] == number for p in data["phoneNumbers"]):
            return data

        # phoneNumbers replaces the whole list, so send the existing entries back too.
        phone_numbers = []
        for p in data["phoneNumbers"]:
            entry = {"number": p["number"], "isPrimary": p["isPrimary"]}
            if p.get("label"):
                entry["label"] = p["label"]
            if p.get("countryCode"):
                entry["countryCode"] = p["countryCode"]
            phone_numbers.append(entry)
        phone_numbers.append({"number": number, "isPrimary": False})

        write = requests.patch(
            url,
            json={"phoneNumbers": phone_numbers},
            headers={
                **AUTH,
                "If-Match": etag,
                # A new key per attempt: each attempt sends a different body.
                "Idempotency-Key": str(uuid.uuid4()),
            },
            timeout=30,
        )
        if write.ok:
            return write.json()["data"]

        # 412: the contact changed since the read. 409 "lock_timeout": another write held the lock for 5 seconds.
        cause = None
        if write.status_code == 409:
            cause = ((write.json().get("errors") or [{}])[0]).get("code")
        if write.status_code == 412 or cause == "lock_timeout":
            time.sleep(0.1 * 2**attempt)
            continue
        raise RuntimeError(f"Write failed: HTTP {write.status_code} {write.text}")
    raise RuntimeError("Too many concurrent changes; try again later")


contact = add_phone(os.environ["CONTACT_ID"], "+34 600 000 000")
print(len(contact["phoneNumbers"]), "phone numbers")
```

## Conditional reads

Detail reads, and [`GET /api/v1/account`](https://docs.filmo.io/rest-api/endpoint-reference/account/get-the-account), also accept `If-None-Match`. Send the `ETag` of the copy you already hold: if it is still current, the answer is 304 with no body and the `ETag` header, and your copy is current. If the record changed, you get the full 200 with its new `ETag`.

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# CONTACT_ID: the id of a contact in your account
# ETAG: the ETag header of an earlier read of that contact, for example
ETAG=$(curl -sS -D - -o /dev/null "$BASE/api/v1/contacts/$CONTACT_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')

# Send the ETag you already have. HTTP 304 means your copy is current.
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' "$BASE/api/v1/contacts/$CONTACT_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "If-None-Match: $ETAG"
```

A 304 saves bandwidth, not quota: plan on it counting against your limit, like the other responses of an authenticated key (see [Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits)).

## Over MCP

Detail tools return the tag in `meta.etag`. Pass it as `ifMatch` on a write to that resource to get the same 412 behaviour. Omit it to write unconditionally.

---

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