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.

Tu sistema
API
  1. GET /api/v1/<recurso>/{id}

  2. 200 con un ETag fuerte

  3. PATCH / PUT / DELETE / acción con If-Match: <ETag>

    La comparación y la escritura se ejecutan bajo un bloqueo por recurso.

  4. 200: nada cambió desde que lo leíste

  5. Si el recurso cambió entretanto

  6. 412 precondition_failed, antes de hacer nada: vuelve a leerlo y reintenta

  7. 409 conflict (lock_timeout) si la escritura esperó más de 5 s al bloqueo: reintenta

  8. If-Match: * solo exige que el recurso exista. Sin la cabecera, la escritura siempre se ejecuta.

Concurrencia: ETag e If-Match

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}) 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) 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, 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 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
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"}'

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

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

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.

RespuestaSignificadoQué hacer
412 precondition_failedEl 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 conflicterrors[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_foundEl registro no existe o queda fuera del aislamiento de tu clave.Comprueba el id.

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}: 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.

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");

Las lecturas de detalle, y GET /api/v1/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.

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

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.

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

  • 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

    Respuestas

    Las respuestas correctas envuelven su contenido en data; las listas añaden pagination; algunas operaciones añaden meta; los errores son problemas RFC 9457. Las respuestas de la API comparten un mismo conjunto de cabeceras.