Guías

Sincronizar datos

Filmo v1 no envía webhooks, así que mantienes otro sistema sincronizado consultando periódicamente: lee lo que cambió en ventanas de tiempo semiabiertas y ejecuta un barrido aparte para encontrar lo que se eliminó. No sigas el cursor de paginación para decidir qué cambió o qué desapareció.

Esta guía da el algoritmo, los parámetros que necesita cada lista y código completo en Node, Python y cURL. Da por hecho que sabes cómo paginan las listas (Paginación) y cómo se limitan las peticiones (Límites de peticiones).

NecesitasUso
Los registros que han cambiado desde tu última ejecuciónEn las listas que los tienen, updatedFrom (desde esa fecha, incluida) y updatedBefore (antes de esa fecha), con sort=updatedAt y limit=500.
Lo mismo, en listas sin filtros updated*sort=-updatedAt, sigue el cursor y para en tu marca de agua menos un solapamiento.
Para detectar un borradoUn barrido de reconciliación sobre ventanas de createdAt, con un GET de detalle para confirmar cada id que falta.
La hora del lado de FilmoLa cabecera Date de cualquier respuesta. Nunca tu propio reloj.
Para que te avisen de que algo ha cambiadoUn workflow con un trigger de evento y un paso de petición HTTP que llama a tu endpoint. Es una pista, no los datos: conserva la pasada de sondeo. Consulta Push, más abajo.

El cursor codifica un desplazamiento interno. Eso basta para una pantalla que muestra una lista una vez, y no basta para una sincronización que se ejecuta mientras la gente sigue trabajando en Filmo.

Imagina que lees registros en orden ascendente de updatedAt, en bloques de 500, y el registro 17 se actualiza mientras lees. Pasa al final del orden, todos los registros posteriores suben una posición y el registro que estaba en la posición 501 queda ahora en la 500: un desplazamiento que ya consumiste. Nunca lo recibes. Su updatedAt queda entre valores que ya leíste, por debajo de la marca de agua que vas a guardar, así que ningún solape de segundos o minutos lo recupera. Se queda perdido hasta que vuelva a cambiar. Borrar un registro que ya leíste produce el mismo salto.

Un cursor también es estricto con su origen. Está ligado al limit y al sort de la consulta que lo produjo, su desplazamiento debe ser múltiplo de limit y tiene como máximo 200 caracteres. No construyas ni edites uno nunca, y no cambies limit ni sort mientras lo sigues.

El algoritmo de abajo evita el cursor: cada consulta es una página de una ventana de tiempo, así que no se arrastra ningún desplazamiento de una consulta a la siguiente.

Tu sistema
API
  1. Primera carga: los parámetros de pasada completa de cada lista (overlay.syncDefaults), para no perder lo que se oculta por defecto.

  2. Pasada incremental, listas con filtros updated*

  3. GET ...?updatedFrom=A&updatedBefore=B&sort=updatedAt&limit=500

    Una ventana semiabierta [A, B). B es la hora del servidor (la cabecera Date de la última respuesta) menos un margen de seguridad, nunca el reloj del cliente. Una sola página, nunca el cursor.

  4. pagination.hasMore: true → parte la ventana en dos y repite

  5. Leídas todas las subventanas, la marca de agua pasa a ser B. Si la pasada se interrumpe, no se mueve. Deduplica por id.

  6. Listas sin filtros updated* pero ordenables por updatedAt

  7. sort=-updatedAt, sigue el cursor

    Para al llegar a la marca de agua menos un solapamiento; deduplica por id.

  8. Barrido de conciliación (borrados y actualizaciones saltadas)

  9. Lee todos los ids en ventanas de createdAt (un cursor con dos barridos donde la lista no tiene filtros de fecha)

    Compara cada updatedAt con el que guardaste y vuelve a leer los que difieren.

  10. GET del detalle de cada id que falta en el barrido

    Solo un 404 cuenta como borrado. Donde no hay lectura de detalle, el id tiene que faltar en dos barridos seguidos.

Mantener una copia sincronizada

Esto funciona en todas las listas que tienen updatedFrom y updatedBefore. Las ventanas son semiabiertas, [A, B): A es inclusivo y B es exclusivo, así que las ventanas consecutivas ni se solapan ni dejan un hueco.

  1. Solución B a partir del reloj del servidor

    B es la hora de los servidores de Filmo menos un margen de seguridad. Lee la hora de la cabecera Date de la última respuesta, nunca de tu propio reloj: un reloj adelantado empujaría B más allá de registros que todavía no son visibles. Usa un margen de 2 minutos más 1 segundo (la precisión de Date). El aviso de abajo explica por qué ni siquiera ese margen es una garantía.

  2. Pide la ventana [A, B)

    updatedFrom=A, updatedBefore=B, sort=updatedAt y limit=500. Lee solo una página. A es tu marca de agua; en la primera ejecución es la fecha más temprana que te interesa.

  3. Dividir cuando la página está llena

    Si pagination.hasMore es true, divide la ventana en dos mitades y repite el paso 2 en cada una. No sigas nunca pagination.nextCursor.

  4. Avanza la marca de agua

    Cuando se han leído todas las subventanas de [A, B), la marca de agua pasa a ser B. Si la pasada se interrumpe, la marca de agua no se mueve y la siguiente ejecución vuelve a leer la misma ventana.

  5. Deduplicar por id

    Un registro actualizado durante la pasada puede aparecer en dos subventanas. Eso es un duplicado, no un hueco: quédate con la copia que tenga el updatedAt más reciente y haz upsert por id en tu base de datos.

