*API REST › Tutoriales*

# Sincronizar tu inventario con otro sistema

> Lees el catálogo, las unidades físicas y las líneas de los pedidos que las comprometen, lo cargas todo una vez, y después consultas los cambios y concilias cada semana. Filmo no envía webhooks, así que consultar periódicamente es la forma de mantenerte al día.

Este tutorial muestra solo las llamadas específicas del inventario. La pasada por ventanas, el margen de seguridad, el barrido de reconciliación y las razones que los sustentan se definen en [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data); lee primero esa guía y usa esta página como su ejemplo práctico.

## Lo que lees

| Datos | Operación | Cómo la mantienes al día |
| --- | --- | --- |
| Artículos del catálogo | `GET /api/v1/items` | Pasada por ventanas sobre `updatedAt`; una lectura de detalle con `GET /api/v1/items/{itemId}` para los campos que la lista omite |
| Unidades físicas | `GET /api/v1/serialized-items` | Pasada por ventanas sobre `updatedAt`; la unidad indica en `currentOrderId` qué pedido la ha preparado, recogido, vendido o recuperado; una reserva no está ahí, léela en `serials` |
| Pedidos (la cabecera) | `GET /api/v1/orders` | Pasada por ventanas sobre `updatedAt` |
| Líneas de pedido | `GET /api/v1/orders/{orderId}/lines` | Vuelve a leer por pedido: una línea no tiene `updatedAt` propio, y un cambio solo en las líneas no mueve el del pedido |

