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; lee primero esa guía y usa esta página como su ejemplo práctico.
Lo que lees
Enlace a esta sección: Lo que lees| Datos | Operación | Cómo la mantienes al día |
|---|---|---|
| Artículos del catálogo | GET /api | Pasada por ventanas sobre updatedAt; una lectura de detalle con GET /api para los campos que la lista omite |
| Unidades físicas | GET /api | 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 | Pasada por ventanas sobre updatedAt |
| Líneas de pedido | GET /api | 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 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, que tiene su propia cuota de 30 peticiones por minuto; consulta De la disponibilidad al pedido reservado.
Requisitos previos
Enlace a esta sección: Requisitos previos- Una clave de API con
account:read,items:readyorders: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 entornoFILMO_API_KEY. - La idea de la pasada por ventanas de Sincronizar datos: pide una ventana semiabierta de
updatedAt, una página, y divide la ventana sipagination.eshasMore true. Nunca sigas el cursor para decidir qué ha cambiado.
Paso 1. Configurar el cliente y leer la cuenta
Enlace a esta sección: Paso 1. Configurar el cliente y leer la cuentaGuarda el cliente una vez; todos los fragmentos posteriores lo importan. Aguarda una respuesta 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.
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
// 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
};
}
}# 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 bodyDespués lee la cuenta con GET /api. timezone es la zona horaria del negocio y rateLimits informa de las cuotas de tu clave; la global es de 120 peticiones por minuto.
# 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"import { get } from "./filmo.mjs";
const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);from filmo import get
account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])Paso 2. Cargar el catálogo
Enlace a esta sección: Paso 2. Cargar el catálogoLa 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 y updatedBefore, ordenando con sort updatedAt y con limit 500. Cada ventana es una página; el helper divide por la mitad una ventana que desborda y deduplica por id.
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"// 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;
}# 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 recordsFILMO_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"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");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, sku, ean, stockCalculationMethod (bulk o serialized), collectionId, variants y 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 para los artículos que los necesiten, y calcula una llamada por artículo.
Paso 3. Cargar las unidades físicas
Enlace a esta sección: Paso 3. Cargar las unidades físicasGET /api lista todas las unidades, en todos los estados, a menos que filtres con 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 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.
# 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"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);
}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
Enlace a esta sección: Paso 4. Cargar los pedidos y volver a leer sus líneasLee los pedidos con el mismo ayudante y luego lee las líneas de cada pedido con GET /api. Pasa 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.
# 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"// 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;
}# 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"]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
}
}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
raisePara el inventario, los campos de línea útiles son itemId, variantId, quantity, serials (las unidades concretas asignadas, vacío si aún no hay ninguna), status, addedDuringDelivery, y el periodo en startDate y 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 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
Enlace a esta sección: Paso 5. Mantenerlo al díaCada 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 (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.
// 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
}# 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 readUn pedido archivado responde 404 en su GET y desaparece de la lista; igual que uno eliminado. Cuando 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.
Paso 6. Reconciliar una vez por semana
Enlace a esta sección: Paso 6. Reconciliar una vez por semanaEliminar 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 y 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.
# 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"// 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 };
}# 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 y GET /api se barren del mismo modo; confirma las unidades que faltan con GET /api y los pedidos que faltan con GET /api. GET /api y GET /api devuelven todos los registros que pueden ver. GET /api 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
Enlace a esta sección: 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
Enlace a esta sección: Presupuesto de llamadas frente a 120 peticiones por minutoTu 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):
calls = 1 + (W_items + 2*S_items) + (W_units + 2*S_units) + (W_orders + 2*S_orders) + O
minutes = calls / 120Ejemplo 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.
Gestión de errores
Enlace a esta sección: 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
Enlace a esta sección: Siguientes pasos- Sincronizar datos: el algoritmo, las pruebas y las listas que ocultan registros por defecto.
- Filtrado y orden y Paginación: los filtros
updated*ycreated*y por qué el cursor es un desplazamiento. - Límites de peticiones: las cabeceras que te dicen cuánta cuota queda.
- De la disponibilidad al pedido reservado: responde a «¿está libre en estas fechas?» para un sitio de reservas.
- Pasar a producción: una lista de comprobación antes de que la sincronización se ejecute en producción.
Páginas relacionadas
Enlace a esta sección: Páginas relacionadasGuí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ó.
Guías
Paginación
Las listas devuelven una página cada vez. Pide una página con
limity, mientraspagination.seahasMore true, vuelve a llamar con elnextCursorque te dieron.Guías
Límites de peticiones
Cada clave de API tiene 120 peticiones por minuto, y las tres comprobaciones de disponibilidad comparten su propia cuota de 30 por minuto. Al pasar un límite, la API responde 429 con
Retry-After.Tutoriales
De la comprobación de disponibilidad al pedido reservado
Comprueba el stock desde tu servidor con una caché y lotes que respeten las 30 peticiones por minuto de las operaciones de disponibilidad, crea el pedido con una clave de idempotencia, luego resérvalo y gestiona
insufficient_stock.
¿Te ha resultado útil esta página?