*API REST › 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](https://docs.filmo.io/es/rest-api/tutorials/inventory-sync), la agenda no se pagina y está acotada por fechas. Eso elimina los problemas de cursor de [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data): 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

| Datos | Operación | Notas |
| --- | --- | --- |
| Proyectos, pedidos, tareas y servicios internos en una ventana | `GET /api/v1/schedule` | 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/v1/planner/services` | 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/v1/planner/resources` | Personal, vehículos o ambos, con `resourceType` obligatorio. Sin paginación. |
| Ausencias de personal, vehículos y espacios | `GET /api/v1/availability/unavailabilities` | `resourceType` es obligatorio; se pagina con su propio cursor de fechas. |
| Tareas, con recurrencia y documento relacionado | `GET /api/v1/tasks` | Paginado; tiene `from` y `to` para la ventana de solapamiento y los filtros `updated*`. |
| Si el personal está libre en una ventana | `POST /api/v1/staff/availability` | Comparte las 30 peticiones por minuto de las consultas de disponibilidad. |

## Requisitos previos

- Una clave de API con [`account:read`](https://docs.filmo.io/es/rest-api/guides/scopes#account:read), [`schedule:read`](https://docs.filmo.io/es/rest-api/guides/scopes#schedule:read), [`planner:read`](https://docs.filmo.io/es/rest-api/guides/scopes#planner:read), [`availability:read`](https://docs.filmo.io/es/rest-api/guides/scopes#availability:read) y [`tasks:read`](https://docs.filmo.io/es/rest-api/guides/scopes#tasks:read). Para la comprobación opcional de disponibilidad del personal añade [`staff:read`](https://docs.filmo.io/es/rest-api/guides/scopes#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.

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

Guarda el cliente una vez; todos los fragmentos posteriores lo importan. Espera `Retry-After` segundos cuando recibe [`rate_limited`](https://docs.filmo.io/es/rest-api/errors/rate_limited).

*El cliente*

**cURL**

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

```

**Node**

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

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

**Python**

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

import requests

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


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

Lee la cuenta con [`GET /api/v1/account`](https://docs.filmo.io/es/rest-api/endpoint-reference/account/get-the-account) y conserva [`timezone`](https://docs.filmo.io/es/rest-api/objects/account#response-timezone): la necesitas para mostrar horas a las personas y para construir desplazamientos en las escrituras.

**cURL**

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

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

**Node**

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

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

**Python**

```python
from filmo import get

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

## Paso 2. Leer la agenda de una ventana

[`GET /api/v1/schedule`](https://docs.filmo.io/es/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range) toma [`startDate`](https://docs.filmo.io/es/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range#param-startDate) y [`endDate`](https://docs.filmo.io/es/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range#param-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.

**cURL**

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

**Node**

```javascript
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 changed
```

**Python**

```python
import 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 changed
```

La capa `orderServices` no tiene id propio. Este tutorial la deja fuera de la lectura de la agenda con [`layers`](https://docs.filmo.io/es/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range#param-layers) y lee los mismos servicios de [`GET /api/v1/planner/services`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-services-to-cover), que sí lo lleva. Las entradas de [`tasks`](https://docs.filmo.io/es/rest-api/objects/schedule#response-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

[`GET /api/v1/planner/services`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-services-to-cover) 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`](https://docs.filmo.io/es/rest-api/objects/planner-service#response-rowKind) `summary`, con [`staffMemberId`](https://docs.filmo.io/es/rest-api/objects/planner-service#response-staffMemberId) y [`vehicleId`](https://docs.filmo.io/es/rest-api/objects/planner-service#response-vehicleId) siempre null, y una fila con `detail` por cada persona o vehículo asignado, todas con el mismo [`orderServiceId`](https://docs.filmo.io/es/rest-api/objects/planner-service#response-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`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-services-to-cover#param-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`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-services-to-cover#param-serviceType) (`crew`, `fleet` o `both`) vale both por defecto. [`GET /api/v1/planner/resources`](https://docs.filmo.io/es/rest-api/endpoint-reference/planner/list-the-assignable-resources) lista las personas y los vehículos que se pueden asignar. Ninguna de las dos pagina.

**cURL**

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

**Node**

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

**Python**

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

[`GET /api/v1/availability/unavailabilities`](https://docs.filmo.io/es/rest-api/endpoint-reference/availability/list-the-unavailabilities-of-a-resource) devuelve las ventanas en las que una persona, un vehículo o un espacio no está disponible. [`resourceType`](https://docs.filmo.io/es/rest-api/endpoint-reference/availability/list-the-unavailabilities-of-a-resource#param-resourceType) es obligatorio, así que lee los tres tipos uno tras otro. Para las personas, [`absenceType`](https://docs.filmo.io/es/rest-api/objects/unavailability#response-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`](https://docs.filmo.io/es/rest-api/objects/unavailability#response-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.

**cURL**

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

**Node**

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

**Python**

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

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`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks). [`from`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-from) y [`to`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-to) devuelven las tareas que se solapan con la ventana, así que se incluye una que empieza antes y termina dentro de ella; [`assignedStaffIds`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-assignedStaffIds) y [`isCompleted`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-isCompleted) la acotan. Una tarea recurrente muestra su [`recurrencePattern`](https://docs.filmo.io/es/rest-api/objects/task#response-recurrencePattern); la lista devuelve la tarea almacenada, no cada repetición.

**cURL**

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

**Node**

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

**Python**

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

Para preguntar si unas personas concretas están libres en una ventana, usa [`POST /api/v1/staff/availability`](https://docs.filmo.io/es/rest-api/endpoint-reference/staff/check-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.

**cURL**

```bash
# 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"'"]
  }'
```

**Node**

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

**Python**

```python
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/v1/staff/availability`](https://docs.filmo.io/es/rest-api/endpoint-reference/staff/check-staff-availability), [`POST /api/v1/vehicles/availability`](https://docs.filmo.io/es/rest-api/endpoint-reference/vehicles/check-fleet-availability) y [`POST /api/v1/availability/check`](https://docs.filmo.io/es/rest-api/endpoint-reference/availability/check-item-availability) 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

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}`](https://docs.filmo.io/es/rest-api/endpoint-reference/orders/get-an-order), [`GET /api/v1/projects/{projectId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/projects/get-a-project) o [`GET /api/v1/tasks/{taskId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/get-a-task)) 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.

**Node**

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

**Python**

```python
# 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.mjs` o `unavailabilities.py`. Si tu calendario necesita cambios incrementales de tareas en lugar de instantáneas, [`GET /api/v1/tasks`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks) también acepta [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-updatedFrom) y [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-updatedBefore); usa la pasada por ventanas de [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data), 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

- 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`](https://docs.filmo.io/es/rest-api/objects/account#response-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`](https://docs.filmo.io/es/rest-api/endpoint-reference/tasks/list-tasks#param-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}`](https://docs.filmo.io/es/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice).
- Una tarea con [`isAllDay`](https://docs.filmo.io/es/rest-api/objects/task#response-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](https://docs.filmo.io/es/further-reading/dates-and-times).

*Mostrar un instante en la zona horaria del negocio*

**Node**

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

**Python**

```python
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:00
```

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

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:

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

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

- [Fechas y horas](https://docs.filmo.io/es/further-reading/dates-and-times): desplazamientos, UTC, fechas sin hora y la zona horaria del negocio.
- [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data): el recorrido por ventanas, si necesitas los cambios de las tareas en lugar de instantáneas.
- [Límites de peticiones](https://docs.filmo.io/es/rest-api/guides/rate-limits): las cuotas global y de disponibilidad, y las cabeceras.
- [Sincronización de inventario](https://docs.filmo.io/es/rest-api/tutorials/inventory-sync): el mismo bucle para artículos, unidades y líneas de pedido.
- [De la disponibilidad al pedido reservado](https://docs.filmo.io/es/rest-api/tutorials/availability-to-reserved-order): la cuota de 30 por minuto en un sitio de reservas.

---

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