Por qué no pierde registros: cada consulta es una sola página sin desplazamiento. Un registro que cambia mientras lees sale de su ventana hacia un updatedAt igual o posterior a B, y la siguiente pasada lo recoge.

updatedAt no es una marca de agua segura por sí solo

Filmo marca updatedAt con el reloj de la aplicación, antes de que la transacción haga commit, y algunas escrituras lo marcan un segundo en el pasado (registrar un pago, validar una factura, cambiar el estado de un presupuesto). Una transacción puede tardar segundos en hacer commit y el trabajo posterior al commit no tiene límite superior, así que un cambio puede hacerse visible con un updatedAt por debajo de una marca de agua que ya guardaste. El margen de 2 minutos más 1 segundo lo hace poco frecuente, y Filmo no garantiza un límite. Mantén el margen configurable y ejecuta periódicamente el barrido de reconciliación de más abajo: es lo que detecta el registro que una pasada se saltó.

Las marcas de tiempo tienen precisión de milisegundos, tanto en los filtros como en las respuestas. Los cuatro límites de un filtro de fechas se comportan así.

ParámetroComparaciónUna fecha sin hora (YYYY-MM-DD) significa
updatedFrom>= (en o después)00:00:00.000 de ese día
updatedAfter> (estrictamente después)23:59:59.999 de ese día, así que el día entero queda excluido
updatedBefore< (estrictamente antes)00:00:00.000 de ese día
updatedTo<= (en o antes)23:59:59.999 de ese día

Una fecha sin hora se lee en la zona horaria de la cuenta. Una sincronización no debería usar fechas sin hora: pasa un instante ISO 8601 con desplazamiento, como los instantes con Z de los ejemplos. Los filtros created* siguen las mismas reglas.

Cuando más de 500 registros comparten un mismo instante

Enlace a esta sección: Cuando más de 500 registros comparten un mismo instante

Una importación masiva puede dar a más de 500 registros el mismo updatedAt. Dividir a la mitad no puede separarlos. Cuando una ventana de 1 milisegundo sigue informando pagination.hasMore, sigue el cursor solo dentro de esa ventana y vuelve a leerla hasta que dos lecturas consecutivas devuelvan los mismos ids con el mismo updatedAt. El código de abajo lo hace y se rinde tras cinco intentos sin avanzar la marca de agua.

BASE="${FILMO_BASE_URL:-https://app.filmo.io}"

# Server time: the Date header of any response (here, a one-row read of the same list).
curl -sS -D headers.txt -o /dev/null -G "$BASE/api/v1/orders" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "limit=1"
SERVER_DATE=$(grep -i '^date:' headers.txt | cut -d' ' -f2- | tr -d '\r')

# B = server time minus 2 minutes and 1 second (GNU date; use gdate on macOS).
B=$(date -u -d "$SERVER_DATE - 121 seconds" +%Y-%m-%dT%H:%M:%S.000Z)
A=2026-10-01T00:00:00.000Z   # your stored watermark

# The window [A, B): one page, sorted by updatedAt.
curl -sS -G "$BASE/api/v1/orders" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "updatedFrom=$A" \
  --data-urlencode "updatedBefore=$B" \
  --data-urlencode "sort=updatedAt" \
  --data-urlencode "limit=500" \
  | jq '{records: (.data | length), hasMore: .pagination.hasMore}'

# If hasMore is true, run the same request again on [A, M) and [M, B),
# where M is the midpoint. Never pass the cursor. Store B only when
# every sub-window has been read.

El almacén de marcas de agua es una interfaz con dos métodos, load y save. Los ejemplos lo guardan en un archivo JSON que se escribe mediante un archivo temporal y un renombrado; en producción usa una fila de base de datos, en la misma transacción que tus upserts si puedes. apply recibe todos los registros que encontró la pasada y debe hacer upsert por id, porque tras un fallo se vuelve a leer la misma ventana.

La primera ejecución y el presupuesto de peticiones

Enlace a esta sección: La primera ejecución y el presupuesto de peticiones

La primera ejecución es la misma pasada con una A lejana, fijada con FILMO_SYNC_FROM en los ejemplos. Calcula aproximadamente una petición por cada 250 a 500 registros, más las peticiones que descubren ventanas vacías. Es una estimación, no un contrato. Una clave está limitada a 120 peticiones por minuto (Límites de peticiones), y el código espera a que pase un 429 usando Retry-After. Planifica la carga inicial según ese presupuesto.

