*API REST › Guías*

# Concurrencia y bloqueo optimista

> Cada lectura de detalle devuelve un `ETag`. Envíalo de vuelta en la cabecera `If-Match` de una escritura y Filmo rechaza la escritura con 412 si el registro ha cambiado desde que lo leíste, de modo que dos integraciones no puedan sobrescribirse en silencio.

## ETag e If-Match

**Concurrencia: ETag e If-Match**
- Tu sistema → API: GET /api/v1/<recurso>/{id}
- API → Tu sistema: 200 con un ETag fuerte
- Tu sistema → API: PATCH / PUT / DELETE / acción con If-Match: <ETag> La comparación y la escritura se ejecutan bajo un bloqueo por recurso.
- API → Tu sistema: 200: nada cambió desde que lo leíste
- Si el recurso cambió entretanto
- API → Tu sistema: 412 precondition_failed, antes de hacer nada: vuelve a leerlo y reintenta
- API → Tu sistema: 409 conflict (lock_timeout) si la escritura esperó más de 5 s al bloqueo: reintenta
- If-Match: * solo exige que el recurso exista. Sin la cabecera, la escritura siempre se ejecuta.

El `ETag` es una etiqueta fuerte: un hash de la representación, así que cambia siempre que cambia algo que puedes ver en el registro. El flujo tiene cuatro pasos.

1. **Leer el registro** Llama a la lectura de detalle (por ejemplo [`GET /api/v1/contacts/{contactId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/get-a-contact)) y conserva la cabecera de respuesta `ETag`.
2. **Cámbialo en tu código** Calcula los nuevos valores a partir de lo que has leído.
3. **Escribir con If-Match** Envía el PATCH, PUT, DELETE o la acción (como [`POST /api/v1/orders/{orderId}/reserve`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/reserve-an-order)) con `If-Match: <the ETag you read>`.
4. **Gestionar la respuesta** Si tiene éxito, una operación que devuelve el registro te da su nuevo `ETag` en la respuesta. Con un 412 [`precondition_failed`](https://docs.filmo.io/es/rest-api/errors/precondition_failed), no se escribió nada: lee el registro de nuevo, rehaz el cambio y reintenta.

La comparación y la escritura se ejecutan bajo un bloqueo por recurso, así que dos escrituras concurrentes con la misma etiqueta terminan en 200 y 412, exactamente como si hubieran llegado una tras otra. Una escritura que espera más de cinco segundos a ese bloqueo recibe 409 [`conflict`](https://docs.filmo.io/es/rest-api/errors/conflict) con `lock_timeout` en `errors[0].code`, y se puede reintentar.

- Sin `If-Match` la escritura siempre se realiza: gana la última escritura.
- `If-Match: *` solo exige que el recurso exista. No protege frente a cambios concurrentes.
- Las expansiones que pides en una lectura, como el historial de etapas de una oportunidad, no forman parte de la etiqueta. La etiqueta de una lectura expandida sigue coincidiendo.

*Lee y luego escribe con 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"}'
```

## Qué operaciones aceptan If-Match

Lo aceptan las escrituras sobre un recurso que tiene lectura de detalle. La tabla enumera todas las operaciones que declaran la cabecera.

| Operación | Permiso |
| --- | --- |
| `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` |

Las escrituras sobre recursos que aún no tienen lectura de detalle no aceptan `If-Match`: etiquetas, notas, indisponibilidades, colecciones, asignaciones del planificador y ejecuciones de workflows. Tampoco lo acepta el lote de devoluciones por línea, porque abarca varios pedidos. En una creación (un POST a una colección) la cabecera no es una precondición: solo `If-Match: *` pasa, y cualquier otro valor responde 412. En esos casos, protégete de los duplicados con una clave de idempotencia y lee antes de escribir.

## Qué significa cada respuesta

| Respuesta | Significado | Qué hacer |
| --- | --- | --- |
| 412 `precondition_failed` | El `ETag` que enviaste ya no coincide. No se escribió nada. | Lee el registro de nuevo, rehaz el cambio sobre la versión nueva y reintenta. |
| 409 `conflict` | `errors[0].code` dice por qué. `lock_timeout`: la escritura esperó más de cinco segundos al bloqueo por recurso. `in_progress`: una petición con el mismo `Idempotency-Key` sigue en ejecución. `invalid_state`: el estado del registro prohíbe la operación. | Reintenta `lock_timeout` e `in_progress` con espera. Para `invalid_state`, lee el registro y comprueba su estado; esperar no ayuda. Consulta las decisiones de reintento en la guía de errores. |
| 404 `not_found` | El registro no existe o queda fuera del aislamiento de tu clave. | Comprueba el id. |

## Un bucle de leer-modificar-escribir

Las listas en un cuerpo PATCH suelen reemplazar la lista entera. Este bucle añade un número de teléfono a un contacto con [`PATCH /api/v1/contacts/{contactId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/update-a-contact): lee el contacto, añade el número, escribe con `If-Match` y vuelve a empezar ante un 412 o ante un 409 `lock_timeout`. Pon en `CONTACT_ID` un contacto de tu cuenta.

**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")
```

## Lecturas condicionales

Las lecturas de detalle, y [`GET /api/v1/account`](https://docs.filmo.io/es/rest-api/endpoint-reference/account/get-the-account), también aceptan `If-None-Match`. Envía el `ETag` de la copia que ya tienes: si sigue vigente, la respuesta es 304 sin cuerpo y con la cabecera `ETag`, y tu copia está al día. Si el registro cambió, recibes el 200 completo con su nuevo `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"
```

Un 304 ahorra ancho de banda, no cuota: cuenta con que computa contra tu límite, como las demás respuestas de una clave autenticada (consulta [Límites de peticiones](https://docs.filmo.io/es/rest-api/guides/rate-limits)).

## Por MCP

Las herramientas de detalle devuelven la etiqueta en `meta.etag`. Pásala como `ifMatch` en una escritura sobre ese recurso para obtener el mismo comportamiento 412. Omítela para escribir sin condiciones.

---

URL canónica: https://docs.filmo.io/es/rest-api/guides/concurrency  
Última actualización: 2026-10-09