*API REST › Guías*

# Idempotencia y reintentos seguros

> Envía una cabecera `Idempotency-Key` con cada escritura y reutiliza el mismo valor cuando reintentes esa operación: Filmo devuelve el resultado almacenado del primer intento en lugar de escribir dos veces.

Un timeout de red no te dice si la petición llegó. Reintentar una creación a ciegas puede crear el registro dos veces; no reintentar puede dejarlo sin crear. Tú eliges la clave, así que puedes reintentar tantas veces como necesites y obtener exactamente un efecto.

## Cómo funciona la clave

**Idempotencia: qué recibe cada reintento**
- Tu sistema → API: POST con Idempotency-Key: K Una clave por operación de negocio, en el primer intento y en cada reintento de esa misma operación.
- API → Tu sistema: Ejecuta y guarda la respuesta
- El reintento, dentro de 24 h
- Tu sistema → API: Misma clave K, mismo cuerpo
- API → Tu sistema: La respuesta guardada, Idempotent-Replay: true, sin ejecutar nada otra vez También cuando el primer intento falló después de confirmar la escritura: ese error se guarda y se repite.
- Tu sistema → API: Misma clave K, cuerpo distinto
- API → Tu sistema: 422 idempotency_key_reused, el manejador no se ejecuta
- Tu sistema → API: Misma clave K mientras el primer intento sigue en curso
- API → Tu sistema: 409 conflict (errors[0].code: in_progress): espera y reintenta
- Pasadas 24 h la clave caduca: es una petición nueva.

Pon un solo valor en la cabecera `Idempotency-Key` (hasta 255 caracteres; sirve un UUID) en el primer intento y en cada reintento de esa misma operación. Una clave identifica una operación, nunca un lote de ellas.

| Situación | Qué hace Filmo | Lo que haces |
| --- | --- | --- |
| Misma clave, mismo cuerpo, en 24 horas | Devuelve la respuesta guardada del primer intento sin ejecutar nada de nuevo, con la cabecera `Idempotent-Replay: true`. | Trátala como el resultado de la operación. Esta es la respuesta que buscabas. |
| Misma clave, cuerpo distinto | Responde 422 `idempotency_key_reused`. El handler nunca se ejecuta. El método, la ruta y el cuerpo en bruto forman parte de la huella de la primera petición, así que reordenar las claves JSON o enviar la misma clave a otra ruta también responde 422. | Corrige el cliente: reutilizar una clave para otra cosa casi siempre es un error. Usa una clave nueva solo para una operación realmente distinta. |
| Misma clave mientras el primer intento sigue en curso | Responde 409 `conflict`, con `in_progress` en `errors[0].code`. | Espera y reintenta con la misma clave. No envíes un segundo intento en paralelo. |
| El primer intento falló antes de escribir nada | Libera la clave. El reintento se ejecuta de nuevo. | Reintenta con la misma clave. |
| El primer intento falló después de confirmarse la escritura (no se pudo construir la respuesta) | Guarda ese error y lo reproduce como cualquier otra respuesta. El reintento no escribe dos veces. | Deja de reintentar y lee el recurso para ver en qué estado está. |
| Más de 24 horas después | La clave ha caducado y la petición es nueva. | Si necesitas una garantía de más de 24 horas, usa otra clave y comprueba antes por tu lado. |

## Qué operaciones lo aceptan