[`serializedItemsCount`](https://docs.filmo.io/es/rest-api/objects/item#response-serializedItemsCount) te dice cuántas unidades tiene un artículo, pero un artículo no lleva un nivel de stock para una fecha. Para preguntar qué está libre entre dos fechas, usa [`POST /api/v1/availability/check`](https://docs.filmo.io/es/rest-api/endpoint-reference/availability/check-item-availability), que tiene su propia cuota de 30 peticiones por minuto; consulta [De la disponibilidad al pedido reservado](https://docs.filmo.io/es/rest-api/tutorials/availability-to-reserved-order).

## Requisitos previos

- Una clave de API con [`account:read`](https://docs.filmo.io/es/rest-api/guides/scopes#account:read), [`items:read`](https://docs.filmo.io/es/rest-api/guides/scopes#items:read) y [`orders:read`](https://docs.filmo.io/es/rest-api/guides/scopes#orders:read). Créala en los ajustes de API y MCP de la app (Ajustes → API y MCP); solo pueden hacerlo los administradores. Una clave de solo lectura basta para todo el tutorial.
- Node 22 o posterior, o Python 3 con `requests`, y la clave en la variable de entorno `FILMO_API_KEY`.
- La idea de la pasada por ventanas de [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data): pide una ventana semiabierta de `updatedAt`, una página, y divide la ventana si `pagination.hasMore` es `true`. Nunca sigas el cursor para decidir qué ha cambiado.

## Paso 1. Configurar el cliente y leer la cuenta

Guarda el cliente una vez; todos los fragmentos posteriores lo importan. Aguarda una respuesta [`rate_limited`](https://docs.filmo.io/es/rest-api/errors/rate_limited) durante tantos segundos como indique `Retry-After` y devuelve la hora propia del servidor a partir de la cabecera `Date`, que las ventanas necesitan. Los fragmentos de cURL de los pasos posteriores reutilizan la variable `FILMO_URL` definida en la pestaña de cURL.

*El cliente*

**cURL**

```bash
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

```

**Node**

```javascript
// filmo.mjs: a small client. Node 22 or later, no dependencies.
export const API = (process.env.FILMO_BASE_URL ?? "https://app.filmo.io") + "/api/v1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// GET with query parameters. Waits out a 429 as Retry-After says.
export async function get(path, query = {}, headers = {}) {
  const url = new URL(API + path);
  for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value));
  for (;;) {
    const res = await fetch(url, {
      headers: { Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "), ...headers },
    });
    if (res.status === 429) {
      await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
      continue;
    }
    if (res.status === 304) return { status: 304, etag: res.headers.get("ETag") };
    if (!res.ok) {
      throw Object.assign(new Error(res.status + " " + (await res.text())), { status: res.status });
    }
    return {
      ...(await res.json()),
      status: res.status,
      etag: res.headers.get("ETag"),
      serverTime: new Date(res.headers.get("Date")), // the server's clock, not yours
    };
  }
}
```

**Python**

```python
# filmo.py: a small client. Python 3 and requests.
import os
import time
from email.utils import parsedate_to_datetime

import requests

API = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io") + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FILMO_API_KEY']}"


def get(path, headers=None, **query):
    """GET with query parameters. Waits out a 429 as Retry-After says."""
    while True:
        res = session.get(API + path, params=query, headers=headers)
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", "1")))
            continue
        if res.status_code == 304:
            return {"status": 304, "etag": res.headers.get("ETag")}
        res.raise_for_status()
        body = res.json()
        body["status"] = res.status_code
        body["etag"] = res.headers.get("ETag")
        body["serverTime"] = parsedate_to_datetime(res.headers["Date"])  # the server's clock
        return body
```

Después lee la cuenta con [`GET /api/v1/account`](https://docs.filmo.io/es/rest-api/endpoint-reference/account/get-the-account). [`timezone`](https://docs.filmo.io/es/rest-api/objects/account#response-timezone) es la zona horaria del negocio y [`rateLimits`](https://docs.filmo.io/es/rest-api/objects/account#response-rateLimits) informa de las cuotas de tu clave; la global es de 120 peticiones por minuto.

**cURL**

```bash
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

curl -sS "$FILMO_URL/api/v1/account" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
import { get } from "./filmo.mjs";

const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);
```

**Python**

```python
from filmo import get

account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])
```

## Paso 2. Cargar el catálogo

La primera carga lee todos los artículos. Elige un inicio anterior a tu primer registro (fija `START` en el helper en el mes de tu primer registro), fija el final B en la hora del servidor menos el margen de seguridad, y recorre el rango en ventanas de 30 días con [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-updatedFrom) y [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-updatedBefore), ordenando con [`sort`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-sort) `updatedAt` y con [`limit`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-limit) 500. Cada ventana es una página; el helper divide por la mitad una ventana que desborda y deduplica por `id`.

*La lectura por ventanas*

**cURL**

```bash
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

# One window [updatedFrom, updatedBefore), one page. If pagination.hasMore is true, split the window.
curl -sS -G "$FILMO_URL/api/v1/items" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "updatedFrom=2026-09-01T00:00:00.000Z" \
  --data-urlencode "updatedBefore=2026-10-01T00:00:00.000Z" \
  --data-urlencode "sort=updatedAt" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
// window.mjs: the windowed read of the syncing guide, one page per query.
import { get } from "./filmo.mjs";

const iso = (ms) => new Date(ms).toISOString();

export const START = Date.parse("2020-01-01T00:00:00Z"); // move it to the month of your first record

// B: the server's time minus the safety margin of the guide (2 minutes and 1 second). The time is the
// Date header of a response, never your own clock.
export async function upperBound() {
  const { serverTime } = await get("/account");
  return serverTime.getTime() - (2 * 60 * 1000 + 1000);
}

// Every record whose updatedAt (or createdAt) falls in [from, to), in epoch milliseconds.
// It never follows the cursor: a window that overflows one page is split in half.
export async function windowed(path, from, to, { field = "updated", query = {} } = {}) {
  const page = await get(path, {
    ...query,
    [field + "From"]: iso(from),
    [field + "Before"]: iso(to),
    sort: field + "At",
    limit: 500,
  });
  if (!page.pagination.hasMore) return page.data;
  if (to - from <= 1) throw new Error("More than 500 records share one millisecond: see the guide");
  const mid = Math.floor((from + to) / 2);
  const merged = new Map();
  for (const half of [
    await windowed(path, from, mid, { field, query }),
    await windowed(path, mid, to, { field, query }),
  ]) {
    for (const record of half) merged.set(record.id, record); // deduplicate by id
  }
  return [...merged.values()];
}

// Walks [start, end) in 30-day windows. Returns a Map of id to record.
export async function readRange(path, start, end, options) {
  const step = 30 * 24 * 3600 * 1000;
  const records = new Map();
  for (let from = start; from < end; from += step) {
    for (const record of await windowed(path, from, Math.min(from + step, end), options)) {
      records.set(record.id, record);
    }
  }
  return records;
}
```

**Python**

```python
# window.py: the windowed read of the syncing guide, one page per query.
from datetime import datetime, timezone

from filmo import get


START = int(datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp() * 1000)  # move it to the month of your first record


def upper_bound():
    """B: the server's time minus the safety margin of the guide (2 minutes and 1 second).
    The time is the Date header of a response, never your own clock."""
    return int(get("/account")["serverTime"].timestamp() * 1000) - (2 * 60 * 1000 + 1000)


def iso(ms):
    stamp = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
    return stamp.isoformat(timespec="milliseconds").replace("+00:00", "Z")


def windowed(path, start, end, field="updated", **query):
    """Every record whose updatedAt (or createdAt) falls in [start, end), in epoch ms.
    It never follows the cursor: a window that overflows one page is split in half."""
    page = get(
        path,
        **query,
        **{field + "From": iso(start), field + "Before": iso(end), "sort": field + "At", "limit": 500},
    )
    if not page["pagination"]["hasMore"]:
        return page["data"]
    if end - start <= 1:
        raise RuntimeError("More than 500 records share one millisecond: see the guide")
    mid = (start + end) // 2
    merged = {r["id"]: r for r in windowed(path, start, mid, field, **query)}
    merged.update({r["id"]: r for r in windowed(path, mid, end, field, **query)})
    return list(merged.values())


def read_range(path, start, end, field="updated", **query):
    """Walks [start, end) in 30-day windows. Returns a dict of id to record."""
    step = 30 * 24 * 3600 * 1000
    records = {}
    for begin in range(start, end, step):
        for record in windowed(path, begin, min(begin + step, end), field, **query):
            records[record["id"]] = record
    return records
```

*Cargar todos los artículos*

**cURL**

```bash
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

# One window [updatedFrom, updatedBefore), one page. If pagination.hasMore is true, split the window.
curl -sS -G "$FILMO_URL/api/v1/items" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "updatedFrom=2026-09-01T00:00:00.000Z" \
  --data-urlencode "updatedBefore=2026-10-01T00:00:00.000Z" \
  --data-urlencode "sort=updatedAt" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { readRange, START, upperBound } from "./window.mjs";

const B = await upperBound(); // the end of this load; save it as the watermark when every list is read
const items = await readRange("/items", START, B);
console.log(items.size + " items");
```

**Python**

```python
from window import START, read_range, upper_bound

B = upper_bound()  # the end of this load; save it as the watermark when every list is read
items = read_range("/items", START, B)
print(len(items), "items")
```

La lista devuelve los campos que necesitas para replicar un catálogo: [`name`](https://docs.filmo.io/es/rest-api/objects/item#response-name), [`sku`](https://docs.filmo.io/es/rest-api/objects/item#response-sku), [`ean`](https://docs.filmo.io/es/rest-api/objects/item#response-ean), [`stockCalculationMethod`](https://docs.filmo.io/es/rest-api/objects/item#response-stockCalculationMethod) (`bulk` o `serialized`), [`collectionId`](https://docs.filmo.io/es/rest-api/objects/item#response-collectionId), [`variants`](https://docs.filmo.io/es/rest-api/objects/item#response-variants) y [`salePrice`](https://docs.filmo.io/es/rest-api/objects/item#response-salePrice) (una cadena decimal). El valor de reposición, los accesorios y las alternativas solo están en el detalle; léelos con [`GET /api/v1/items/{itemId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/get-an-item) para los artículos que los necesiten, y calcula una llamada por artículo.

## Paso 3. Cargar las unidades físicas

[`GET /api/v1/serialized-items`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-serialized-items) lista todas las unidades, en todos los estados, a menos que filtres con [`status`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-serialized-items#param-status); los valores que acepta el filtro son `available`, `prepared`, `picked_up`, `returned`, `broken`, `archived` y `sold`. No filtres en la sincronización: quieres ver cómo una unidad cambia de estado, y una unidad `archived` o `sold` no debe parecer eliminada. [`inventoryType`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-serialized-items#param-inventoryType) separa tu propio stock (`regular`) del stock traído para un trabajo (`temporary`). La unidad lleva el pedido que la ha preparado, recogido, vendido o recibido de vuelta, así que puedes responder "dónde está el número de serie X" para esos estados sin leer líneas; una unidad que solo está reservada muestra su pedido en los `serials` de la línea de pedido.

**cURL**

```bash
# The units of one catalog item, with where each one is committed right now.
curl -sS -G "$FILMO_URL/api/v1/serialized-items" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "itemId=$ITEM_ID" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
import { readRange, START, upperBound } from "./window.mjs";

const B = await upperBound();
const units = await readRange("/serialized-items", START, B);
for (const unit of units.values()) {
  // unit.currentOrderId is the order that has prepared, picked up, sold or got the unit back.
  // It is null otherwise, also when the unit is only reserved on an order.
  console.log(unit.code, unit.status, unit.itemId, unit.currentOrderId);
}
```

**Python**

```python
from window import START, read_range, upper_bound

B = upper_bound()
units = read_range("/serialized-items", START, B)
for unit in units.values():
    # unit["currentOrderId"] is the order that has prepared, picked up, sold or got the unit back.
    # It is None otherwise, also when the unit is only reserved on an order.
    print(unit["code"], unit["status"], unit["itemId"], unit["currentOrderId"])
```

## Paso 4. Cargar los pedidos y volver a leer sus líneas

Lee los pedidos con el mismo ayudante y luego lee las líneas de cada pedido con [`GET /api/v1/orders/{orderId}/lines`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-the-lines-of-an-order). Pasa [`includeDelivery`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-the-lines-of-an-order#param-includeDelivery) puesto a `true`: sin él, las líneas añadidas durante la entrega se quedan fuera y tu imagen del stock se queda corta. Las líneas pertenecen al pedido y solo se alcanzan a través de él; no hay una lista de líneas por sí sola.

*Leer las líneas de un pedido*

**cURL**

```bash
# The lines of one order, including the ones added during delivery.
curl -sS -G "$FILMO_URL/api/v1/orders/$ORDER_ID/lines" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "includeDelivery=true" \
  --data-urlencode "limit=500"
```

**Node**

```javascript
// orders.mjs
import { get } from "./filmo.mjs";

// Lines are only reachable through their order, and they have no updatedAt.
export async function readLines(orderId) {
  const lines = [];
  let cursor;
  do {
    const page = await get("/orders/" + orderId + "/lines", {
      includeDelivery: true,
      limit: 500,
      ...(cursor && { cursor }),
    });
    lines.push(...page.data);
    cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined;
  } while (cursor);
  return lines;
}
```

**Python**

```python
# orders.py
from filmo import get


def read_lines(order_id):
    """Lines are only reachable through their order, and they have no updatedAt."""
    lines, cursor = [], None
    while True:
        query = {"includeDelivery": "true", "limit": 500}
        if cursor:
            query["cursor"] = cursor
        page = get("/orders/" + order_id + "/lines", **query)
        lines += page["data"]
        if not page["pagination"]["hasMore"]:
            return lines
        cursor = page["pagination"]["nextCursor"]
```

*Cargar los pedidos y sus líneas*

**Node**

```javascript
import { readRange, START, upperBound } from "./window.mjs";
import { readLines } from "./orders.mjs";

const B = await upperBound();
const orders = await readRange("/orders", START, B);
for (const order of orders.values()) {
  try {
    const lines = await readLines(order.id);
    // Replace the lines you hold for order.id with these.
    for (const line of lines) console.log(order.orderNumber, line.itemId, line.quantity, line.serials.length);
  } catch (error) {
    if (error.status !== 404) throw error; // 404: archived or deleted since the list was read
  }
}
```

**Python**

```python
import requests

from orders import read_lines
from window import START, read_range, upper_bound

B = upper_bound()
orders = read_range("/orders", START, B)
for order in orders.values():
    try:
        for line in read_lines(order["id"]):
            # Replace the lines you hold for this order with these.
            print(order["orderNumber"], line["itemId"], line["quantity"], len(line["serials"]))
    except requests.HTTPError as error:
        if error.response.status_code != 404:  # 404: archived or deleted since the list was read
            raise
```

Para el inventario, los campos de línea útiles son [`itemId`](https://docs.filmo.io/es/rest-api/objects/order-line#response-itemId), [`variantId`](https://docs.filmo.io/es/rest-api/objects/order-line#response-variantId), [`quantity`](https://docs.filmo.io/es/rest-api/objects/order-line#response-quantity), [`serials`](https://docs.filmo.io/es/rest-api/objects/order-line#response-serials) (las unidades concretas asignadas, vacío si aún no hay ninguna), [`status`](https://docs.filmo.io/es/rest-api/objects/order-line#response-status), [`addedDuringDelivery`](https://docs.filmo.io/es/rest-api/objects/order-line#response-addedDuringDelivery), y el periodo en [`startDate`](https://docs.filmo.io/es/rest-api/objects/order-line#response-startDate) y [`endDate`](https://docs.filmo.io/es/rest-api/objects/order-line#response-endDate), que son null cuando la línea hereda el periodo del pedido. Las líneas de tipo `section` y `type_group` son agrupaciones de presentación sin recurso; sáltalas.

> **Las líneas no tienen updatedAt:** [`id`](https://docs.filmo.io/es/rest-api/objects/order-line#response-id) identifica una línea, pero nada te dice cuándo cambió, y un cambio solo en las líneas no mueve el `updatedAt` del pedido. Guardar el pedido con fechas nuevas, reservar, anular la reserva, cancelar y editar el pedido sin fechas sí lo mueven; una devolución masiva de líneas lo mueve solo cuando todas las líneas activas acaban en un estado final. Así que no basta con que el pedido aparezca en una ventana: vuelve a leer las líneas de cada pedido que aparezca en una ventana y, según un calendario (una vez al día como mínimo; cada hora si el stock debe estar al día), las líneas de los pedidos que tienes como aún abiertos (cualquier estado distinto de `checked_in` o `cancelled`; los pedidos archivados no se pueden leer). Un guardado completo de un pedido escribe sus líneas después de confirmar el pedido, así que una línea leída justo después del cambio del pedido todavía puede ser la antigua: la siguiente relectura lo corrige. Un pedido con más de 500 líneas necesita el cursor: lee todas sus páginas de una vez y, si el `updatedAt` del pedido cambió mientras leías, léelas de nuevo.

## Paso 5. Mantenerlo al día

Cada pocos minutos, haz una pasada sobre [A, B): A es la marca de agua que guardaste y B es la hora del servidor menos el margen. La marca de agua pasa a B solo cuando se han leído todas las listas; si la pasada se interrumpe, la siguiente repite la misma ventana. Los registros que cambian mientras lees caen en una ventana posterior. El `updatedAt` de un registro viene del reloj de la aplicación y no es monótono en el commit: algunas escrituras lo marcan un segundo antes, y el trabajo posterior al commit puede tardar más. Por eso mantén el margen en dos minutos o más, empieza cada ventana con [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-updatedFrom) (inclusivo) y deduplica por `id`. Filmo no da ningún límite superior garantizado para el retraso, así que la reconciliación semanal se mantiene. La guía explica el algoritmo y por qué el cursor no puede darte una garantía.

**Node**

```javascript
// poll.mjs: one incremental pass. Run it every few minutes.
import { get } from "./filmo.mjs";
import { readRange } from "./window.mjs";
import { readLines } from "./orders.mjs";

const MARGIN = 2 * 60 * 1000 + 1000; // the guide's safety margin: 2 minutes and 1 second

export async function pollOnce(state) {
  // state = { watermark } in epoch ms, kept in your database or a file.
  const { serverTime } = await get("/account");
  const A = state.watermark;
  const B = serverTime.getTime() - MARGIN;
  if (B <= A) return state;

  const items = await readRange("/items", A, B);
  const units = await readRange("/serialized-items", A, B);
  const orders = await readRange("/orders", A, B);

  // ...upsert items and units by id here...

  // Lines have no updatedAt: re-read the lines of every order that appears in the window.
  for (const order of orders.values()) {
    const lines = await readLines(order.id);
    // ...replace the lines you hold for order.id...
  }
  return { watermark: B }; // moves only when every list above was read
}
```

**Python**

```python
# poll.py: one incremental pass. Run it every few minutes.
from filmo import get
from window import read_range
from orders import read_lines

MARGIN = 2 * 60 * 1000 + 1000  # the guide's safety margin: 2 minutes and 1 second


def poll_once(state):
    """state = {"watermark": epoch ms}, kept in your database or a file."""
    server_time = get("/account")["serverTime"]
    a = state["watermark"]
    b = int(server_time.timestamp() * 1000) - MARGIN
    if b <= a:
        return state

    items = read_range("/items", a, b)
    units = read_range("/serialized-items", a, b)
    orders = read_range("/orders", a, b)

    # ...upsert items and units by id here...

    # Lines have no updatedAt: re-read the lines of every order that appears in the window.
    for order in orders.values():
        lines = read_lines(order["id"])
        # ...replace the lines you hold for this order...
    return {"watermark": b}  # moves only when every list above was read
```

Un pedido archivado responde 404 en su GET y desaparece de la lista; igual que uno eliminado. Cuando [`not_found`](https://docs.filmo.io/es/rest-api/errors/not_found) vuelva para un pedido que tienes, márcalo como archivado o eliminado y vuelve a comprobarlo en el siguiente barrido en lugar de eliminarlo en tu lado. Consulta [Archivar y eliminar](https://docs.filmo.io/es/further-reading/archiving-and-deleting).

## Paso 6. Reconciliar una vez por semana

Eliminar es definitivo y no deja rastro: eliminar un artículo elimina también sus unidades serializadas, y su GET responde 404 a partir de entonces. Una pasada no puede informarte de una eliminación, y un barrido que sigue el cursor no puede decidirla, porque un registro omitido por un cambio concurrente se ve igual que uno eliminado. Así que el barrido lista ventanas de [`createdFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-createdFrom) y [`createdBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items#param-createdBefore) (todas las listas de este tutorial las tienen), compara lo que vuelve con lo que tienes y confirma cada id ausente con su propio GET. Solo cuenta un 404. El barrido también compara `updatedAt` y vuelve a leer los registros que difieren, lo que recupera cualquier actualización que las pasadas hayan perdido.

**cURL**

```bash
# A week later: list a createdAt window...
curl -sS -G "$FILMO_URL/api/v1/items" \
  -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"

# ...and confirm every id you hold that is missing from it. Only a 404 counts.
curl -sS -o /dev/null -w "%{http_code}\n" "$FILMO_URL/api/v1/items/$MISSING_ITEM_ID" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
// sweep.mjs: weekly reconciliation of one list.
import { get } from "./filmo.mjs";
import { readRange } from "./window.mjs";

// held: Map of id to the updatedAt you stored. Returns what to re-read and what is gone.
export async function sweep(path, held, start, end) {
  const live = await readRange(path, start, end, { field: "created" });

  // Updates the early passes may have skipped: the stored updatedAt differs.
  const stale = [...live].filter(([id, record]) => held.get(id) !== record.updatedAt).map(([id]) => id);

  // A missing id is not a deletion until its own GET answers 404.
  const gone = [];
  for (const id of held.keys()) {
    if (live.has(id)) continue;
    try {
      await get(path + "/" + id); // still there: the sweep skipped it, do not delete
    } catch (error) {
      if (error.status !== 404) throw error;
      gone.push(id); // for an order, 404 means archived or deleted
    }
  }
  return { stale, gone };
}
```

**Python**

```python
# sweep.py: weekly reconciliation of one list.
import requests

from filmo import get
from window import read_range


def sweep(path, held, start, end):
    """held: dict of id to the updatedAt you stored. Returns what to re-read and what is gone."""
    live = read_range(path, start, end, field="created")

    # Updates the early passes may have skipped: the stored updatedAt differs.
    stale = [i for i, record in live.items() if held.get(i) != record["updatedAt"]]

    # A missing id is not a deletion until its own GET answers 404.
    gone = []
    for record_id in held:
        if record_id in live:
            continue
        try:
            get(path + "/" + record_id)  # still there: the sweep skipped it, do not delete
        except requests.HTTPError as error:
            if error.response.status_code != 404:
                raise
            gone.append(record_id)  # for an order, 404 means archived or deleted
    return {"stale": stale, "gone": gone}
```

[`GET /api/v1/serialized-items`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-serialized-items) y [`GET /api/v1/orders`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders) se barren del mismo modo; confirma las unidades que faltan con [`GET /api/v1/serialized-items/{serializedItemId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/get-a-serialized-item) y los pedidos que faltan con [`GET /api/v1/orders/{orderId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/get-an-order). [`GET /api/v1/items`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-items) y [`GET /api/v1/serialized-items`](https://docs.filmo.io/es/rest-api/endpoint-reference/items/list-serialized-items) devuelven todos los registros que pueden ver. [`GET /api/v1/orders`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/list-orders) no: nunca devuelve pedidos archivados, y ningún parámetro los incluye, así que un pedido que el equipo archiva desaparece de cada recorrido y de cada barrido. Por eso un pedido que falta se confirma con su GET y un 404 se registra como “archivado o eliminado”, no como una eliminación. Las líneas de entrega de un pedido se tratan más arriba.

## Qué guardar por tu parte

| Guardar | Por qué |
| --- | --- |
| `id` de Filmo junto a tu propio id, para artículos, unidades y pedidos | Los identificadores son opacos; nunca derives uno de otro. La tabla es tu única correspondencia. |
| El `updatedAt` que viste por última vez, por registro | El barrido semanal lo compara para encontrar actualizaciones que una pasada se dejó. |
| La marca de agua B de la última pasada completa | La siguiente pasada empieza ahí. Solo avanza tras una pasada completa. |
| Las líneas de cada pedido, reemplazadas como conjunto | Una línea no tiene `updatedAt`, así que sustituyes el conjunto entero cada vez que lo vuelves a leer. |
| Un indicador de registros archivados o eliminados, no un borrado | Un 404 en un pedido significa archivado o eliminado; el barrido lo decidirá más tarde. |

## Presupuesto de llamadas frente a 120 peticiones por minuto

Tu clave permite 120 peticiones por minuto en toda la API, y cada respuesta autenticada cuenta, incluidas un 404 o un 429. Cuenta las llamadas antes de empezar. El ayudante recorre el rango en ventanas fijas de 30 días. Para una lista leída en W ventanas de las cuales S desbordan una página, el coste es W + 2S llamadas: cada desbordamiento cuesta la llamada fallida más dos mitades. W depende de tu fecha de inicio, no del número de registros: un rango de 24 meses son 24 ventanas. La carga inicial son las tres listas, una llamada para la hora del servidor y una llamada de líneas por pedido (más solo para los pedidos con más de 500 líneas):

*Carga inicial*

```text
calls = 1 + (W_items + 2*S_items) + (W_units + 2*S_units) + (W_orders + 2*S_orders) + O
minutes = calls / 120
```

Ejemplo práctico. Una cuenta tiene 2,400 artículos, 9,000 unidades y 3,000 pedidos, y sus registros se remontan a 24 meses, así que cada lista necesita 24 ventanas de 30 días. Supón que 1 ventana de artículos desborda (el mes en que se importó el catálogo), 2 de unidades y 3 de pedidos. Son 1 + (24 + 2) + (24 + 4) + (24 + 6) + 3,000 = 3,085 llamadas, o 25.7 minutos a los 120 por minuto completos. Dejando una quinta parte de la cuota a otras integraciones (96 por minuto), tarda 32.1 minutos. Dominan las líneas: 3,000 de las 3,085 llamadas.

En régimen estable, una pasada cuesta una llamada por la hora más una por cada lista, es decir, 4 por pasada; una pasada cada hora son 96 llamadas al día. Suma una llamada de líneas por cada pedido que vuelvas a leer. Si 120 pedidos cambian en un día y vuelves a leer una vez al día los 450 que tienes como abiertos, el día cuesta 96 + 120 + 450 = 666 llamadas, 5.6 minutos de cuota de los 1,440 minutos que tiene un día (0.4%). El barrido semanal cuesta aproximadamente las mismas llamadas de lista que la carga (84 en el ejemplo) más un GET por cada id que falte.

Reparte la carga inicial: ejecútala en una hora tranquila y deja que el cliente espere cuando reciba [`rate_limited`](https://docs.filmo.io/es/rest-api/errors/rate_limited).

## Gestión de errores

| Respuesta | Qué significa aquí | Qué hacer |
| --- | --- | --- |
| 401 `api_key_owner_inactive` o `api_key_owner_not_admin` | La persona que creó la clave se fue o dejó de ser administradora. | Detén el trabajo, avisa a alguien y vuelve a emitir la clave desde una cuenta que siga siendo administradora. |
| 403 `insufficient_scope` | A la clave le falta un scope; `requiredScope` lo nombra. | Vuelve a emitir la clave con `items:read` y `orders:read`. |
| 404 `not_found` | En un pedido: archivado o eliminado. En un artículo o una unidad: eliminado. También fuera de tu cuenta. | Marca el registro y confírmalo en el barrido semanal. No elimines por tu parte al primer 404 de una lectura de listado. |
| 429 `rate_limited` | Gastaste las 120 peticiones del minuto. | Espera `Retry-After` segundos y continúa con la misma ventana. |
| 400 `validation_error` | Un parámetro tiene un formato incorrecto; `errors[]` los lista uno a uno. | Corrige la petición. `errors[]` indica el campo. Comprueba por tu parte que `updatedFrom` sea anterior a `updatedBefore`: no está documentado que el listado rechace una ventana invertida. |
| 500, 502 o 504 | Un fallo transitorio. Las lecturas se pueden repetir sin riesgo. | Reintenta la misma ventana con espera exponencial. No muevas la marca de agua. |

## Siguientes pasos

- [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data): el algoritmo, las pruebas y las listas que ocultan registros por defecto.
- [Filtrado y orden](https://docs.filmo.io/es/rest-api/guides/filtering-and-sorting) y [Paginación](https://docs.filmo.io/es/rest-api/guides/pagination): los filtros `updated*` y `created*` y por qué el cursor es un desplazamiento.
- [Límites de peticiones](https://docs.filmo.io/es/rest-api/guides/rate-limits): las cabeceras que te dicen cuánta cuota queda.
- [De la disponibilidad al pedido reservado](https://docs.filmo.io/es/rest-api/tutorials/availability-to-reserved-order): responde a «¿está libre en estas fechas?» para un sitio de reservas.
- [Pasar a producción](https://docs.filmo.io/es/rest-api/guides/going-live): una lista de comprobación antes de que la sincronización se ejecute en producción.

---

URL canónica: https://docs.filmo.io/es/rest-api/tutorials/inventory-sync  
Última actualización: 2026-10-09