Algunas listas no devuelven todo a menos que lo pidas. Una sincronización que omite el parámetro nunca ve los registros ocultos: una oportunidad ganada o perdida desaparece de la lista y el otro sistema nunca se entera de que se cerró. Usa estos parámetros en cada pasada de sincronización, barrido y ejemplo.

ListaOculto por defectoParámetro para una pasada completa
GET /api/v1/crm/opportunitiesOportunidades ganadas y perdidasstatus establecido en all
GET /api/v1/orders/{orderId}/linesLíneas añadidas durante la entregaincludeDelivery establecido en true
GET /api/v1/treasury/accountsCuentas archivadasincludeArchived establecido en true (se ignora cuando pasas status)
GET /api/v1/crm/pipelinesEmbudos archivados (la lista no está paginada)includeArchived establecido en true
GET /api/v1/task-typesTipos de tarea archivados (la lista no está paginada)includeArchived establecido en true
GET /api/v1/projectsProyectos archivadosarchived establecido en true devuelve solo los archivados, así que una pasada completa son dos pasadas: una sin el parámetro y otra con él

Los pedidos son distintos: un pedido archivado no se oculta con un parámetro, es invisible para la API por completo. Consulta Archivado no es borrado, más abajo.

La tabla siguiente se genera a partir de la especificación. Para cada lista muestra qué filtros created* y updated* existen, si ordena por updatedAt, qué registros oculta por defecto, qué estrategia incremental y qué barrido de borrados se aplican, y si hay un GET de detalle.

OperaciónFiltros de creaciónFiltros de actualizaciónOrdena por updatedAtOculta por defectoEstrategia incrementalBarrido de borradosGET de detalle
GET /api/v1/contactsSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/itemsSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/ordersSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/orders/{orderId}/linesNoNoNoincludeDelivery=trueBarrido completoCursor, dos barridos seguidosNo
GET /api/v1/invoicesSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/quotesSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/projectsSíSíSíarchived=false · archived=trueVentanas de updatedAtVentanas de createdAtSí
GET /api/v1/invoices/{invoiceId}/paymentsNoNoSí—Descendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosNo
GET /api/v1/invoices/{invoiceId}/linesNoNoNo—Barrido completoCursor, dos barridos seguidosNo
GET /api/v1/quotes/{quoteId}/linesNoNoNo—Barrido completoCursor, dos barridos seguidosNo
GET /api/v1/serialized-itemsSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/spacesSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/repair-ticketsSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/vehiclesSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/staffSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/subrentsSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/purchase-ordersSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/contractsSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/kitsNoNoSí—Descendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosSí
GET /api/v1/collectionsSíSíSí—Ventanas de updatedAtVentanas de createdAtNo
GET /api/v1/servicesNoNoSí—Descendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosSí
GET /api/v1/internal-servicesNoNoSí—Descendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosSí
GET /api/v1/availability/unavailabilitiesNoNoNo—Barrido completoCursor, dos barridos seguidosNo
GET /api/v1/incidentsSíNoSí—Descendente por updatedAt, parada en la marca de aguaVentanas de createdAtSí
GET /api/v1/notesSíSíSí—Ventanas de updatedAtVentanas de createdAtNo
GET /api/v1/tasksSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/crm/opportunitiesNoNoSístatus=allDescendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosSí
GET /api/v1/supplier-invoicesSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/treasury/accountsNoNoSíincludeArchived=trueDescendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosSí
GET /api/v1/treasury/paymentsNoNoSí—Descendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosSí
GET /api/v1/credit-notesSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/proformasSíSíSí—Ventanas de updatedAtVentanas de createdAtSí
GET /api/v1/workflowsSíSíSí—Ventanas de updatedAtVentanas de createdAtNo
GET /api/v1/payment-methodsNoNoSí—Descendente por updatedAt, parada en la marca de aguaCursor, dos barridos seguidosNo
GET /api/v1/usersSíSíSí—Ventanas de updatedAtVentanas de createdAtNo

Algunas listas no se pueden acotar por ventana de fecha de actualización, pero sí ordenar por ella. Se ordenan por updatedAt y no se filtran por ese campo. En ellas, lee en orden descendente, sigue el cursor y para en el primer registro más antiguo que tu marca de agua menos un solape (una hora en los ejemplos). Deduplica por id.

ListaFiltros de fechaOrdena por updatedAt
GET /api/v1/kitsNingunoSí, solo un campo
GET /api/v1/servicesNingunoSí, solo un campo
GET /api/v1/internal-servicesNingunoSí
GET /api/v1/crm/opportunitiesNingunoSí
GET /api/v1/treasury/paymentsNingunoSí, solo un campo
GET /api/v1/payment-methodsNingunoSí
GET /api/v1/treasury/accountsNingunoSí, solo un campo
GET /api/v1/invoices/{invoiceId}/paymentsNingunoSí, solo un campo
GET /api/v1/incidentsSolo created*Sí, solo un campo