Solo las operaciones que escriben: 93 de ellas, que cubren toda creación, actualización, borrado y acción. Las lecturas ignoran la cabecera. También los POST de consulta por cuerpo, como [`POST /api/v1/availability/check`](https://docs.filmo.io/es/rest-api/endpoint-reference/availability/check-item-availability) y [`POST /api/v1/ai/ask`](https://docs.filmo.io/es/rest-api/endpoint-reference/ai/ask-the-ai-about-your-data): son POST porque la pregunta no cabe en una cadena de consulta, no porque cambien nada. Cada operación que acepta la clave declara la cabecera en su propio contrato, así que un cliente generado ya la conoce.

| 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/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` |

Una respuesta repetida no es una segunda ejecución, así que no emite eventos: un `Idempotency-Key` repetido no vuelve a iniciar los workflows de la cuenta. Aun así, la clave no convierte todas las operaciones en no-ops. No cuentes con que archivar de nuevo sea un no-op: archivar un pedido o un proyecto que ya está archivado, con una clave nueva, vuelve a mover su `updatedAt` y vuelve a disparar el evento de archivado, aunque la descripción de la operación prometa que no hay ningún efecto adicional. Comprueba el estado antes de archivar dos veces.

## Cuatro operaciones que necesitan tu propia clave

En la mayoría de las escrituras, reintentar sin clave es solo una molestia. En estas cuatro duplica algo que no se puede deshacer, así que envía siempre una clave que hayas generado y guardado, y reenvía la misma si reintentas.

| Operación | Qué duplica un reintento a ciegas |
| --- | --- |
| `POST /api/v1/orders/line-items/returns` | La devolución: el mismo material vuelve dos veces al conjunto. |
| `POST /api/v1/purchase-orders/{purchaseOrderId}/receive` | El stock: `newStockReceived` registra inventario nuevo cada vez. |
| `POST /api/v1/treasury/payments` | El pago: se registra dos veces. |
| `POST /api/v1/workflows/{workflowId}/runs` | La ejecución: se encola una segunda ejecución en lugar de devolver la primera. |

## Generar y guardar claves

- Genera un UUID por cada operación de negocio (por ejemplo, uno por cada registro que vayas a crear), no uno por cada intento HTTP.
- Guarda la clave junto a tu propio registro antes del primer intento. Si tu proceso se cae entre la petición y la respuesta, la siguiente ejecución debe reutilizar la misma clave.
- Reutiliza la clave en cada reintento de esa operación, con exactamente el mismo cuerpo. Un reintento con el cuerpo editado es un 422.
- No generes nunca la clave dentro del bucle de reintentos.
- Recuerda la ventana de 24 horas. Una cola que pueda reintentar pasado un día debe leer antes el recurso en lugar de fiarse de la clave.

## Crear un registro con reintentos seguros

Este ejemplo crea un contacto y reintenta ante fallos de red, 409, 429, 500 y 502, 503 o 504 (un fallo de pasarela sin documento de problema, tratado como un 500) con la misma clave, espera `Retry-After` cuando la respuesta lo lleva, y se detiene cuando vuelve un 500 marcado `Idempotent-Replay: true` o un 409 notifica `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)")
```

## El 500 que se reproduce durante 24 horas

Un 500 puede ocurrir antes de la escritura o después. Si ocurrió después de confirmar la escritura, Filmo guarda ese 500 y lo repite durante 24 horas para esa clave. Reintentar con espera exponencial devuelve entonces el mismo 500 cada vez, y el registro ya existe.

La señal es la cabecera `Idempotent-Replay: true` en un 500. Cuando la veas, deja de reintentar y lee el recurso. Si el reintento de un 500 vuelve sin esa cabecera, el primer intento falló antes de escribir y el reintento se ha ejecutado de verdad.

Para leer el recurso tras una creación fallida, búscalo con una lista filtrada por una ventana de creación o por tu propia referencia, o léelo por id si ya lo tienes. No lo vuelvas a crear nunca con una clave nueva antes de haber buscado.

> **Un 500 en una escritura sin clave:** Sin una `Idempotency-Key`, nada protege una escritura reintentada de crear un duplicado. Lee antes de reintentar.

## Tabla de decisión de reintentos

Una regla por código de estado no basta: el mismo 409 puede significar espera y reintenta o no reintentes nunca, y un 500 puede repetirse durante 24 horas. La tabla de decisión, con el ayudante de reintentos de referencia, está en [Errores](https://docs.filmo.io/es/rest-api/guides/errors#retry). Para los casos que dependen de la clave, en resumen:

- 409 [`conflict`](https://docs.filmo.io/es/rest-api/errors/conflict) con `in_progress` en `errors[0].code`: tu clave sigue en ejecución. Espera y reintenta con la misma clave.
- 409 [`conflict`](https://docs.filmo.io/es/rest-api/errors/conflict) con `lock_timeout`: otra escritura condicional retuvo el recurso durante más de cinco segundos. Reintenta con espera creciente.
- 409 [`conflict`](https://docs.filmo.io/es/rest-api/errors/conflict) con `invalid_state`: el estado del recurso prohíbe la operación. No reintentes; lee el recurso.
- 500 [`internal_error`](https://docs.filmo.io/es/rest-api/errors/internal_error) con `Idempotent-Replay: true`: detente y lee el recurso.
- 422 [`idempotency_key_reused`](https://docs.filmo.io/es/rest-api/errors/idempotency_key_reused): un error del cliente. Usa una clave nueva solo para una operación realmente distinta.

Las lecturas no necesitan clave. Reintenta libremente, dentro de los límites descritos en la guía de límites de peticiones.

## Por MCP

Las herramientas de escritura aceptan un argumento `idempotencyKey` con las mismas reglas. Si lo omites, nada protege un reintento, salvo en cuatro herramientas en las que el servidor genera una clave y la devuelve en `meta.idempotencyKey`, tanto en caso de éxito como de error; reenvía ese valor cuando reintentes. Aun así, no puede proteger una llamada cuya respuesta nunca te llegó, así que para `filmo_orders_line_items_return`, `filmo_purchase_orders_receive`, `filmo_treasury_payments_create` y `filmo_workflows_run` (las cuatro que generan una) envían siempre tu propio UUID, y envían uno en cualquier otra herramienta de escritura también.

Donde REST envía `Idempotent-Replay: true`, el resultado de una herramienta lleva `meta.idempotentReplay: true` cuando es la respuesta guardada de una llamada anterior con la misma clave. Si una escritura falla con `internal_error` y el resultado es una repetición, detente y lee el recurso, igual que con un 500 guardado por REST.

---

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