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.

DatosOperaciónNotas
Proyectos, pedidos, tareas y servicios internos en una ventanaGET /api/v1/scheduleSin 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 cubreGET /api/v1/planner/servicesUn 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 cubrirlasGET /api/v1/planner/resourcesPersonal, vehículos o ambos, con resourceType obligatorio. Sin paginación.
Ausencias de personal, vehículos y espaciosGET /api/v1/availability/unavailabilitiesresourceType es obligatorio; se pagina con su propio cursor de fechas.
Tareas, con recurrencia y documento relacionadoGET /api/v1/tasksPaginado; tiene from y to para la ventana de solapamiento y los filtros updated*.
Si el personal está libre en una ventanaPOST /api/v1/staff/availabilityComparte las 30 peticiones por minuto de las consultas de disponibilidad.
  • Una clave de API con account:read, schedule:read, planner:read, availability:read y tasks:read. Para la comprobación opcional de disponibilidad del personal añade staff: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 entorno FILMO_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.

Guarda 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}"

Lee la cuenta con GET /api/v1/account 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"

GET /api/v1/schedule 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"

La 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/v1/planner/services, 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 cubrirlo

GET /api/v1/planner/services 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/v1/planner/resources 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"

GET /api/v1/availability/unavailabilities 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"

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 necesites

Las 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/v1/tasks. 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"

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/v1/staff/availability 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"'"]
  }'

La cuota de disponibilidad es de 30 por minuto

POST /api/v1/staff/availability, POST /api/v1/vehicles/availability y POST /api/v1/availability/check 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.

Cada 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/v1/orders/{orderId}, GET /api/v1/projects/{projectId} o GET /api/v1/tasks/{taskId}) 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());

El bucle importa el lector del paso 4; guarda ese fragmento como unavailabilities.mjs o unavailabilities.py. Si tu calendario necesita cambios incrementales de tareas en lugar de instantáneas, GET /api/v1/tasks 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.

  • 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 como Europe/Madrid. Es la zona en la que se lee una fecha sin hora (2026-10-14) cuando un filtro admite una, por ejemplo dueFrom.
  • La agenda, el planificador, las indisponibilidades y los from y to de 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 invoiceDate y dueDate en PUT /api/v1/invoices/external/{originSystem}/{originExternalId}.
  • Una tarea con isAllDay activado 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.
GuardarPor qué
Un mapa de la clave del evento (layer:id) al id de tu evento de calendarioPara 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 eventoPara distinguir un evento eliminado de uno que ha salido de la ventana.
La zona horaria de la cuentaPara mostrar horas y construir desplazamientos en las escrituras.
Opcionalmente, el último ETag y su ventanaUn 304 evita el trabajo de analizar y comparar.

Un 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 cycles

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

RespuestaQué significa aquíQué hacer
401 api_key_owner_inactive o api_key_owner_not_adminLa 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_scopeA 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_errorUna 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_foundEn 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_limitedLos 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 504Un 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_unavailableSolo 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.
  • Guías

    Sincronizar datos

    Filmo v1 no envía webhooks, así que mantienes otro sistema sincronizado consultando periódicamente: lee lo que cambió en ventanas de tiempo semiabiertas y ejecuta un barrido aparte para encontrar lo que se eliminó. No sigas el cursor de paginación para decidir qué cambió o qué desapareció.

  • 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-17 se 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.