El orden descendente es seguro donde el ascendente no lo es. Un registro actualizado durante la pasada salta al principio y empuja a los demás hacia atrás, lo que produce un duplicado en el límite de página, no un hueco. Una eliminación concurrente aún puede hacer que el recorrido se salte un registro, y lo que pierdes entonces es una actualización. El barrido de conciliación lo recupera comparando updatedAt, lo que cuesta mucho menos que una relectura completa.

Dos detalles de estas listas. Las oportunidades se identifican por opportunityId y no tienen campo id, y su cursor es específico del servicio de CRM: trátalo como opaco igual que cualquier otro. updatedAt y updatedAt pueden ser null, y un null nunca debe detener el recorrido.

BASE="${FILMO_BASE_URL:-https://app.filmo.io}"

# First page, newest update first. status=all keeps won and lost opportunities in.
curl -sS -G "$BASE/api/v1/crm/opportunities" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "status=all" \
  --data-urlencode "sort=-updatedAt" \
  --data-urlencode "limit=500" \
  | jq '{newest: .data[0].updatedAt, hasMore: .pagination.hasMore, next: .pagination.nextCursor}'

# Read the next page with --data-urlencode "cursor=<next>" (same sort and limit)
# until you meet a record whose updatedAt is older than your watermark minus the overlap.

Listas que no se pueden leer de forma incremental

Enlace a esta sección: Listas que no se pueden leer de forma incremental

GET /api/v1/orders/{orderId}/lines, GET /api/v1/invoices/{invoiceId}/lines y GET /api/v1/quotes/{quoteId}/lines devuelven las líneas de un documento padre, y las líneas no llevan updatedAt ni createdAt, así que no se pueden acotar por ventanas. Vuelve a leer las líneas de un padre cada vez que leas el padre, y sustituye el conjunto que tienes para él por lo que leas.

  • Pide limit=500. Si pagination.hasMore es false, la página es una instantánea consistente de las líneas y no interviene ningún cursor. Si es true, sigue el cursor y vuelve a leer las líneas hasta que dos lecturas coincidan.
  • En los pedidos, pasa includeDelivery con valor true, para que se incluyan las líneas añadidas durante la entrega.
  • Un cambio en las líneas no siempre mueve el updatedAt del padre (consulta la sección siguiente), así que vuelve a leer las líneas de los padres que barres, no solo las de los que han cambiado.
  • Planifica el presupuesto: leer líneas cuesta al menos una petición por cada elemento padre. Para 10,000 pedidos son 10,000 peticiones, unos 84 minutos a 120 peticiones por minuto. Hazlo en la primera ejecución y reparte las relecturas posteriores entre los barridos.

No des por hecho que cualquier cambio en un hijo mueve el updatedAt de su padre. Esto es lo que lo mueve hoy.

Cambio¿Se mueve el updatedAt del padre?
Líneas de pedido que cambiaron por sí solasNo.
Un guardado completo del pedido (PATCH con fechas)Sí, en varios pasos. Las líneas se escriben después de guardar el pedido, así que pueden cambiar después del updatedAt del pedido.
Reservar, cancelar, anular la reserva; PATCH sin fechasYes.
Devolución masiva de líneas de pedidoSolo cuando todas las líneas activas terminan en un estado final.
Un pago, una validación de factura o un cambio de estado de presupuesto, en el pedidoSí, con un updatedAt un segundo en el pasado.
Líneas de factura, pagos, validación o anulación, en la facturaSí. Las líneas se guardan dentro de la factura.
Un cambio de estado de presupuestoSí, en el presupuesto y en su versión activa.
Edición de un borrador de presupuestoNo: solo cambia la versión, no el presupuesto.
Vincular o desvincular un pedido y un proyectoSí. Cuando creas un pedido con un projectId, el vínculo se hace fuera de la transacción de creación, y si falla el proyecto no se mueve.
Direcciones y contactos relacionados de un contactoYes.
Variantes, propiedades físicas, precios, accesorios y alternativas de un artículoYes.
Asignar o quitar una etiquetaSolo para algunos tipos de entidad. Mueve artículos, espacios, pedidos, proyectos, facturas, notas de crédito, proformas, personal, vehículos y servicios, y no mueve contactos, unidades serializadas, órdenes de compra, incidencias, tickets de reparación, oportunidades ni contratos.

Así, la pasada incremental solo encuentra un padre modificado cuando su updatedAt se ha movido. Vuelve a leer las listas hijas (líneas, pagos) de los padres que barras y, para los tipos de entidad en los que una etiqueta no mueve al padre, vuelve a leer las etiquetas en la reconciliación.

Los filtros updated* de la lista de presupuestos tienen en cuenta la más reciente entre la actualización del presupuesto y la de su versión. El updatedAt que muestra el presupuesto, y el que usa sort=updatedAt, es solo el del presupuesto. Por tanto, un presupuesto cuyo borrador se ha editado puede devolverse en una ventana con un updatedAt anterior al inicio de esa ventana. No filtres nunca presupuestos en el cliente solo por updatedAt, y no compares updatedAt para decidir si un presupuesto cambió: cuando el filtro devuelva un presupuesto, lee su detalle y sus líneas.

