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).
Qué te da la API
Enlace a esta sección: Qué te da la API| Necesitas | Uso |
|---|---|
| Los registros que han cambiado desde tu última ejecución | En 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 borrado | Un barrido de reconciliación sobre ventanas de createdAt, con un GET de detalle para confirmar cada id que falta. |
| La hora del lado de Filmo | La cabecera Date de cualquier respuesta. Nunca tu propio reloj. |
| Para que te avisen de que algo ha cambiado | Un 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. |
Por qué seguir el cursor pierde registros
Enlace a esta sección: Por qué seguir el cursor pierde registrosEl 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.
La pasada incremental
Enlace a esta sección: La pasada incrementalPrimera carga: los parámetros de pasada completa de cada lista (
overlay.), para no perder lo que se oculta por defecto.syncDefaults Pasada incremental, listas con filtros
updated*GET ...?updatedFrom=A&updatedBefore=B&sort=updatedAt&limit=500 Una ventana semiabierta [A, B). B es la hora del servidor (la cabecera
Datede la última respuesta) menos un margen de seguridad, nunca el reloj del cliente. Una sola página, nunca el cursor.pagination.→ parte la ventana en dos y repitehasMore: true Leídas todas las subventanas, la marca de agua pasa a ser B. Si la pasada se interrumpe, no se mueve. Deduplica por
id.Listas sin filtros
updated*pero ordenables porupdatedAtsort=-updatedAt, sigue el cursorPara al llegar a la marca de agua menos un solapamiento; deduplica por
id.Barrido de conciliación (borrados y actualizaciones saltadas)
Lee todos los ids en ventanas de
createdAt(un cursor con dos barridos donde la lista no tiene filtros de fecha)Compara cada
updatedAtcon el que guardaste y vuelve a leer los que difieren.GETdel detalle de cada id que falta en el barridoSolo un
404cuenta como borrado. Donde no hay lectura de detalle, el id tiene que faltar en dos barridos seguidos.
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.
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
Datede 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 deDate). El aviso de abajo explica por qué ni siquiera ese margen es una garantía.Pide la ventana [A, B)
updatedFrom=A,updatedBefore=B,sort=updatedAtylimit=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.Dividir cuando la página está llena
Si
pagination.eshasMore true, divide la ventana en dos mitades y repite el paso 2 en cada una. No sigas nuncapagination..nextCursor 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.
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
updatedAtmá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ó.
Precisión del filtro
Enlace a esta sección: Precisión del filtroLas 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ámetro | Comparación | Una 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 instanteUna 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., 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.
// filmo-sync.mjs: Node 22+, ESM, no dependencies.
import { readFile, rename, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const BASE = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const KEY = process.env.FILMO_API_KEY;
const INITIAL_FROM = process.env.FILMO_SYNC_FROM ?? "2000-01-01T00:00:00Z";
export const LIMIT = 500;
export const SAFETY_MS = 2 * 60_000 + 1_000; // 2 minutes + the 1 s precision of `Date`
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const iso = (ms) => new Date(ms).toISOString();
/** GET /api/v1<path>. Waits out a 429. Returns { status, body, date } for the statuses in `ok`. */
export async function api(path, params = {}, ok = [200]) {
const url = new URL(`/api/v1${path}`, BASE);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, String(value));
for (;;) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}`, Accept: "application/json" },
});
if (res.status === 429) {
await sleep(Number(res.headers.get("Retry-After") ?? "1") * 1000);
continue;
}
const body = await res.json().catch(() => null);
if (!ok.includes(res.status)) {
throw new Error(`${res.status} ${body?.code ?? ""} (${res.headers.get("X-Request-Id")})`);
}
const date = Date.parse(res.headers.get("Date") ?? "");
if (Number.isNaN(date)) throw new Error("the response has no Date header");
return { status: res.status, body, date };
}
}
/** Merge two id -> record maps, keeping the copy with the later updatedAt. */
function merge(into, from) {
for (const [id, row] of from) {
const known = into.get(id);
if (!known || Date.parse(row.updatedAt) >= Date.parse(known.updatedAt)) into.set(id, row);
}
return into;
}
/**
* Read the half-open window [a, b) (epoch ms) of a list: one page per query,
* split in halves while pagination.hasMore is true. `field` is "updated" for
* the incremental pass and "created" for the deletion sweep. Returns Map id -> record.
*/
export async function syncWindow(listPath, params, a, b, field = "updated") {
const query = {
...params,
[`${field}From`]: iso(a),
[`${field}Before`]: iso(b),
sort: `${field}At`,
limit: LIMIT,
};
const { body } = await api(listPath, query);
const rows = new Map(body.data.map((row) => [row.id, row]));
if (!body.pagination.hasMore) return rows;
if (b - a > 1) {
const mid = a + Math.floor((b - a) / 2);
const left = await syncWindow(listPath, params, a, mid, field);
return merge(left, await syncWindow(listPath, params, mid, b, field));
}
return readOneMillisecond(listPath, query);
}
/** More than LIMIT records share one millisecond: follow the cursor, repeat until two reads agree. */
async function readOneMillisecond(listPath, query) {
let previous = null;
for (let attempt = 0; attempt < 5; attempt++) {
const rows = new Map();
let cursor;
do {
const { body } = await api(listPath, cursor ? { ...query, cursor } : query);
for (const row of body.data) rows.set(row.id, row);
cursor = body.pagination.hasMore ? body.pagination.nextCursor : undefined;
} while (cursor);
const signature = [...rows.values()].map((row) => `${row.id}@${row.updatedAt}`).sort().join("|");
if (signature === previous) return rows;
previous = signature;
}
throw new Error("more than 500 records share one millisecond and keep changing; retry later");
}
/** A watermark store is { load(): Promise<string | null>, save(watermark: string): Promise<void> }. */
export function jsonFileStore(file) {
return {
async load() {
try {
return JSON.parse(await readFile(file, "utf8")).watermark;
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
},
async save(watermark) {
await writeFile(`${file}.tmp`, JSON.stringify({ watermark }));
await rename(`${file}.tmp`, file); // never leave a half-written file
},
};
}
/**
* One incremental pass. `variants` are the query parameters of a full pass
* (see the table of hidden records): [{}] for orders, [{}, { archived: "true" }] for projects.
* `apply` must upsert by id: after a crash the same window is read again.
*/
export async function incrementalPass(listPath, variants, store, apply) {
const probe = await api(listPath, { ...variants[0], limit: 1 });
const b = probe.date - SAFETY_MS; // server time minus the margin, never your own clock
const a = Date.parse((await store.load()) ?? INITIAL_FROM);
if (b <= a) return;
const rows = new Map();
for (const variant of variants) merge(rows, await syncWindow(listPath, variant, a, b));
await apply([...rows.values()]);
await store.save(iso(b)); // the watermark moves only after every sub-window was read
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await incrementalPass("/orders", [{}], jsonFileStore("watermark-orders.json"), async (rows) => {
console.log(`${rows.length} orders changed`); // upsert them by id in your database here
});
}
# filmo_sync.py: Python 3.11+, requests.
import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
INITIAL_FROM = os.environ.get("FILMO_SYNC_FROM", "2000-01-01T00:00:00Z")
LIMIT = 500
SAFETY_MS = 2 * 60_000 + 1_000 # 2 minutes + the 1 s precision of `Date`
session = requests.Session()
session.headers.update(
{"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}", "Accept": "application/json"}
)
def iso(ms):
stamp = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
return stamp.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def to_ms(text):
return round(datetime.fromisoformat(text).timestamp() * 1000)
def api(path, params=None, ok=(200,)):
"""GET /api/v1<path>. Waits out a 429. Returns (status, body, server_time_ms)."""
while True:
res = session.get(f"{BASE}/api/v1{path}", params=params, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "1")))
continue
try:
body = res.json()
except ValueError:
body = None
if res.status_code not in ok:
code = body.get("code") if isinstance(body, dict) else ""
raise RuntimeError(f"{res.status_code} {code} ({res.headers.get('X-Request-Id')})")
date = res.headers.get("Date")
if not date:
raise RuntimeError("the response has no Date header")
return res.status_code, body, round(parsedate_to_datetime(date).timestamp() * 1000)
def merge(into, other):
"""Merge two id -> record dicts, keeping the copy with the later updatedAt."""
for id_, row in other.items():
known = into.get(id_)
if known is None or to_ms(row["updatedAt"]) >= to_ms(known["updatedAt"]):
into[id_] = row
return into
def sync_window(list_path, params, a, b, field="updated"):
"""Read the half-open window [a, b) (epoch ms) of a list: one page per query,
split in halves while pagination.hasMore is true. `field` is "updated" for the
incremental pass and "created" for the deletion sweep. Returns {id: record}."""
query = {
**params,
f"{field}From": iso(a),
f"{field}Before": iso(b),
"sort": f"{field}At",
"limit": LIMIT,
}
_, body, _ = api(list_path, query)
rows = {row["id"]: row for row in body["data"]}
if not body["pagination"]["hasMore"]:
return rows
if b - a > 1:
mid = a + (b - a) // 2
left = sync_window(list_path, params, a, mid, field)
return merge(left, sync_window(list_path, params, mid, b, field))
return read_one_millisecond(list_path, query)
def read_one_millisecond(list_path, query):
"""More than LIMIT records share one millisecond: follow the cursor, repeat until two reads agree."""
previous = None
for _ in range(5):
rows, cursor = {}, None
while True:
_, body, _ = api(list_path, {**query, "cursor": cursor} if cursor else query)
rows.update({row["id"]: row for row in body["data"]})
if not body["pagination"]["hasMore"]:
break
cursor = body["pagination"]["nextCursor"]
signature = sorted((row["id"], row["updatedAt"]) for row in rows.values())
if signature == previous:
return rows
previous = signature
raise RuntimeError("more than 500 records share one millisecond and keep changing; retry later")
class JsonFileStore:
"""A watermark store is any object with load() -> str | None and save(watermark: str)."""
def __init__(self, path):
self.path = path
def load(self):
try:
with open(self.path) as handle:
return json.load(handle)["watermark"]
except FileNotFoundError:
return None
def save(self, watermark):
with open(f"{self.path}.tmp", "w") as handle:
json.dump({"watermark": watermark}, handle)
os.replace(f"{self.path}.tmp", self.path) # never leave a half-written file
def incremental_pass(list_path, variants, store, apply):
"""One incremental pass. `variants` are the query parameters of a full pass
(see the table of hidden records): [{}] for orders, [{}, {"archived": "true"}] for projects.
`apply` must upsert by id: after a crash the same window is read again."""
_, _, server_now = api(list_path, {**variants[0], "limit": 1})
b = server_now - SAFETY_MS # server time minus the margin, never your own clock
a = to_ms(store.load() or INITIAL_FROM)
if b <= a:
return
rows = {}
for variant in variants:
merge(rows, sync_window(list_path, variant, a, b))
apply(list(rows.values()))
store.save(iso(b)) # the watermark moves only after every sub-window was read
if __name__ == "__main__":
incremental_pass(
"/orders",
[{}],
JsonFileStore("watermark-orders.json"),
lambda rows: print(f"{len(rows)} orders changed"), # upsert them by id in your database here
)
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 peticionesLa 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.
Registros que los listados ocultan por defecto
Enlace a esta sección: Registros que los listados ocultan por defectoAlgunas 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.
| Lista | Oculto por defecto | Parámetro para una pasada completa |
|---|---|---|
GET /api | Oportunidades ganadas y perdidas | status establecido en all |
GET /api | Líneas añadidas durante la entrega | includeDelivery establecido en true |
GET /api | Cuentas archivadas | includeArchived establecido en true (se ignora cuando pasas status) |
GET /api | Embudos archivados (la lista no está paginada) | includeArchived establecido en true |
GET /api | Tipos de tarea archivados (la lista no está paginada) | includeArchived establecido en true |
GET /api | Proyectos archivados | archived 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ón | Filtros de creación | Filtros de actualización | Ordena por updatedAt | Oculta por defecto | Estrategia incremental | Barrido de borrados | GET de detalle |
|---|---|---|---|---|---|---|---|
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | No | No | No | includeDelivery=true | Barrido completo | Cursor, dos barridos seguidos | No |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | archived=false · archived=true | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | No |
GET /api | No | No | No | — | Barrido completo | Cursor, dos barridos seguidos | No |
GET /api | No | No | No | — | Barrido completo | Cursor, dos barridos seguidos | No |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
GET /api | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
GET /api | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
GET /api | No | No | No | — | Barrido completo | Cursor, dos barridos seguidos | No |
GET /api | Sí | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | No | No | Sí | status=all | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | No | No | Sí | includeArchived=true | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
GET /api | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
GET /api | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | No |
GET /api | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
Listas sin filtros de actualización
Enlace a esta sección: Listas sin filtros de actualizaciónAlgunas 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.
| Lista | Filtros de fecha | Ordena por updatedAt |
|---|---|---|
GET /api | Ninguno | Sí, solo un campo |
GET /api | Ninguno | Sí, solo un campo |
GET /api | Ninguno | Sí |
GET /api | Ninguno | Sí |
GET /api | Ninguno | Sí, solo un campo |
GET /api | Ninguno | Sí |
GET /api | Ninguno | Sí, solo un campo |
GET /api | Ninguno | Sí, solo un campo |
GET /api | Solo 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.
// early-stop.mjs: for lists with no updatedFrom/updatedBefore that sort by updatedAt.
import { api, iso, jsonFileStore, LIMIT, SAFETY_MS } from "./filmo-sync.mjs";
const OVERLAP_MS = 60 * 60_000; // re-read the last hour on every pass
/** `idKey` is the field that holds the id: "id" for most records, "opportunityId" for opportunities. */
export async function earlyStopPass(listPath, params, idKey, store, apply) {
const probe = await api(listPath, { ...params, limit: 1 });
const passStart = probe.date - SAFETY_MS;
const saved = await store.load();
const stopAt = saved ? Date.parse(saved) - OVERLAP_MS : -Infinity;
const rows = new Map();
let cursor;
scan: for (;;) {
const { body } = await api(listPath, {
...params,
sort: "-updatedAt",
limit: LIMIT,
...(cursor && { cursor }),
});
for (const row of body.data) {
// updatedAt can be null on a few lists: a null never stops the scan
if (row.updatedAt && Date.parse(row.updatedAt) < stopAt) break scan;
rows.set(row[idKey], row);
}
if (!body.pagination.hasMore) break;
cursor = body.pagination.nextCursor;
}
await apply([...rows.values()]);
await store.save(iso(passStart));
}
// Opportunities: status=all, otherwise won and lost ones disappear from the list.
await earlyStopPass(
"/crm/opportunities",
{ status: "all" },
"opportunityId",
jsonFileStore("watermark-opportunities.json"),
async (rows) => console.log(`${rows.length} opportunities changed`),
);
# early_stop.py: for lists with no updatedFrom/updatedBefore that sort by updatedAt.
from filmo_sync import LIMIT, SAFETY_MS, JsonFileStore, api, iso, to_ms
OVERLAP_MS = 60 * 60_000 # re-read the last hour on every pass
def early_stop_pass(list_path, params, id_key, store, apply):
"""`id_key` is the field that holds the id: "id" for most records, "opportunityId" for opportunities."""
_, _, server_now = api(list_path, {**params, "limit": 1})
pass_start = server_now - SAFETY_MS
saved = store.load()
stop_at = to_ms(saved) - OVERLAP_MS if saved else float("-inf")
rows, cursor, done = {}, None, False
while not done:
query = {**params, "sort": "-updatedAt", "limit": LIMIT}
if cursor:
query["cursor"] = cursor
_, body, _ = api(list_path, query)
for row in body["data"]:
# updatedAt can be null on a few lists: a null never stops the scan
if row["updatedAt"] and to_ms(row["updatedAt"]) < stop_at:
done = True
break
rows[row[id_key]] = row
if not body["pagination"]["hasMore"]:
break
cursor = body["pagination"]["nextCursor"]
apply(list(rows.values()))
store.save(iso(pass_start))
if __name__ == "__main__":
# Opportunities: status=all, otherwise won and lost ones disappear from the list.
early_stop_pass(
"/crm/opportunities",
{"status": "all"},
"opportunityId",
JsonFileStore("watermark-opportunities.json"),
lambda rows: print(f"{len(rows)} opportunities changed"),
)
Listas que no se pueden leer de forma incremental
Enlace a esta sección: Listas que no se pueden leer de forma incrementalGET /apitoma un tipo de recurso obligatorio y una ventana de fechas, y no tiene orden ni filtro de actualización. Vuelve a leer en cada ejecución las ventanas que te interesan y sustituye lo que tienes para ellas./v1 /availability /unavailabilities - Varias listas devuelven todo en una sola respuesta y no tienen
cursor. Entre ellas,GET /api,/v1 /tags GET /api,/v1 /task-types GET /api,/v1 /crm /pipelines GET /apiy/v1 /custom-fields GET /apison catálogos pequeños que no se ordenan y no se filtran por/v1 /project-types created*niupdated*: léelos enteros en cada ejecución y sustituye lo que tengas. Un filtroupdated*sobre ellas responde 400validation_error.GET /api(que necesita el id del contacto),/v1 /contacts /{contactId} /related-contacts GET /api(que necesita una fecha de inicio y una de fin) y/v1 /planner /services GET /api(que necesita un tipo de recurso) también responden sin paginación; léelas enteras igualmente./v1 /planner /resources - Las listas de líneas no llevan
updatedAt. Consulta la sección siguiente.
Líneas de pedidos y de documentos
Enlace a esta sección: Líneas de pedidos y de documentosGET /api, GET /api y GET /api 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. Sipagination.eshasMore false, la página es una instantánea consistente de las líneas y no interviene ningún cursor. Si estrue, sigue el cursor y vuelve a leer las líneas hasta que dos lecturas coincidan. - En los pedidos, pasa
includeDeliverycon valortrue, para que se incluyan las líneas añadidas durante la entrega. - Un cambio en las líneas no siempre mueve el
updatedAtdel 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.
Cuando cambia un hijo y el padre no
Enlace a esta sección: Cuando cambia un hijo y el padre noNo 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í solas | No. |
| 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 fechas | Yes. |
| Devolución masiva de líneas de pedido | Solo 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 pedido | Sí, con un updatedAt un segundo en el pasado. |
| Líneas de factura, pagos, validación o anulación, en la factura | Sí. Las líneas se guardan dentro de la factura. |
| Un cambio de estado de presupuesto | Sí, en el presupuesto y en su versión activa. |
| Edición de un borrador de presupuesto | No: solo cambia la versión, no el presupuesto. |
| Vincular o desvincular un pedido y un proyecto | Sí. 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 contacto | Yes. |
| Variantes, propiedades físicas, precios, accesorios y alternativas de un artículo | Yes. |
| Asignar o quitar una etiqueta | Solo 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.
Presupuestos
Enlace a esta sección: PresupuestosLos 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.
Archivado no es eliminado
Enlace a esta sección: Archivado no es eliminadoArchivar un pedido lo saca de la API. Tras PUT /api (que responde 204), el GET del pedido responde 404, igual que con un pedido eliminado, el pedido desaparece de GET /api y no se puede modificar. No hay ningún parámetro que incluya los pedidos archivados. Restaurarlo con PUT /api 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 decreatedAt. - No cuentes con que un archivado repetido no haga nada: archivar de nuevo mueve
updatedAty 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 suupdatedAt, y la lista muestra los proyectos archivados solo conarchivedpuesto atrue.
Detección de eliminaciones
Enlace a esta sección: Detección de eliminacionesUn 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.
Lee con ventanas, no con el cursor
En las listas que tienen
createdFromycreatedBefore(semiabiertos, como los filtrosupdated*), aplica sobrecreatedAtel mismo método de ventanas que en la pasada incremental:sort=createdAt, una página por consulta, dividida en mitades mientraspagination.seahasMore true.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.
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.
Recuperar actualizaciones omitidas
Compara el
updatedAtde 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.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.
Listas sin filtros de creación
Enlace a esta sección: Listas sin filtros de creaciónEn 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.
| Lista | GET de detalle | Regla para borrar por tu lado |
|---|---|---|
GET /api, GET /api, GET /api, GET /api, GET /api, GET /api | Sí | Lee todos los ids por cursor y luego confirma cada uno que falte con su GET de detalle. Solo cuenta un 404. |
GET /api | No | El id debe faltar en dos barridos consecutivos. |
GET /api | No | Lee 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 detalleGET /api, GET /api y GET /api 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 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"
// reconcile.mjs: deletion sweep. Usage: node reconcile.mjs 2026-09-01T00:00:00Z 2026-10-01T00:00:00Z
import { readFile } from "node:fs/promises";
import { api, LIMIT, SAFETY_MS, syncWindow } from "./filmo-sync.mjs";
/**
* Sweep one createdAt window [a, b) (epoch ms) of a list that has createdFrom/createdBefore.
* `local` is Map id -> { createdAt, updatedAt } from your database.
* Returns the records to upsert and the ids whose detail GET answered 404.
*/
export async function sweepWindow({ listPath, detailPath, variants, local, a, b }) {
const remote = new Map();
for (const variant of variants) {
for (const [id, row] of await syncWindow(listPath, variant, a, b, "created")) remote.set(id, row);
}
const upsert = []; // new records and records whose updatedAt differs from yours
for (const [id, row] of remote) {
const mine = local.get(id);
if (!mine || Date.parse(mine.updatedAt) !== Date.parse(row.updatedAt)) upsert.push(row);
}
const gone = []; // missing from the sweep AND 404 on the detail GET
for (const [id, mine] of local) {
const created = Date.parse(mine.createdAt);
if (created < a || created >= b || remote.has(id)) continue;
const { status, body } = await api(`${detailPath}/${encodeURIComponent(id)}`, {}, [200, 404]);
if (status === 404) gone.push(id);
else upsert.push(body.data); // the sweep skipped a live record
}
return { upsert, gone };
}
/** Lists with no created filters: every id by cursor. A list of candidates, never a verdict. */
export async function readIds(listPath, params, idKey = "id") {
const ids = new Set();
let cursor;
do {
const { body } = await api(listPath, { ...params, limit: LIMIT, ...(cursor && { cursor }) });
for (const row of body.data) ids.add(row[idKey]);
cursor = body.pagination.hasMore ? body.pagination.nextCursor : undefined;
} while (cursor);
return ids;
}
/** With a detail GET only a 404 counts. Without one, the id must be missing in two sweeps in a row. */
export async function confirmGone(candidates, previousCandidates, detailPath) {
const gone = [];
for (const id of candidates) {
if (detailPath) {
const { status } = await api(`${detailPath}/${encodeURIComponent(id)}`, {}, [200, 404]);
if (status === 404) gone.push(id);
} else if (previousCandidates.has(id)) {
gone.push(id);
}
}
return gone;
}
if (process.argv[2] && process.argv[3]) {
const local = new Map(Object.entries(JSON.parse(await readFile("local-orders.json", "utf8"))));
const a = Date.parse(process.argv[2]);
const probe = await api("/orders", { limit: 1 });
const b = Math.min(Date.parse(process.argv[3]), probe.date - SAFETY_MS); // server time, not your clock
const { upsert, gone } = await sweepWindow({
listPath: "/orders",
detailPath: "/orders",
variants: [{}],
local,
a,
b,
});
// For orders a 404 means archived or deleted: mark them, do not delete.
console.log(JSON.stringify({ upsert: upsert.map((row) => row.id), archivedOrDeleted: gone }, null, 2));
}
# reconcile.py: deletion sweep.
# Usage: python3 reconcile.py 2026-09-01T00:00:00Z 2026-10-01T00:00:00Z
import json
import sys
from urllib.parse import quote
from filmo_sync import LIMIT, SAFETY_MS, api, sync_window, to_ms
def sweep_window(list_path, detail_path, variants, local, a, b):
"""Sweep one createdAt window [a, b) (epoch ms) of a list that has createdFrom/createdBefore.
`local` is {id: {"createdAt": ..., "updatedAt": ...}} from your database.
Returns the records to upsert and the ids whose detail GET answered 404."""
remote = {}
for variant in variants:
remote.update(sync_window(list_path, variant, a, b, "created"))
upsert = [] # new records and records whose updatedAt differs from yours
for id_, row in remote.items():
mine = local.get(id_)
if mine is None or to_ms(mine["updatedAt"]) != to_ms(row["updatedAt"]):
upsert.append(row)
gone = [] # missing from the sweep AND 404 on the detail GET
for id_, mine in local.items():
created = to_ms(mine["createdAt"])
if created < a or created >= b or id_ in remote:
continue
status, body, _ = api(f"{detail_path}/{quote(id_, safe='')}", None, (200, 404))
if status == 404:
gone.append(id_)
else:
upsert.append(body["data"]) # the sweep skipped a live record
return upsert, gone
def read_ids(list_path, params, id_key="id"):
"""Lists with no created filters: every id by cursor. A list of candidates, never a verdict."""
ids, cursor = set(), None
while True:
query = {**params, "limit": LIMIT}
if cursor:
query["cursor"] = cursor
_, body, _ = api(list_path, query)
ids.update(row[id_key] for row in body["data"])
if not body["pagination"]["hasMore"]:
return ids
cursor = body["pagination"]["nextCursor"]
def confirm_gone(candidates, previous_candidates, detail_path):
"""With a detail GET only a 404 counts. Without one, the id must be missing in two sweeps in a row."""
gone = []
for id_ in candidates:
if detail_path:
status, _, _ = api(f"{detail_path}/{quote(id_, safe='')}", None, (200, 404))
if status == 404:
gone.append(id_)
elif id_ in previous_candidates:
gone.append(id_)
return gone
if __name__ == "__main__" and len(sys.argv) == 3:
with open("local-orders.json") as handle:
local = json.load(handle)
_, _, server_now = api("/orders", {"limit": 1})
a = to_ms(sys.argv[1])
b = min(to_ms(sys.argv[2]), server_now - SAFETY_MS) # server time, not your clock
upsert, gone = sweep_window("/orders", "/orders", [{}], local, a, b)
# For orders a 404 means archived or deleted: mark them, do not delete.
print(json.dumps({"upsert": [row["id"] for row in upsert], "archivedOrDeleted": gone}, indent=2))
Los scripts leen tu estado local de local-orders., 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.
Contactos y tus propios ids
Enlace a esta sección: Contactos y tus propios idsLos 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 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 /apipara decidir qué referencias crear (/v1 /contacts /lookup 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
emailcuandocontactNumberno baste. - Otros registros están mejor situados. Los proyectos aceptan y devuelven un
externalReference. Los pedidos también devuelven unexternalReference, pero la API no te deja escribirlo. Una factura emitida en otro sistema se refleja conPUT /api, con clave/v1 /invoices /external /{originSystem} /{originExternalId} originSystemyoriginExternalId. - Un
Idempotency-Keydeduplica solo durante 24 horas (Idempotencia). Pasado ese plazo, una creación reintentada genera un segundo contacto: comprueba antes tu tabla de correspondencias.
Sincronización bidireccional y ecos
Enlace a esta sección: Sincronización bidireccional y ecosNingú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-Matchcon elETagde tu última lectura, de modo que se rechace una escritura sobre un registro que cambió entretanto (Concurrencia).
Escrituras masivas y automatizaciones
Enlace a esta sección: Escrituras masivas y automatizacionesUna 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 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.
Push: reaccionar a los cambios
Enlace a esta sección: Push: reaccionar a los cambiosFilmo 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.
El paso de petición HTTP
Enlace a esta sección: El paso de petición HTTP| Aspecto | Comportamiento |
|---|---|
| Lo que defines | El 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 ti | Idempotency-Key: <runId>:<position> en los métodos que modifican, User-Agent: filmo-workflows, y Content-Type: application cuando una petición que no es GET lleva cuerpo. Las cabeceras que tú fijes se aplican al final y pueden sustituir a estas. |
| Firma | Ninguna. La petición no está firmada. |
| Reintentos | Ninguna 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. |
| Destino | Solo 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ímites | 15 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.
Eventos y su payload
Enlace a esta sección: Eventos y su payloadLos nombres de trigger empiezan por filmo., por ejemplo filmo.. 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.
| Entidad | Eventos |
|---|---|
| Pedidos | order., order., order., order., order., order., order., order., order. |
| Presupuestos | quote., quote., quote., quote. |
| Facturas | invoice., invoice., invoice., invoice., invoice., invoice. |
| Pagos | payment., payment. |
| Otros | Los contactos, tareas, artículos, proyectos, oportunidades, incidencias, compras, contratos y notas de crédito tienen eventos propios, como contact., contact. y contact.. |
| Campo | Contenido |
|---|---|
name | El nombre del evento. |
entityType, entityId, entityName | El registro al que se refiere el evento. |
actionCode | La acción que produjo el evento. |
origin | De dónde vino el cambio: neo, workflow, api, mcp o integration. Puede ser null. |
actor | Quié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. |
entity | El registro, leído después de confirmar el cambio. En un borrado, una instantánea de lo que se borró. |
changes | Un 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.
Proteger el receptor
Enlace a esta sección: Proteger el receptorComo 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.
// 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 excluirapiymcp, 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-Keypor 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.
El mismo recorrido por MCP
Enlace a esta sección: El mismo recorrido por MCPUna 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., 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.
Páginas relacionadas
Enlace a esta sección: Páginas relacionadasGuías
Paginación
Las listas devuelven una página cada vez. Pide una página con
limity, mientraspagination.seahasMore true, vuelve a llamar con elnextCursorque te dieron.Guías
Filtrado y orden
La mayoría de las listas aceptan
sorty los filtros de tiempocreated*yupdated*, muchas aceptan filtros con lista de valores comostatuses, y algunas aceptanqpara 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-Keycon 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 cabeceraIf-Matchde 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.
¿Te ha resultado útil esta página?