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.

Tu sistema
API
  1. 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.

  2. Ejecuta y guarda la respuesta

  3. El reintento, dentro de 24 h

  4. Misma clave K, mismo cuerpo

  5. 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.

  6. Misma clave K, cuerpo distinto

  7. 422 idempotency_key_reused, el manejador no se ejecuta

  8. Misma clave K mientras el primer intento sigue en curso

  9. 409 conflict (errors[0].code: in_progress): espera y reintenta

  10. Pasadas 24 h la clave caduca: es una petición nueva.

Idempotencia: qué recibe cada reintento

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ónQué hace FilmoLo que haces
Misma clave, mismo cuerpo, en 24 horasDevuelve 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 distintoResponde 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 cursoResponde 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 nadaLibera 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ésLa 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.

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 y POST /api/v1/ai/ask: 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ónPermiso
POST /api/v1/contactscontacts:write
PATCH /api/v1/contacts/{contactId}contacts:write
DELETE /api/v1/contacts/{contactId}contacts:write
PATCH /api/v1/contacts/{contactId}/preferencescontacts:write
POST /api/v1/contacts/{contactId}/addressescontacts: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-contactscontacts:write
PATCH /api/v1/contacts/{contactId}/related-contacts/{relatedContactId}contacts:write
DELETE /api/v1/contacts/{contactId}/related-contacts/{relatedContactId}contacts:write
POST /api/v1/itemsitems:write
PATCH /api/v1/items/{itemId}items:write
DELETE /api/v1/items/{itemId}items:write
PATCH /api/v1/items/{itemId}/physical-propertiesitems:write
PATCH /api/v1/items/{itemId}/pricingitems:write
POST /api/v1/ordersorders:write
PATCH /api/v1/orders/{orderId}orders:write
POST /api/v1/projectsprojects:write
PUT /api/v1/projects/{projectId}/archiveprojects: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}/amenitiesspaces:write
PATCH /api/v1/spaces/{spaceId}/pricingspaces:write
POST /api/v1/repair-ticketsrepair-tickets:write
PATCH /api/v1/repair-tickets/{ticketId}repair-tickets:write
POST /api/v1/vehiclesvehicles:write
PATCH /api/v1/vehicles/{vehicleId}vehicles:write
DELETE /api/v1/vehicles/{vehicleId}vehicles:write
POST /api/v1/staffstaff: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/tagstags:write
DELETE /api/v1/tags/{tagId}tags:write
POST /api/v1/tags/{tagId}/assignmentstags:write
DELETE /api/v1/tags/{tagId}/assignmentstags:write
POST /api/v1/kitskits:write
PATCH /api/v1/kits/{kitId}kits:write
POST /api/v1/collectionscollections:write
PATCH /api/v1/collections/{collectionId}collections:write
POST /api/v1/servicesservices:write
PATCH /api/v1/services/{serviceId}services:write
POST /api/v1/internal-servicesinternal-services:write
PATCH /api/v1/internal-services/{internalServiceId}internal-services:write
POST /api/v1/planner/assignmentsplanner:write
POST /api/v1/planner/assignments/removeplanner:write
POST /api/v1/availability/unavailabilitiesavailability:write
PATCH /api/v1/availability/unavailabilities/{unavailabilityId}availability:write
DELETE /api/v1/availability/unavailabilities/{unavailabilityId}availability:write
POST /api/v1/incidentsincidents:write
PATCH /api/v1/incidents/{incidentId}incidents:write
POST /api/v1/incidents/{incidentId}/commentsincidents:write
POST /api/v1/notesnotes:write
POST /api/v1/taskstasks:write
PATCH /api/v1/tasks/{taskId}tasks:write
DELETE /api/v1/tasks/{taskId}tasks:write
POST /api/v1/crm/opportunitiescrm:write
PATCH /api/v1/crm/opportunities/{opportunityId}crm:write
PATCH /api/v1/crm/opportunities/{opportunityId}/stagecrm:write
POST /api/v1/crm/opportunities/{opportunityId}/closecrm:write
POST /api/v1/crm/opportunities/{opportunityId}/linkscrm:write
POST /api/v1/purchase-orders/{purchaseOrderId}/approvepurchase-orders:write
POST /api/v1/purchase-orders/{purchaseOrderId}/receivepurchase-orders:write
POST /api/v1/treasury/paymentstreasury:write
PATCH /api/v1/treasury/payments/{paymentId}treasury:write
POST /api/v1/treasury/payments/{paymentId}/linkstreasury:write
POST /api/v1/orders/{orderId}/cancelorders:write
PUT /api/v1/orders/{orderId}/archiveorders:write
PUT /api/v1/items/{itemId}/accessoriesitems:write
PUT /api/v1/items/{itemId}/alternativesitems:write
PATCH /api/v1/items/{itemId}/variants/{variantId}items:write
POST /api/v1/orders/line-items/returnsorders:write
POST /api/v1/workflows/{workflowId}/runsworkflows:run
POST /api/v1/orders/{orderId}/reserveorders:write
POST /api/v1/orders/{orderId}/unreserveorders:write
POST /api/v1/projects/{projectId}/orders/{orderId}projects:write
DELETE /api/v1/projects/{projectId}/orders/{orderId}projects:write
POST /api/v1/invoices/{invoiceId}/validateinvoices:write
POST /api/v1/invoices/{invoiceId}/voidinvoices:write
PUT /api/v1/invoices/external/{originSystem}/{originExternalId}invoices:write
POST /api/v1/credit-notes/{creditNoteId}/validateinvoices:write
POST /api/v1/credit-notes/{creditNoteId}/voidinvoices:write
PATCH /api/v1/proformas/{proformaId}/statusinvoices:write
PATCH /api/v1/quotes/{quoteId}/statusquotes:write
POST /api/v1/contracts/{contractId}/validatecontracts:write
POST /api/v1/contracts/{contractId}/signcontracts:write
POST /api/v1/contracts/{contractId}/voidcontracts:write
POST /api/v1/contracts/{contractId}/cancelcontracts:write
POST /api/v1/supplier-invoices/{supplierInvoiceId}/validatesupplier-invoices:write
POST /api/v1/supplier-invoices/{supplierInvoiceId}/voidsupplier-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