Archivar un pedido lo saca de la API. Tras PUT /api/v1/orders/{orderId}/archive (que responde 204), el GET del pedido responde 404, igual que con un pedido eliminado, el pedido desaparece de GET /api/v1/orders y no se puede modificar. No hay ningún parámetro que incluya los pedidos archivados. Restaurarlo con PUT /api/v1/orders/{orderId}/archive y archived: false (o en la app) lo devuelve. Solo se puede archivar un pedido en borrador o cancelado. El status de un pedido enumera archived entre sus valores, pero Filmo nunca lo produce y tampoco puedes observarlo. Consulta también Archivar y eliminar.

  • Ante un 404 de un pedido que tienes, márcalo como "archivado o eliminado" en tu sistema. No lo borres todavía de tu lado y vuelve a comprobarlo en el siguiente barrido. Un pedido archivado desaparece de la pasada incremental sin dejar rastro.
  • Archivar y restaurar mueven ambos updatedAt. Un pedido restaurado reaparece en las listas con la hora de la restauración, así que la pasada incremental lo devuelve. El barrido también: compara ids sobre ventanas de createdAt.
  • No cuentes con que un archivado repetido no haga nada: archivar de nuevo mueve updatedAt y vuelve a disparar el evento.
  • Los proyectos se comportan de otra forma. Un proyecto archivado sigue respondiendo a su GET (con archived: true), archivar y restaurar mueven su updatedAt, y la lista muestra los proyectos archivados solo con archived puesto a true.

Un DELETE es físico y definitivo: Filmo no guarda ninguna marca de borrado y, después, el GET del registro responde 404. No existe el filtro deletedSince ni includeDeleted ni un feed de registros eliminados, así que las eliminaciones se detectan comparando lo que tienes con lo que devuelve Filmo. Eliminar un contacto también elimina sus tareas, notas y archivos.

Los pedidos y los proyectos no tienen DELETE en la v1. Un pedido se cancela o se archiva, y un proyecto se archiva o se le desvinculan sus pedidos. Existen eventos de workflow para el borrado de contactos, tareas, artículos, incidencias y oportunidades, y ninguno para espacios, vehículos, personal ni etiquetas. Son de mejor esfuerzo, así que úsalos como pista y mantén el barrido.

No decidas nunca un borrado a partir del cursor

Un registro que el cursor omite se ve exactamente igual que uno eliminado. Si eliminas en tu lado todo lo que una lectura con cursor no devolvió, acabarás eliminando un pedido, una factura o un contacto vivos en tu ERP. Solo un 404 del GET de detalle, o el doble barrido que se describe más abajo, es prueba.

  1. Lee con ventanas, no con el cursor

    En las listas que tienen createdFrom y createdBefore (semiabiertos, como los filtros updated*), aplica sobre createdAt el mismo método de ventanas que en la pasada incremental: sort=createdAt, una página por consulta, dividida en mitades mientras pagination.hasMore sea true.

  2. Confirmar cada id ausente con su GET de detalle

    Para cada id que tengas en la ventana y que el barrido no devolvió, llama al GET de detalle. Solo cuenta un 404. En los pedidos, un 404 significa archivado o eliminado. Un 200 significa que el barrido se saltó un registro vivo: haz upsert.

  3. Sin GET de detalle, exige dos barridos

    Cuando una lista no tiene GET de detalle, un id debe faltar en dos barridos consecutivos antes de que lo trates como eliminado.

  4. Recuperar actualizaciones omitidas

    Compara el updatedAt de cada id que devuelve el barrido con el que almacenaste, y vuelve a leer los registros que difieran. Así se recuperan las actualizaciones que la parada anticipada pueda haberse saltado.

  5. Usa los parámetros de un recorrido completo

    Pasa los parámetros de la tabla de registros ocultos, para que un registro oculto por defecto no parezca borrado.

Mantén el final de un barrido por detrás del mismo margen de seguridad que la pasada incremental. Un barrido de todo el historial es el trabajo más caro de esta guía, así que divídelo: barre unas pocas semanas de createdAt por ejecución, las ventanas recientes más a menudo que las antiguas.

En las listas sin filtros created* ni updated*, el cursor es la única forma de enumerar, así que lo que devuelve es una lista de candidatos, nunca un veredicto.

ListaGET de detalleRegla para borrar por tu lado
GET /api/v1/kits, GET /api/v1/services, GET /api/v1/internal-services, GET /api/v1/crm/opportunities, GET /api/v1/treasury/payments, GET /api/v1/treasury/accountsSíLee todos los ids por cursor y luego confirma cada uno que falte con su GET de detalle. Solo cuenta un 404.
GET /api/v1/payment-methodsNoEl id debe faltar en dos barridos consecutivos.
GET /api/v1/invoices/{invoiceId}/paymentsNoLee los pagos de cada factura en una sola página (limit=500) y sustituye el conjunto que tienes.

