*API REST › 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](https://docs.filmo.io/es/rest-api/guides/pagination)) y cómo se limitan las peticiones ([Límites de peticiones](https://docs.filmo.io/es/rest-api/guides/rate-limits)).

## 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

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.

## La pasada incremental

**Mantener una copia sincronizada**
- Primera carga: los parámetros de pasada completa de cada lista (overlay.syncDefaults), para no perder lo que se oculta por defecto.
- Pasada incremental, listas con filtros updated*
- Tu sistema → API: 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.
- API → Tu sistema: pagination.hasMore: true → parte la ventana en dos y repite
- 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 por updatedAt
- Tu sistema → API: sort=-updatedAt, sigue el cursor Para al llegar a la marca de agua menos un solapamiento; deduplica por id.
- Barrido de conciliación (borrados y actualizaciones saltadas)
- Tu sistema → API: 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.
- Tu sistema → API: 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.

Esto funciona en todas las listas que tienen [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders#param-updatedFrom) y [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders#param-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`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders#param-updatedFrom)=A, [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders#param-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ó.

### Precisión del filtro

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á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

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.

*Una ventana, y la pasada incremental completa*

**cURL**

```bash
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.

```

**Node**

```javascript
// 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
  });
}

```

**Python**

```python
# 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

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](https://docs.filmo.io/es/rest-api/guides/rate-limits)), 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

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.

| Lista | Oculto por defecto | Parámetro para una pasada completa |
| --- | --- | --- |
| `GET /api/v1/crm/opportunities` | Oportunidades ganadas y perdidas | `status` establecido en `all` |
| `GET /api/v1/orders/{orderId}/lines` | Líneas añadidas durante la entrega | `includeDelivery` establecido en `true` |
| `GET /api/v1/treasury/accounts` | Cuentas archivadas | `includeArchived` establecido en `true` (se ignora cuando pasas `status`) |
| `GET /api/v1/crm/pipelines` | Embudos archivados (la lista no está paginada) | `includeArchived` establecido en `true` |
| `GET /api/v1/task-types` | Tipos de tarea archivados (la lista no está paginada) | `includeArchived` establecido en `true` |
| `GET /api/v1/projects` | 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/v1/contacts` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/items` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/orders` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/orders/{orderId}/lines` | No | No | No | `includeDelivery=true` | Barrido completo | Cursor, dos barridos seguidos | No |
| `GET /api/v1/invoices` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/quotes` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/projects` | Sí | Sí | Sí | `archived=false` · `archived=true` | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/invoices/{invoiceId}/payments` | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | No |
| `GET /api/v1/invoices/{invoiceId}/lines` | No | No | No | — | Barrido completo | Cursor, dos barridos seguidos | No |
| `GET /api/v1/quotes/{quoteId}/lines` | No | No | No | — | Barrido completo | Cursor, dos barridos seguidos | No |
| `GET /api/v1/serialized-items` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/spaces` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/repair-tickets` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/vehicles` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/staff` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/subrents` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/purchase-orders` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/contracts` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/kits` | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
| `GET /api/v1/collections` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
| `GET /api/v1/services` | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
| `GET /api/v1/internal-services` | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
| `GET /api/v1/availability/unavailabilities` | No | No | No | — | Barrido completo | Cursor, dos barridos seguidos | No |
| `GET /api/v1/incidents` | Sí | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Ventanas de createdAt | Sí |
| `GET /api/v1/notes` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
| `GET /api/v1/tasks` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/crm/opportunities` | No | No | Sí | `status=all` | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
| `GET /api/v1/supplier-invoices` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/treasury/accounts` | No | No | Sí | `includeArchived=true` | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
| `GET /api/v1/treasury/payments` | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | Sí |
| `GET /api/v1/credit-notes` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/proformas` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | Sí |
| `GET /api/v1/workflows` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |
| `GET /api/v1/payment-methods` | No | No | Sí | — | Descendente por updatedAt, parada en la marca de agua | Cursor, dos barridos seguidos | No |
| `GET /api/v1/users` | Sí | Sí | Sí | — | Ventanas de updatedAt | Ventanas de createdAt | No |

## Listas sin filtros de actualización

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.

| Lista | Filtros de fecha | Ordena por `updatedAt` |
| --- | --- | --- |
| `GET /api/v1/kits` | Ninguno | Sí, solo un campo |
| `GET /api/v1/services` | Ninguno | Sí, solo un campo |
| `GET /api/v1/internal-services` | Ninguno | Sí |
| `GET /api/v1/crm/opportunities` | Ninguno | Sí |
| `GET /api/v1/treasury/payments` | Ninguno | Sí, solo un campo |
| `GET /api/v1/payment-methods` | Ninguno | Sí |
| `GET /api/v1/treasury/accounts` | Ninguno | Sí, solo un campo |
| `GET /api/v1/invoices/{invoiceId}/payments` | Ninguno | Sí, solo un campo |
| `GET /api/v1/incidents` | 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`](https://docs.filmo.io/es/rest-api/objects/service#response-updatedAt) y [`updatedAt`](https://docs.filmo.io/es/rest-api/objects/payment-method#response-updatedAt) pueden ser `null`, y un `null` nunca debe detener el recorrido.

*Parada anticipada, descendente por updatedAt*

**cURL**

```bash
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.

```

**Node**

```javascript
// 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`),
);

```

**Python**

```python
# 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

