Tutoriales
Replicar la agenda en un calendario
La agenda llega como una instantánea de una ventana de fechas, así que un espejo de calendario es un bucle que lee la ventana, la compara con lo que tienes y envía solo los cambios. Las respuestas van en UTC; la zona horaria del negocio sale de la cuenta.
A diferencia de las listas de Sincronización de inventario, la agenda no se pagina y está acotada por fechas. Eso elimina los problemas de cursor de Sincronizar datos: lees una ventana y la comparas con lo que tienes. Pero no es una instantánea completa. Cada lectura devuelve como máximo 200 proyectos, 200 pedidos y 500 tareas, por orden de fecha de inicio, y no avisa cuando corta una lista; además, deja fuera los registros terminados y cancelados. Por eso, un registro que falta en una lectura no se ha eliminado necesariamente. El bucle de abajo lee ventanas cortas, divide una ventana cuya capa vuelve llena y confirma cada eliminación con la lectura del propio registro. Como comparas ventanas y no valores de updatedAt, las salvedades de esa guía sobre la marca de actualización (un cambio en un hijo que no mueve a su padre, una marca un segundo anterior) no afectan a este bucle.
Lo que lees
Enlace a esta sección: Lo que lees| Datos | Operación | Notas |
|---|---|---|
| Proyectos, pedidos, tareas y servicios internos en una ventana | GET /api | Sin paginación. layers elige cuáles de las cinco capas obtener; por defecto son todas. Admite ETag y If-None-Match. |
| Servicios de personal y de flota por cubrir, con quién los cubre | GET /api | Un servicio llega como varias filas que comparten el mismo orderServiceId, el identificador que se asigna: agrúpalas (paso 3). startDate y endDate son obligatorios. Sin paginación. |
| Quién puede cubrirlas | GET /api | Personal, vehículos o ambos, con resourceType obligatorio. Sin paginación. |
| Ausencias de personal, vehículos y espacios | GET /api | resourceType es obligatorio; se pagina con su propio cursor de fechas. |
| Tareas, con recurrencia y documento relacionado | GET /api | Paginado; tiene from y to para la ventana de solapamiento y los filtros updated*. |
| Si el personal está libre en una ventana | POST /api | Comparte las 30 peticiones por minuto de las consultas de disponibilidad. |
Requisitos previos
Enlace a esta sección: Requisitos previos- Una clave de API con
account:read,schedule:read,planner:read,availability:readytasks:read. Para la comprobación opcional de disponibilidad del personal añadestaff:read. Crea la clave en los ajustes de API y MCP de la app (Ajustes → API y MCP); solo pueden hacerlo los administradores. - Node 22 o posterior, o Python 3 con
requests, y la clave en la variable de entornoFILMO_API_KEY. - Un calendario en el que puedes escribir. El bucle de abajo devuelve los eventos que hay que crear, cambiar y eliminar; enviarlos a la API de tu calendario te corresponde a ti.
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. Espera Retry-After segundos cuando recibe rate_limited.
# 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 bodyLee la cuenta con GET /api y conserva timezone: la necesitas para mostrar horas a las personas y para construir desplazamientos en las escrituras.
# 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. Leer la agenda de una ventana
Enlace a esta sección: Paso 2. Leer la agenda de una ventanaGET /api toma startDate y endDate como instantes con desplazamiento, y devuelve cinco arrays: projects, orders, tasks, internalServices y orderServices. Los asignados salen como un id y un nombre; sus datos de contacto no. La especificación no indica una ventana máxima, así que pide lo que muestra un calendario: una semana atrás y dos meses por delante es de sobra. Pide la misma ventana en cada consulta periódica, por ejemplo alineada a medianoche UTC como se hace más abajo; así puedes devolver el ETag en If-None-Match y una respuesta 304 te dice que nada cambió. Un 304 no tiene cuerpo, pero cuenta igualmente para tus 120 peticiones por minuto. La lectura tiene un tope, y la especificación no lo dice (solo dice que la lectura "está acotada por fechas"): devuelve como máximo 200 proyectos, 200 pedidos y 500 tareas, y una capa que vuelve exactamente llena puede haberse cortado. También muestra solo algunos estados: pedidos desde draft hasta checked_in_partially (no checked_in, cancelled ni archived) y proyectos que están en draft, budgeted, confirmed o in_progress. Un pedido totalmente recepcionado o cancelado sale del calendario por sí solo. Mantén la ventana corta y divídela cuando una capa vuelva llena, como hace el bucle del paso 7.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
# A fixed window, as instants with an offset. Keep the window the same between polls.
curl -sS -i -G "$FILMO_URL/api/v1/schedule" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "startDate=2026-10-02T00:00:00Z" \
--data-urlencode "endDate=2026-12-08T00:00:00Z" \
--data-urlencode "layers=projects,orders,tasks,internal_services"
# Repeat it with the ETag you received. 304 means nothing changed and the copy you hold is current.
curl -sS -i -G "$FILMO_URL/api/v1/schedule" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "If-None-Match: $ETAG" \
--data-urlencode "startDate=2026-10-02T00:00:00Z" \
--data-urlencode "endDate=2026-12-08T00:00:00Z" \
--data-urlencode "layers=projects,orders,tasks,internal_services"import { get } from "./filmo.mjs";
const DAY = 24 * 3600 * 1000;
const today = Math.floor(Date.now() / DAY) * DAY; // midnight UTC, so the window repeats all day
const query = {
startDate: new Date(today - 7 * DAY).toISOString(),
endDate: new Date(today + 60 * DAY).toISOString(),
layers: "projects,orders,tasks,internal_services",
};
const first = await get("/schedule", query);
console.log(first.data.tasks.length + " tasks, ETag " + first.etag);
const again = await get("/schedule", query, { "If-None-Match": first.etag });
console.log(again.status); // 304: no body, nothing changedimport time
from datetime import datetime, timedelta, timezone
from filmo import get
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) # the window repeats all day
query = {
"startDate": (today - timedelta(days=7)).isoformat().replace("+00:00", "Z"),
"endDate": (today + timedelta(days=60)).isoformat().replace("+00:00", "Z"),
"layers": "projects,orders,tasks,internal_services",
}
first = get("/schedule", **query)
print(len(first["data"]["tasks"]), "tasks, ETag", first["etag"])
again = get("/schedule", headers={"If-None-Match": first["etag"]}, **query)
print(again["status"]) # 304: no body, nothing changedLa capa orderServices no tiene id propio. Este tutorial la deja fuera de la lectura de la agenda con layers y lee los mismos servicios de GET /api, que sí lo lleva. Las entradas de tasks tienen un startDate que admite null; salta las que no tienen inicio, como hace el bucle.
Paso 3. Leer lo que queda sin cubrir y quién puede cubrirlo
Enlace a esta sección: Paso 3. Leer lo que queda sin cubrir y quién puede cubrirloGET /api es la vista del planificador de la misma ventana: los servicios de personal y de flota de los pedidos. Un servicio no llega como una sola fila. Llega como una fila con rowKind summary, con staffMemberId y vehicleId siempre null, y una fila con detail por cada persona o vehículo asignado, todas con el mismo orderServiceId. Agrupa las filas por ese identificador: las personas y los vehículos son las filas detail, y un servicio con solo su fila summary está sin cubrir. Un null en la fila summary no significa sin cubrir. La lista también trae los servicios internos, con un orderId vacío; la agenda los devuelve también, así que omite esas filas o reflejarás cada uno dos veces. Por defecto, y con orderStatus all, obtienes los pedidos en cualquier estado salvo cancelado y archivado, no solo los borradores y los reservados; draft o reserved lo restringen. serviceType (crew, fleet o both) vale both por defecto. GET /api lista las personas y los vehículos que se pueden asignar. Ninguna de las dos pagina.
# What has to be covered in the window. staffMemberId and vehicleId at null mean uncovered.
curl -sS -G "$FILMO_URL/api/v1/planner/services" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "startDate=2026-10-02T00:00:00Z" \
--data-urlencode "endDate=2026-12-08T00:00:00Z" \
--data-urlencode "orderStatus=all"
# With whom: the staff and the fleet that can cover it.
curl -sS -G "$FILMO_URL/api/v1/planner/resources" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "resourceType=both"import { get } from "./filmo.mjs";
const DAY = 24 * 3600 * 1000;
const today = Math.floor(Date.now() / DAY) * DAY; // midnight UTC
const startDate = new Date(today - 7 * DAY).toISOString();
const endDate = new Date(today + 60 * DAY).toISOString();
// A service comes as several rows that share an orderServiceId: one with rowKind "summary" (the
// service itself, staffMemberId and vehicleId at null) and one "detail" row for each person or
// vehicle assigned. Rows with an empty orderId are internal services, which the schedule already
// returns: skip them.
function groupServices(rows) {
const byService = new Map();
for (const row of rows) {
if (row.orderId === "") continue;
const service = byService.get(row.orderServiceId) ?? { staff: [], vehicles: [] };
if (row.rowKind === "summary") Object.assign(service, row); // the service itself
else {
if (row.staffMemberId) service.staff.push(row.staffMemberId);
if (row.vehicleId) service.vehicles.push(row.vehicleId);
}
byService.set(row.orderServiceId, service);
}
return [...byService.values()];
}
const services = await get("/planner/services", { startDate, endDate, orderStatus: "all" });
const grouped = groupServices(services.data);
const uncovered = grouped.filter((s) => s.staff.length === 0 && s.vehicles.length === 0);
console.log(uncovered.length + " of " + grouped.length + " services without a staff member or a vehicle");
const resources = await get("/planner/resources", { resourceType: "both" });
for (const r of resources.data) console.log(r.type, r.id, r.name);from datetime import datetime, timedelta, timezone
from filmo import get
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
start_date = (today - timedelta(days=7)).isoformat().replace("+00:00", "Z")
end_date = (today + timedelta(days=60)).isoformat().replace("+00:00", "Z")
def group_services(rows):
"""A service comes as several rows that share an orderServiceId: one with rowKind "summary" (the
service itself, staffMemberId and vehicleId at null) and one "detail" row for each person or
vehicle assigned. Rows with an empty orderId are internal services, which the schedule already
returns: skip them."""
by_service = {}
for row in rows:
if row["orderId"] == "":
continue
service = by_service.setdefault(row["orderServiceId"], {"staff": [], "vehicles": []})
if row["rowKind"] == "summary":
service.update(row) # the service itself
else:
if row["staffMemberId"]:
service["staff"].append(row["staffMemberId"])
if row["vehicleId"]:
service["vehicles"].append(row["vehicleId"])
return list(by_service.values())
services = get("/planner/services", startDate=start_date, endDate=end_date, orderStatus="all")["data"]
grouped = group_services(services)
uncovered = [s for s in grouped if not s["staff"] and not s["vehicles"]]
print(len(uncovered), "of", len(grouped), "services without a staff member or a vehicle")
for r in get("/planner/resources", resourceType="both")["data"]:
print(r["type"], r["id"], r["name"])Paso 4. Leer las ausencias
Enlace a esta sección: Paso 4. Leer las ausenciasGET /api devuelve las ventanas en las que una persona, un vehículo o un espacio no está disponible. resourceType es obligatorio, así que lee los tres tipos uno tras otro. Para las personas, absenceType es null porque serían datos de salud, y el motivo nunca se devuelve. La lista devuelve solo las ausencias vigentes o futuras, así que state es en la práctica siempre unavailable, y una ausencia que ha terminado sale de la lista por sí sola: no lo tomes por una eliminación. Esta lista no forma parte de la cuota de disponibilidad; cuenta para las 120 por minuto como la agenda.
# resourceType is required: staff, vehicle or space. Repeat the call for each one.
curl -sS -G "$FILMO_URL/api/v1/availability/unavailabilities" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "resourceType=staff" \
--data-urlencode "startDate=2026-10-02T00:00:00Z" \
--data-urlencode "endDate=2026-12-08T00:00:00Z" \
--data-urlencode "limit=500"// unavailabilities.mjs
import { get } from "./filmo.mjs";
// Follows the list's own date cursor until it ends.
export async function listUnavailabilities(resourceType, startDate, endDate) {
const found = [];
let cursor;
do {
const page = await get("/availability/unavailabilities", {
resourceType,
startDate,
endDate,
limit: 500,
...(cursor && { cursor }),
});
found.push(...page.data);
cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined;
} while (cursor);
return found;
}
// Use: await listUnavailabilities("staff", startDate, endDate), then "vehicle" and "space".# unavailabilities.py
from filmo import get
def list_unavailabilities(resource_type, start_date, end_date):
"""Follows the list's own date cursor until it ends."""
found, cursor = [], None
while True:
params = {"resourceType": resource_type, "startDate": start_date, "endDate": end_date, "limit": 500}
if cursor:
params["cursor"] = cursor
page = get("/availability/unavailabilities", **params)
found += page["data"]
if not page["pagination"]["hasMore"]:
return found
cursor = page["pagination"]["nextCursor"]
# Use: list_unavailabilities("staff", start_date, end_date), then "vehicle" and "space".Paso 5. Leer el detalle de las tareas cuando lo necesites
Enlace a esta sección: Paso 5. Leer el detalle de las tareas cuando lo necesitesLas entradas de tareas de la agenda son compactas. Cuando tu calendario necesite la descripción, el patrón de recurrencia, el documento relacionado o la apariencia, lee las tareas con GET /api. from y to devuelven las tareas que se solapan con la ventana, así que se incluye una que empieza antes y termina dentro de ella; assignedStaffIds y isCompleted la acotan. Una tarea recurrente muestra su recurrencePattern; la lista devuelve la tarea almacenada, no cada repetición.
# Tasks that overlap [from, to], with the fields the schedule leaves out.
curl -sS -G "$FILMO_URL/api/v1/tasks" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "from=2026-10-02T00:00:00Z" \
--data-urlencode "to=2026-12-08T00:00:00Z" \
--data-urlencode "sort=startDate" \
--data-urlencode "limit=500"import { get } from "./filmo.mjs";
const DAY = 24 * 3600 * 1000;
const today = Math.floor(Date.now() / DAY) * DAY; // midnight UTC
const from = new Date(today - 7 * DAY).toISOString();
const to = new Date(today + 60 * DAY).toISOString();
const tasks = await get("/tasks", { from, to, sort: "startDate", limit: 500 });
for (const t of tasks.data) {
console.log(t.title, t.isRecurring, t.recurrencePattern?.frequency, t.relatedDocumentType, t.description);
}
console.log(tasks.pagination.hasMore ? "more pages: follow the cursor" : "complete");from datetime import datetime, timedelta, timezone
from filmo import get
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
start = (today - timedelta(days=7)).isoformat().replace("+00:00", "Z")
end = (today + timedelta(days=60)).isoformat().replace("+00:00", "Z")
tasks = get("/tasks", **{"from": start, "to": end, "sort": "startDate", "limit": 500})
for t in tasks["data"]:
pattern = t["recurrencePattern"] or {}
print(t["title"], t["isRecurring"], pattern.get("frequency"), t["relatedDocumentType"], t["description"])
print("more pages: follow the cursor" if tasks["pagination"]["hasMore"] else "complete")Paso 6. Comprobar la disponibilidad del personal (opcional)
Enlace a esta sección: Paso 6. Comprobar la disponibilidad del personal (opcional)Para preguntar si unas personas concretas están libres en una ventana, usa POST /api con hasta 100 staffIds. Es un POST por el cuerpo estructurado, pero solo lee, así que no lleva Idempotency-Key. Cada entrada te dice si la persona está available y qué compromisos la ocupan, con tipo order, internal_service, unavailability o incident. Un id que no existe vuelve como un error por elemento con el código not_found, no como un fallo de toda la llamada.
# Shares the 30 requests per minute of the availability queries. Up to 100 staffIds per call.
curl -sS -X POST "$FILMO_URL/api/v1/staff/availability" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"startDate": "2026-10-14T08:00:00+02:00",
"endDate": "2026-10-14T20:00:00+02:00",
"staffIds": ["'"$STAFF_ID"'"]
}'import { API } from "./filmo.mjs";
const res = await fetch(API + "/staff/availability", {
method: "POST",
headers: {
Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "),
"Content-Type": "application/json",
},
body: JSON.stringify({
startDate: "2026-10-14T08:00:00+02:00",
endDate: "2026-10-14T20:00:00+02:00",
staffIds: [process.env.STAFF_ID],
}),
});
if (res.status === 429) {
console.log("Availability quota spent; retry in " + res.headers.get("Retry-After") + " s");
} else {
const { data } = await res.json();
for (const entry of data) {
if (entry.error) console.log(entry.resourceId, entry.error.code);
else console.log(entry.name, entry.available, entry.commitments.map((c) => c.kind));
}
}import os
from filmo import API, session
res = session.post(
API + "/staff/availability",
json={
"startDate": "2026-10-14T08:00:00+02:00",
"endDate": "2026-10-14T20:00:00+02:00",
"staffIds": [os.environ["STAFF_ID"]],
},
)
if res.status_code == 429:
print("Availability quota spent; retry in", res.headers["Retry-After"], "s")
else:
res.raise_for_status()
for entry in res.json()["data"]:
if "error" in entry:
print(entry["resourceId"], entry["error"]["code"])
else:
print(entry["name"], entry["available"], [c["kind"] for c in entry["commitments"]])La cuota de disponibilidad es de 30 por minuto
POST /api, POST /api y POST /api comparten 30 peticiones por minuto, además de las 120 por minuto de toda la clave. No las llames una vez por celda del calendario ni por espectador. Agrupa hasta 100 personas por llamada, guarda la respuesta en caché durante un tiempo corto y prefiere los compromisos que ya reflejas desde la agenda; pide la operación de disponibilidad solo para la pregunta que el espejo no puede responder. Un 429 indica cuánto esperar en Retry-After.
Paso 7. Ejecutar el bucle de réplica
Enlace a esta sección: Paso 7. Ejecutar el bucle de réplicaCada cinco minutos, lee las capas de la agenda, los servicios del planificador y las tres listas de ausencias para la misma ventana. Convierte cada elemento en un evento identificado por capa e id, compáralo con la copia que tienes y devuelve qué crear, cambiar y eliminar. Un evento que tienes, que se solapa con la ventana y falta en la nueva lectura, es solo una sospecha: la lectura tiene un tope y deja fuera los registros finalizados y cancelados, y una ausencia que ha terminado sale de su lista. Confírmalo con la lectura del propio registro (GET /api, GET /api o GET /api) antes de eliminarlo: un 404, o un pedido o proyecto en un estado que la agenda ya no muestra, significa eliminar. Cuando una capa puede haberse cortado, el bucle no decide ninguna eliminación en ese ciclo. Un evento que simplemente terminó antes del inicio de la ventana se deja como está. El bucle guarda su estado en un archivo aquí; usa tu base de datos.
// mirror.mjs: one cycle of the calendar mirror. Run it every 5 minutes.
import { readFile, writeFile } from "node:fs/promises";
import { get } from "./filmo.mjs";
import { listUnavailabilities } from "./unavailabilities.mjs";
const STATE = "schedule-mirror.json";
const DAY = 24 * 3600 * 1000;
const LAYERS = ["projects", "orders", "tasks", "internalServices"];
const iso = (ms) => new Date(ms).toISOString();
// The schedule returns at most this many records per layer and says nothing when it cuts the list.
const CAP = { projects: 200, orders: 200, tasks: 500 };
// It also leaves out finished and cancelled records: these are the states it still shows.
const SCHEDULED = {
order: new Set(["draft", "reserved", "prepared_partially", "prepared", "picked_up_partially", "picked_up", "returned_partially", "returned", "checked_in_partially"]),
project: new Set(["draft", "budgeted", "confirmed", "in_progress"]),
};
// A layer that comes back exactly full may have been cut: read the window again in halves.
async function readSchedule(from, to) {
const { data } = await get("/schedule", {
startDate: iso(from), endDate: iso(to), layers: "projects,orders,tasks,internal_services",
});
const full = Object.entries(CAP).some(([layer, cap]) => data[layer].length >= cap);
if (!full) return { data, complete: true };
if (to - from <= DAY) return { data, complete: false }; // a single day is still cut
const middle = from + Math.floor((to - from) / 2);
const [a, b] = [await readSchedule(from, middle), await readSchedule(middle, to)];
const merged = {};
for (const layer of LAYERS) {
merged[layer] = [...new Map([...a.data[layer], ...b.data[layer]].map((x) => [x.id, x])).values()];
}
return { data: merged, complete: a.complete && b.complete };
}
// A service comes as one "summary" row plus one "detail" row per person or vehicle assigned,
// all with the same orderServiceId. Internal services (empty orderId) are already in the schedule.
function groupServices(rows) {
const byService = new Map();
for (const row of rows) {
if (row.orderId === "") continue;
const service = byService.get(row.orderServiceId) ?? { staff: [], vehicles: [] };
if (row.rowKind === "summary") Object.assign(service, row); // the service itself
else {
if (row.staffMemberId) service.staff.push(row.staffMemberId);
if (row.vehicleId) service.vehicles.push(row.vehicleId);
}
byService.set(row.orderServiceId, service);
}
return [...byService.values()];
}
// One item of each layer becomes one calendar event, keyed by layer and id.
function toEvents(schedule, services, absences) {
const events = new Map();
for (const p of schedule.projects) {
events.set("project:" + p.id, { title: p.name, start: p.startDate, end: p.endDate });
}
for (const o of schedule.orders) {
events.set("order:" + o.id, { title: "Order " + o.orderNumber, start: o.startDate, end: o.endDate });
}
for (const t of schedule.tasks) {
if (!t.startDate) continue;
events.set("task:" + t.id, {
title: t.title, start: t.startDate, end: t.endDate, allDay: t.isAllDay,
done: t.isCompleted, staff: t.assignedStaff.map((s) => s.id),
});
}
for (const s of schedule.internalServices) {
events.set("internal:" + s.id, {
title: s.title, start: s.startDate, end: s.endDate, staff: s.assignedStaff.map((m) => m.id),
});
}
for (const s of services) {
events.set("service:" + s.orderServiceId, {
title: s.serviceName + " (" + s.orderNumber + ")", start: s.startDate, end: s.endDate,
staff: s.staff, vehicles: s.vehicles,
});
}
for (const a of absences) {
events.set("absence:" + a.id, {
title: (a.resourceName ?? a.resourceId) + " " + a.state, start: a.startDate, end: a.endDate,
});
}
return events;
}
// Missing from a read is only a suspicion. Confirm it with the record's own read.
async function isGone(key, held) {
const [kind, id] = key.split(":");
// The list returns only the absences in force or still to come: one that ended drops out by itself.
if (kind === "absence") return held.end > new Date().toISOString();
const path = { project: "/projects/", order: "/orders/", task: "/tasks/" }[kind];
if (!path) return true; // internal and planner services are not capped: absent means removed
try {
const { data } = await get(path + id);
if (SCHEDULED[kind]) return !SCHEDULED[kind].has(data.status); // finished or cancelled: it left the schedule
return false; // a task you can still read is still there
} catch (error) {
if (error.status === 404) return true; // deleted, or an archived order
throw error; // a failed read removes nothing
}
}
export async function cycle() {
const state = JSON.parse(await readFile(STATE, "utf8").catch(() => "{}"));
const today = Math.floor(Date.now() / DAY) * DAY;
const from = today - 7 * DAY;
const to = today + 60 * DAY;
const startDate = iso(from);
const endDate = iso(to);
const schedule = await readSchedule(from, to);
const services = await get("/planner/services", { startDate, endDate, orderStatus: "all" });
const absences = [];
for (const type of ["staff", "vehicle", "space"]) {
absences.push(...(await listUnavailabilities(type, startDate, endDate)));
}
const next = toEvents(schedule.data, groupServices(services.data), absences);
const known = state.events ?? {};
const upsert = [];
const remove = [];
for (const [id, event] of next) {
const json = JSON.stringify(event);
if (known[id]?.json !== json) upsert.push({ id, ...event });
known[id] = { json, start: event.start, end: event.end ?? event.start };
}
// Only judge events that overlap this window; the rest simply scrolled out of it. When a layer
// may have been cut, judge none: a record that did not fit looks exactly like a removed one.
if (schedule.complete) {
for (const [id, held] of Object.entries(known)) {
if (!next.has(id) && held.end >= startDate && held.start < endDate && (await isGone(id, held))) {
remove.push(id);
delete known[id];
}
}
}
await writeFile(STATE, JSON.stringify({ events: known }));
return { upsert, remove }; // send these to your calendar's own API
}
console.log(await cycle());# mirror.py: one cycle of the calendar mirror. Run it every 5 minutes.
import json
from datetime import datetime, timedelta, timezone
import requests
from filmo import get
from unavailabilities import list_unavailabilities
STATE = "schedule-mirror.json"
LAYERS = ("projects", "orders", "tasks", "internalServices")
# The schedule returns at most this many records per layer and says nothing when it cuts the list.
CAP = {"projects": 200, "orders": 200, "tasks": 500}
# It also leaves out finished and cancelled records: these are the states it still shows.
SCHEDULED = {
"order": {"draft", "reserved", "prepared_partially", "prepared", "picked_up_partially", "picked_up",
"returned_partially", "returned", "checked_in_partially"},
"project": {"draft", "budgeted", "confirmed", "in_progress"},
}
def iso(moment):
return moment.isoformat().replace("+00:00", "Z")
def read_schedule(start, end):
"""A layer that comes back exactly full may have been cut: read the window again in halves."""
data = get("/schedule", startDate=iso(start), endDate=iso(end), layers="projects,orders,tasks,internal_services")["data"]
if not any(len(data[layer]) >= cap for layer, cap in CAP.items()):
return data, True
if end - start <= timedelta(days=1):
return data, False # a single day is still cut
middle = start + (end - start) / 2
first, first_ok = read_schedule(start, middle)
second, second_ok = read_schedule(middle, end)
merged = {layer: list({x["id"]: x for x in first[layer] + second[layer]}.values()) for layer in LAYERS}
return merged, first_ok and second_ok
def group_services(rows):
"""A service comes as one "summary" row plus one "detail" row per person or vehicle assigned,
all with the same orderServiceId. Internal services (empty orderId) are already in the schedule."""
by_service = {}
for row in rows:
if row["orderId"] == "":
continue
service = by_service.setdefault(row["orderServiceId"], {"staff": [], "vehicles": []})
if row["rowKind"] == "summary":
service.update(row) # the service itself
else:
if row["staffMemberId"]:
service["staff"].append(row["staffMemberId"])
if row["vehicleId"]:
service["vehicles"].append(row["vehicleId"])
return list(by_service.values())
def to_events(schedule, services, absences):
"""One item of each layer becomes one calendar event, keyed by layer and id."""
events = {}
for p in schedule["projects"]:
events["project:" + p["id"]] = {"title": p["name"], "start": p["startDate"], "end": p["endDate"]}
for o in schedule["orders"]:
events["order:" + o["id"]] = {"title": "Order " + o["orderNumber"], "start": o["startDate"], "end": o["endDate"]}
for t in schedule["tasks"]:
if not t["startDate"]:
continue
events["task:" + t["id"]] = {
"title": t["title"], "start": t["startDate"], "end": t["endDate"], "allDay": t["isAllDay"],
"done": t["isCompleted"], "staff": [s["id"] for s in t["assignedStaff"]],
}
for s in schedule["internalServices"]:
events["internal:" + s["id"]] = {
"title": s["title"], "start": s["startDate"], "end": s["endDate"],
"staff": [m["id"] for m in s["assignedStaff"]],
}
for s in services:
events["service:" + s["orderServiceId"]] = {
"title": s["serviceName"] + " (" + s["orderNumber"] + ")", "start": s["startDate"], "end": s["endDate"],
"staff": s["staff"], "vehicles": s["vehicles"],
}
for a in absences:
events["absence:" + a["id"]] = {
"title": (a["resourceName"] or a["resourceId"]) + " " + a["state"], "start": a["startDate"], "end": a["endDate"],
}
return events
def is_gone(key, held):
"""Missing from a read is only a suspicion. Confirm it with the record's own read."""
kind, record_id = key.split(":", 1)
# The list returns only the absences in force or still to come: one that ended drops out by itself.
if kind == "absence":
return held["end"] > iso(datetime.now(timezone.utc))
path = {"project": "/projects/", "order": "/orders/", "task": "/tasks/"}.get(kind)
if path is None:
return True # internal and planner services are not capped: absent means removed
try:
record = get(path + record_id)["data"]
except requests.HTTPError as error:
if error.response.status_code == 404:
return True # deleted, or an archived order
raise # a failed read removes nothing
if kind in SCHEDULED:
return record["status"] not in SCHEDULED[kind] # finished or cancelled: it left the schedule
return False # a task you can still read is still there
def cycle():
try:
with open(STATE) as f:
known = json.load(f)["events"]
except FileNotFoundError:
known = {}
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
start, end = today - timedelta(days=7), today + timedelta(days=60)
schedule, complete = read_schedule(start, end)
services = get("/planner/services", startDate=iso(start), endDate=iso(end), orderStatus="all")["data"]
absences = []
for kind in ("staff", "vehicle", "space"):
absences += list_unavailabilities(kind, iso(start), iso(end))
nxt = to_events(schedule, group_services(services), absences)
upsert, remove = [], []
for event_id, event in nxt.items():
as_json = json.dumps(event, sort_keys=True)
if known.get(event_id, {}).get("json") != as_json:
upsert.append({"id": event_id, **event})
known[event_id] = {"json": as_json, "start": event["start"], "end": event["end"] or event["start"]}
# Only judge events that overlap this window; the rest simply scrolled out of it. When a layer
# may have been cut, judge none: a record that did not fit looks exactly like a removed one.
if complete:
for event_id, held in list(known.items()):
if event_id not in nxt and held["end"] >= iso(start) and held["start"] < iso(end) and is_gone(event_id, held):
remove.append(event_id)
del known[event_id]
with open(STATE, "w") as f:
json.dump({"events": known}, f)
return {"upsert": upsert, "remove": remove} # send these to your calendar's own API
print(cycle())El bucle importa el lector del paso 4; guarda ese fragmento como unavailabilities. o unavailabilities.. Si tu calendario necesita cambios incrementales de tareas en lugar de instantáneas, GET /api también acepta updatedFrom y updatedBefore; usa la pasada por ventanas de Sincronizar datos, con su margen de seguridad y la deduplicación por id. Las tareas se eliminan de forma definitiva y la lista no tiene un feed de eliminaciones, así que una tarea que tengas y que ya no aparezca hay que confirmarla con su propia lectura.
Zonas horarias
Enlace a esta sección: Zonas horarias- Las respuestas van en UTC: los instantes terminan en
Z. Guárdalos como instantes, no como cadenas locales. - La zona horaria del negocio es
timezone, un nombre IANA comoEurope. Es la zona en la que se lee una fecha sin hora (/Madrid 2026-10-14) cuando un filtro admite una, por ejemplodueFrom. - La agenda, el planificador, las indisponibilidades y los
fromytode las tareas necesitan instantes con desplazamiento. Constrúyelos a partir de la zona horaria del negocio, para que el límite de un día siga siendo correcto cuando cambia la hora. - Las escrituras que añadas más adelante también necesitan un desplazamiento: crear una tarea o declarar una indisponibilidad toma un instante ISO 8601 con desplazamiento, no una fecha sin hora. Incluye el desplazamiento de la zona horaria del negocio en esa fecha, que puede ser distinto en verano y en invierno. Otras pocas escrituras toman una fecha de calendario por diseño, como
invoiceDateydueDateenPUT /api./v1 /invoices /external /{originSystem} /{originExternalId} - Una tarea con
isAllDayactivado es una tarea de todo el día. Muéstrala en la fecha que tiene en la zona horaria del negocio en lugar de convertir el instante a la zona de quien la ve.
Las reglas sobre fechas, desplazamientos y la zona horaria del negocio están en Fechas y horas.
import { get } from "./filmo.mjs";
const { data: account } = await get("/account");
const instant = "2026-10-14T07:00:00Z"; // responses carry UTC
// Show it in the business timezone, whatever the server or the viewer is set to.
console.log(
new Date(instant).toLocaleString("en-GB", { timeZone: account.timezone, dateStyle: "medium", timeStyle: "short" }),
);
// A write needs an offset. Build it from the business timezone, never from your server's.
// "2026-10-14T09:00:00+02:00" is the same instant as the one above in a UTC+2 timezone.from datetime import datetime
from zoneinfo import ZoneInfo
from filmo import get
account = get("/account")["data"]
instant = datetime.fromisoformat("2026-10-14T07:00:00+00:00") # responses carry UTC
# Show it in the business timezone, whatever the server or the viewer is set to.
print(instant.astimezone(ZoneInfo(account["timezone"])).strftime("%d %b %Y %H:%M"))
# A write needs an offset. Build it from the business timezone, never from your server's.
local = datetime(2026, 10, 14, 9, 0, tzinfo=ZoneInfo(account["timezone"]))
print(local.isoformat()) # for example 2026-10-14T09:00:00+02:00Qué guardar por tu parte
Enlace a esta sección: Qué guardar por tu parte| Guardar | Por qué |
|---|---|
Un mapa de la clave del evento (layer:id) al id de tu evento de calendario | Para actualizar o eliminar el evento correcto; los ids de Filmo son opacos. |
| La última versión que enviaste de cada evento (un hash o el JSON) | El bucle lo compara para enviar solo los cambios. |
| El inicio y el fin de cada evento | Para distinguir un evento eliminado de uno que ha salido de la ventana. |
| La zona horaria de la cuenta | Para mostrar horas y construir desplazamientos en las escrituras. |
Opcionalmente, el último ETag y su ventana | Un 304 evita el trabajo de analizar y comparar. |
Presupuesto de llamadas
Enlace a esta sección: Presupuesto de llamadasUn ciclo lee la agenda (1 llamada), los servicios del planificador (1) y los tres tipos de ausencia (1 cada uno, más si uno devuelve más de 500 ventanas), así que unas 5 llamadas, más las lecturas extra cuando se divide una ventana y una lectura por cada registro ausente que confirmes. La tasa son las llamadas por ciclo divididas entre los minutos entre ciclos:
calls per minute = (1 + 1 + 3 + extra pages) / minutes between cyclesA 5 llamadas cada 5 minutos eso es 1 llamada por minuto, el 0.8% de las 120 por minuto que permite tu clave. Leer las tareas añade una llamada por página. La disponibilidad del personal es la única que además cuenta para las 30 por minuto de las consultas de disponibilidad: déjala para preguntas bajo demanda y sácala del bucle.
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 bucle, avisa a alguien y vuelve a emitir la clave desde una cuenta de administrador. |
403 insufficient_scope | A la clave le falta un scope; requiredScope lo nombra. Cada una de las lecturas anteriores necesita el suyo. | Vuelve a emitir la clave con los permisos (scopes) de los requisitos previos. |
400 validation_error | Una fecha no tiene desplazamiento, falta un parámetro obligatorio (como resourceType) o, en las llamadas de disponibilidad, el final no es posterior al inicio. | Corrige la petición; errors[] lista cada campo. |
404 not_found | En la llamada de disponibilidad del personal: solo un id desconocido dentro de data, como error por elemento. En el resto, un recurso fuera de tu cuenta. | Quita el id de lo que pides. |
429 rate_limited | Los 120 por minuto de la clave, o los 30 por minuto de las consultas de disponibilidad. | Espera Retry-After segundos (también retryAfter en el cuerpo del problema). RateLimit-* indica qué cuota se agotó. |
500 internal_error, 502 o 504 | Un fallo transitorio. Las lecturas se pueden repetir sin riesgo. | Conserva el espejo anterior sin cambios y ejecuta el siguiente ciclo. No elimines eventos tras una lectura fallida. |
503 service_unavailable | Solo las escrituras de indisponibilidades de espacios lo documentan, y este tutorial solo lee. Filmo controla el interruptor que hay detrás. | No lo reintentes como un fallo transitorio. Omite la llamada y contacta con el soporte de Filmo con el requestId. |
Siguientes pasos
Enlace a esta sección: Siguientes pasos- Fechas y horas: desplazamientos, UTC, fechas sin hora y la zona horaria del negocio.
- Sincronizar datos: el recorrido por ventanas, si necesitas los cambios de las tareas en lugar de instantáneas.
- Límites de peticiones: las cuotas global y de disponibilidad, y las cabeceras.
- Sincronización de inventario: el mismo bucle para artículos, unidades y líneas de pedido.
- De la disponibilidad al pedido reservado: la cuota de 30 por minuto en un sitio de reservas.
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ó.
Para saber más
Fechas y horas
Las respuestas dan las fechas en UTC. Cuando escribes, envía un instante ISO 8601 con desplazamiento. Una fecha sin hora como
2026-10-17se lee en la zona horaria del negocio de la cuenta cuando está en un filtro; una escritura solo la admite en un campo que sea una fecha simple.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
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.
¿Te ha resultado útil esta página?