Listas con filtros de creación y sin GET de detalle

Enlace a esta sección: Listas con filtros de creación y sin GET de detalle

GET /api/v1/users, GET /api/v1/collections y GET /api/v1/workflows filtran por created* y updated*, pero no tienen un GET de detalle para confirmar una eliminación. Haz un barrido por ventanas sobre createdAt, y da un id por desaparecido solo cuando falte en dos barridos consecutivos. GET /api/v1/notes no se puede barrer así: se lee por entidad, con un tipo de documento relacionado y un id obligatorios, y no hay una lista de notas de toda la cuenta.

BASE="${FILMO_BASE_URL:-https://app.filmo.io}"

# The ids Filmo reports for one createdAt window [FROM, TO). Same halving as the
# incremental pass when hasMore is true; shown here for a window that fits one page.
curl -sS -G "$BASE/api/v1/orders" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "createdFrom=2026-09-01T00:00:00.000Z" \
  --data-urlencode "createdBefore=2026-10-01T00:00:00.000Z" \
  --data-urlencode "sort=createdAt" \
  --data-urlencode "limit=500" \
  | jq -r '.data[] | [.id, .updatedAt] | @tsv' > remote.tsv

# For every id you hold in that window that is NOT in remote.tsv, ask the detail GET.
# 404 on an order means archived or deleted. 200 means the sweep skipped a live record.
curl -sS -o /dev/null -w "%{http_code}\n" "$BASE/api/v1/orders/$ORDER_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY"

Los scripts leen tu estado local de local-orders.json, un sustituto de tu base de datos: un objeto que asocia cada id con el createdAt y el updatedAt que guardaste. El barrido imprime los registros que hay que insertar o actualizar y los ids cuyo GET de detalle respondió 404. En los pedidos, márcalos como archivados o eliminados y deja la decisión para el siguiente barrido.

Los contactos no tienen un campo para tu propio id. Lo más parecido es contactNumber, una referencia que puedes fijar al crear o actualizar un contacto (hasta 50 caracteres, y puede estar vacía). No es única: dos contactos pueden llevar el mismo número.

POST /api/v1/contacts/lookup toma entre 1 y 200 references y compara cada una solo con contactNumber, por igualdad exacta, sin recortar espacios y sin normalizar mayúsculas ni identificadores fiscales. Responde con contacts[] (id, nombre y contactNumber) y notFound[]. No empareja cada referencia con su contacto: hazlo tú por contactNumber. Cuando varios contactos comparten un número, los devuelve todos, sin aviso, en un orden no garantizado. Una referencia que repites y que no se encuentra aparece repetida en notFound.

  • Mantén por tu lado una tabla de correspondencias: tu id en una columna, el id de Filmo en la otra.
  • Usa POST /api/v1/contacts/lookup para decidir qué referencias crear (notFound). Cuando devuelva más de un contacto para una referencia, no elijas uno: trátalo como un conflicto que debe resolver una persona.
  • Busca la correspondencia por email con email cuando contactNumber no baste.
  • Otros registros están mejor situados. Los proyectos aceptan y devuelven un externalReference. Los pedidos también devuelven un externalReference, pero la API no te deja escribirlo. Una factura emitida en otro sistema se refleja con PUT /api/v1/invoices/external/{originSystem}/{originExternalId}, con clave originSystem y originExternalId.
  • Un Idempotency-Key deduplica solo durante 24 horas (Idempotencia). Pasado ese plazo, una creación reintentada genera un segundo contacto: comprueba antes tu tabla de correspondencias.

Ningún objeto indica quién o qué hizo un cambio. Lo que escribe una clave se atribuye al administrador que la posee. El registro de actividad de Filmo anota que el cambio vino de la API, pero la API no lo expone. En una sincronización bidireccional tu propia escritura vuelve como un cambio en la pasada siguiente, y los dos sistemas se rebotan la actualización.

  • Por cada registro que escribas, guarda un hash del último estado que escribiste: los campos que sincronizas, en un orden fijo.
  • Cuando una pasada devuelve un registro, calcula el hash de los mismos campos. Si el hash coincide con el guardado, ignora el cambio.
  • Antes de escribir, compara con lo que Filmo ya tiene y omite las escrituras que no cambian nada.
  • Envía If-Match con el ETag de tu última lectura, de modo que se rechace una escritura sobre un registro que cambió entretanto (Concurrencia).

Una escritura a través de la API sigue la misma ruta que un cambio hecho en la app, así que dispara los workflows de eventos de la cuenta. Esos pueden enviar emails o llamar a terceros. No puedes suprimirlo para una sola petición: no hay cabecera ni indicador para ello. Los eventos se disparan después de la respuesta y en la medida de lo posible. La mayoría de las escrituras de negocio disparan uno (pedidos, facturas, pagos, presupuestos, contactos, tareas, proyectos, oportunidades). Algunas no, como las asignaciones de etiquetas y las ediciones de precios o propiedades físicas de artículos.