- [`GET /api/v1/availability/unavailabilities`](https://docs.filmo.io/es/rest-api/endpoint-reference/availability/list-the-unavailabilities-of-a-resource) toma 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.
- Varias listas devuelven todo en una sola respuesta y no tienen `cursor`. Entre ellas, [`GET /api/v1/tags`](https://docs.filmo.io/es/rest-api/endpoint-reference/tags/list-tags), [`GET /api/v1/task-types`](https://docs.filmo.io/es/rest-api/endpoint-reference/task-types/list-task-types), [`GET /api/v1/crm/pipelines`](https://docs.filmo.io/es/rest-api/endpoint-reference/crm/list-pipelines), [`GET /api/v1/custom-fields`](https://docs.filmo.io/es/rest-api/endpoint-reference/custom-fields/list-custom-field-definitions) y [`GET /api/v1/project-types`](https://docs.filmo.io/es/rest-api/endpoint-reference/projects/list-project-types) son catálogos pequeños que no se ordenan y no se filtran por `created*` ni `updated*`: léelos enteros en cada ejecución y sustituye lo que tengas. Un filtro `updated*` sobre ellas responde 400 [`validation_error`](https://docs.filmo.io/es/rest-api/errors/validation_error). [`GET /api/v1/contacts/{contactId}/related-contacts`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-the-contacts-related-to-a-contact) (que necesita el id del contacto), [`GET /api/v1/planner/services`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-services-to-cover) (que necesita una fecha de inicio y una de fin) y [`GET /api/v1/planner/resources`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-assignable-resources) (que necesita un tipo de recurso) también responden sin paginación; léelas enteras igualmente.
- Las listas de líneas no llevan `updatedAt`. Consulta la sección siguiente.

## Líneas de pedidos y de documentos

[`GET /api/v1/orders/{orderId}/lines`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-the-lines-of-an-order), [`GET /api/v1/invoices/{invoiceId}/lines`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/list-the-lines-of-an-invoice) y [`GET /api/v1/quotes/{quoteId}/lines`](https://docs.filmo.io/es/rest-api/endpoint-reference/quotes/list-the-lines-of-a-quote) 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`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-the-lines-of-an-order#param-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.

## Cuando cambia un hijo y el padre no

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í 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

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.

## Archivado no es eliminado

Archivar un pedido lo saca de la API. Tras [`PUT /api/v1/orders/{orderId}/archive`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/archive-or-restore-an-order) (que responde 204), el GET del pedido responde 404, igual que con un pedido eliminado, el pedido desaparece de [`GET /api/v1/orders`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-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`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/archive-or-restore-an-order) 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](https://docs.filmo.io/es/further-reading/archiving-and-deleting).

- 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`](https://docs.filmo.io/es/rest-api/endpoint-reference/projects/list-projects#param-archived) puesto a `true`.

## Detección de eliminaciones

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`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders#param-createdFrom) y [`createdBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders#param-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.

### Listas sin filtros de creación

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.

| Lista | GET de detalle | Regla 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/accounts` | 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/v1/payment-methods` | No | El id debe faltar en dos barridos consecutivos. |
| `GET /api/v1/invoices/{invoiceId}/payments` | 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

[`GET /api/v1/users`](https://docs.filmo.io/es/rest-api/endpoint-reference/users/list-users), [`GET /api/v1/collections`](https://docs.filmo.io/es/rest-api/endpoint-reference/collections/list-collections) y [`GET /api/v1/workflows`](https://docs.filmo.io/es/rest-api/endpoint-reference/workflows/list-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`](https://docs.filmo.io/es/rest-api/endpoint-reference/notes/list-the-notes-of-an-entity) 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.

*Barrido de reconciliación*

**cURL**

```bash
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"

```

**Node**

```javascript
// 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));
}

```

**Python**

```python
# 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.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.

## Contactos y tus propios ids

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`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) 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`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) 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`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-contacts#param-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}`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice), con clave `originSystem` y `originExternalId`.
- Un `Idempotency-Key` deduplica solo durante 24 horas ([Idempotencia](https://docs.filmo.io/es/rest-api/guides/idempotency)). Pasado ese plazo, una creación reintentada genera un segundo contacto: comprueba antes tu tabla de correspondencias.

## Sincronización bidireccional y ecos

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](https://docs.filmo.io/es/rest-api/guides/concurrency)).

## Escrituras masivas y automatizaciones

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`](https://docs.filmo.io/es/rest-api/endpoint-reference/workflows/list-workflows) con [`isActive`](https://docs.filmo.io/es/rest-api/endpoint-reference/workflows/list-workflows#param-isActive) puesto a `true` y [`triggerTypes`](https://docs.filmo.io/es/rest-api/endpoint-reference/workflows/list-workflows#param-triggerTypes) puesto a `event,entity`. Necesita [`workflows:read`](https://docs.filmo.io/es/rest-api/guides/scopes#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

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](https://docs.filmo.io/es/workflows/overview) 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

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

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.

| Entidad | Eventos |
| --- | --- |
| Pedidos | `order.created`, `order.reserved`, `order.cancelled`, `order.returned`, `order.archived`, `order.unarchived`, `order.completed`, `order.delivered`, `order.statusChanged` |
| Presupuestos | `quote.created`, `quote.updated`, `quote.statusUpdated`, `quote.versionCreated` |
| Facturas | `invoice.created`, `invoice.updated`, `invoice.validated`, `invoice.voided`, `invoice.paid`, `invoice.partiallyPaid` |
| Pagos | `payment.created`, `payment.linked` |
| Otros | Los 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`. |

| 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

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*

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

## El mismo recorrido por MCP

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`](https://docs.filmo.io/es/mcp/tools/orders/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.

---

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