Enlace a esta sección: 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ónQué duplica un reintento a ciegas
POST /api/v1/orders/line-items/returnsLa devolución: el mismo material vuelve dos veces al conjunto.
POST /api/v1/purchase-orders/{purchaseOrderId}/receiveEl stock: newStockReceived registra inventario nuevo cada vez.
POST /api/v1/treasury/paymentsEl pago: se registra dos veces.
POST /api/v1/workflows/{workflowId}/runsLa ejecución: se encola una segunda ejecución en lugar de devolver la primera.
  • 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.

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.

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

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.

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. Para los casos que dependen de la clave, en resumen:

  • 409 conflict con in_progress en errors[0].code: tu clave sigue en ejecución. Espera y reintenta con la misma clave.
  • 409 conflict con lock_timeout: otra escritura condicional retuvo el recurso durante más de cinco segundos. Reintenta con espera creciente.
  • 409 conflict con invalid_state: el estado del recurso prohíbe la operación. No reintentes; lee el recurso.
  • 500 internal_error con Idempotent-Replay: true: detente y lee el recurso.
  • 422 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.

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.

  • Guías

    Errores

    Los errores son documentos de problema RFC 9457. Ramifica según el code estable, nunca según el texto, y usa la tabla de reintentos para decidir si volver a intentarlo.

  • Guías

    Límites de peticiones

    Cada clave de API tiene 120 peticiones por minuto, y las tres comprobaciones de disponibilidad comparten su propia cuota de 30 por minuto. Al pasar un límite, la API responde 429 con Retry-After.

  • 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.

  • Guías

    Lista de comprobación de salida a producción

    Repasa estas ocho comprobaciones antes de que tu integración funcione con datos de producción. Cada una remite a la guía que la explica.