Antes de una carga inicial o de una importación, lista los workflows activos que arrancan desde un evento o desde una entidad: GET /api/v1/workflows con isActive puesto a true y triggerTypes puesto a event,entity. Necesita workflows:read y un administrador. Después, pausa esos workflows en la app o edita su trigger para filtrar por origin y excluir api (y mcp), de modo que ignoren lo que escribe tu clave.

Filmo no envía webhooks y no tiene suscripciones en la API. El push lo construyes dentro de Filmo: un workflow cuyo trigger es un evento y cuyo paso es una petición HTTP a tu endpoint. Consulta Workflows para saber qué son los workflows. El evento es una pista para ejecutar una pasada ahora, nunca el dato: tu pasada sigue siendo la fuente de verdad y la lógica de la marca de agua no cambia.

AspectoComportamiento
Lo que definesEl método (GET, POST, PUT, PATCH o DELETE), la URL, las cabeceras y un cuerpo JSON. La URL y el cuerpo pueden usar los campos del trigger.
Se envía por tiIdempotency-Key: <runId>:<position> en los métodos que modifican, User-Agent: filmo-workflows/1.0, y Content-Type: application/json cuando una petición que no es GET lleva cuerpo. Las cabeceras que tú fijes se aplican al final y pueden sustituir a estas.
FirmaNinguna. La petición no está firmada.
ReintentosNinguna dentro del paso: cualquier respuesta que no sea 2xx hace fallar el paso, y el fallo lleva solo el código de estado, no el cuerpo de la respuesta. La ejecución se intenta hasta 3 veces, esperando 30 segundos y duplicando después entre intentos, y un paso que ya tuvo éxito no se repite.
DestinoSolo http o https. Se rechazan las direcciones de loopback, las privadas y las de metadatos de la nube, así que el endpoint debe ser accesible desde internet público. Se siguen hasta 3 redirecciones, y cada una se vuelve a comprobar.
Límites15 segundos para responder. La respuesta se lee hasta 5 MB.

La clave de idempotencia se construye a partir de la ejecución y la posición del paso, de modo que un paso reintentado envía el mismo valor. Úsala para ignorar una entrega que ya has procesado.

Los nombres de trigger empiezan por filmo., por ejemplo filmo.quote.statusUpdated. Son los que más importan para una sincronización. La lista completa, que tiene bastante más de cien eventos, está en el editor de workflows.

EntidadEventos
Pedidosorder.created, order.reserved, order.cancelled, order.returned, order.archived, order.unarchived, order.completed, order.delivered, order.statusChanged
Presupuestosquote.created, quote.updated, quote.statusUpdated, quote.versionCreated
Facturasinvoice.created, invoice.updated, invoice.validated, invoice.voided, invoice.paid, invoice.partiallyPaid
Pagospayment.created, payment.linked
OtrosLos contactos, tareas, artículos, proyectos, oportunidades, incidencias, compras, contratos y notas de crédito tienen eventos propios, como contact.created, contact.updated y contact.deleted.
CampoContenido
nameEl nombre del evento.
entityType, entityId, entityNameEl registro al que se refiere el evento.
actionCodeLa acción que produjo el evento.
originDe dónde vino el cambio: neo, workflow, api, mcp o integration. Puede ser null.
actorQuién lo hizo: kind, id y name. En un cambio hecho a través de la API, el actor es el administrador propietario de la clave.
entityEl registro, leído después de confirmar el cambio. En un borrado, una instantánea de lo que se borró.
changesUn mapa de los campos que cambiaron, como booleanos. Es {} cuando el evento no calcula una diferencia.

Los eventos se disparan después de la respuesta del cambio y en la medida de lo posible. Una entrega que falla no la reintenta el evento, sino la ejecución, y una ejecución que agota sus intentos se pierde. Mantén la pasada programada, de modo que un evento perdido cueste tiempo y no datos.

Como el paso no firma sus peticiones, autentica tú el receptor. Pon un secreto largo y aleatorio en una cabecera que definas en el paso, compáralo en tiempo constante y usa una URL con un segmento aleatorio que no des a nadie más. Responde rápido con un 2xx y haz el trabajo después. El receptor de abajo hace exactamente eso: comprueba el secreto, ignora una entrega cuya Idempotency-Key ya ha visto y ejecuta la pasada incremental de esta guía, nunca más de una a la vez.

receiver.mjs: Node 22+, ESM, junto a filmo-sync.mjs
// Run: FILMO_HOOK_SECRET=<a long random value> FILMO_API_KEY=... node receiver.mjs
import { timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { incrementalPass, jsonFileStore } from "./filmo-sync.mjs";

const SECRET = Buffer.from(process.env.FILMO_HOOK_SECRET ?? "");
const PATH = process.env.FILMO_HOOK_PATH ?? "/hooks/filmo/replace-with-a-random-segment";
const seen = new Set(); // Idempotency-Key values already handled: use a database in production
let running = false;
let again = false;

function authorized(header) {
  const given = Buffer.from(header ?? "");
  return SECRET.length > 0 && given.length === SECRET.length && timingSafeEqual(given, SECRET);
}

/** Run one pass now, or ask the pass that is running to go round once more. */
async function runPass() {
  if (running) {
    again = true;
    return;
  }
  running = true;
  try {
    do {
      again = false;
      await incrementalPass("/orders", [{}], jsonFileStore("watermark-orders.json"), async (rows) => {
        console.log(rows.length + " orders changed"); // upsert them by id in your database here
      });
    } while (again);
  } catch (error) {
    console.error("pass failed:", error.message);
  } finally {
    running = false;
  }
}

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== PATH) return void res.writeHead(404).end();
  if (!authorized(req.headers["x-filmo-secret"])) return void res.writeHead(401).end();
  req.resume(); // the body is ignored: it only says that something changed
  req.on("end", () => {
    const key = req.headers["idempotency-key"];
    if (!key || !seen.has(key)) {
      if (key) seen.add(key);
      void runPass();
    }
    res.writeHead(204).end();
  });
}).listen(Number(process.env.PORT ?? 8080));

En el workflow, añade la cabecera X-Filmo-Secret al paso de petición HTTP con el mismo valor que FILMO_HOOK_SECRET, y deja el secreto fuera del cuerpo.

Una reacción que escribe en Filmo y un evento que inicia una reacción pueden dispararse mutuamente sin fin. Aplica estas reglas:

  • Filtra el trigger de cada workflow que llame a tu endpoint por origin, para excluir api y mcp, de modo que tus propias escrituras no vuelvan como eventos.
  • Ignora los cambios que coincidan con el último estado que escribiste (el hash descrito antes).
  • Escribe solo cuando los datos difieran de lo que Filmo ya tiene.
  • Limita las escrituras por registro y minuto. Cuando un registro supere el límite, deja de escribir en él y avisa a una persona.
  • Haz idempotente cada reacción: una Idempotency-Key por operación de negocio.

Un workflow que llama a la API con una clave no está cubierto por la protección contra bucles

Filmo protege una cadena de workflows: un workflow que ya está en el linaje queda excluido, y la cadena se detiene a una profundidad de 10. Una escritura que un workflow hace con una clave de API pasa por la API pública, así que lo más probable es que llegue como un evento nuevo de origen api que no lleva linaje, y la protección no la reconocería. Filmo no lo ha confirmado, así que no cuentes con la protección. Si un workflow así puede iniciarse a sí mismo, filtra su trigger por origin y aplica las reglas de arriba.

Una herramienta de lista admite la misma ventana como dateFilters: { updated: { gte: A, lt: B } }, junto con sort: "updatedAt" y limit: 500, por ejemplo en filmo_orders_list. La clave bajo dateFilters es el nombre del campo sin At (created, updated), y un instante con desplazamiento se aplica tal cual con gte y lt. El resultado trae el mismo pagination.hasMore, así que parte la ventana exactamente como arriba. El resultado de una herramienta no trae la cabecera HTTP Date: toma B de un reloj que mantengas sincronizado y usa un margen al menos tan ancho como el de arriba. Para una sincronización programada, REST encaja mejor; MCP es para asistentes.

  • Guías

    Paginación

    Las listas devuelven una página cada vez. Pide una página con limit y, mientras pagination.hasMore sea true, vuelve a llamar con el nextCursor que te dieron.

  • Guías

    Filtrado y orden

    La mayoría de las listas aceptan sort y los filtros de tiempo created* y updated*, muchas aceptan filtros con lista de valores como statuses, y algunas aceptan q para búsqueda de texto libre. Los ejemplos usan la lista de pedidos.

  • 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

    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

    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

    Integraciones con varias cuentas

    Da a cada cuenta de cliente su propia credencial, guárdala con la zona horaria y la moneda de esa cuenta, y trata una credencial que falla como el estado de una conexión, no de todo tu producto. OAuth está disponible para un servidor que ejecutes tú, y las claves de API son más sencillas para el trabajo entre servidores.

  • Guías

    Zapier, Make y n8n

    Zapier, Make y n8n pueden llamar a la API de Filmo con su paso HTTP genérico. Envía la clave como cabecera bearer, pagina las listas con el cursor, espera cuando recibas un 429 y da a cada escritura su propia clave de idempotencia.

  • Para saber más

    Archivar y eliminar

    Filmo tiene varias maneras de retirar un registro de uso, y no son intercambiables. Algunas se pueden deshacer y otras no; consulta esta página antes de llamar a una operación destructiva.

  • Introducción

    Workflows

    Un workflow es una automatización que construyes en la app de Filmo: un trigger inicia una ejecución y los pasos hacen el trabajo, uno tras otro. Desde la API y MCP puedes listar los workflows de una cuenta e iniciar una ejecución.