*REST API › Tutorials*

# Mirror the schedule into a calendar

> The schedule comes as a snapshot of a date window, so a calendar mirror is a loop that reads the window, diffs it against what you hold and sends only the changes. Responses are in UTC; the business timezone comes from the account.

Unlike the lists in [Inventory sync](https://docs.filmo.io/rest-api/tutorials/inventory-sync), the schedule does not paginate and is bounded by dates. That removes the cursor problems of [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data): you read a window and compare it with what you hold. It is not a complete snapshot, though. Each read returns at most 200 projects, 200 orders and 500 tasks, in order of start date, and says nothing when it cuts a list; and it leaves out finished and cancelled records. So a record that is missing from a read has not necessarily been removed. The loop below reads short windows, splits a window whose layer comes back full, and confirms every removal with the record's own read. Because you compare windows and not `updatedAt` values, the caveats of that guide about the update timestamp (a child change that does not move its parent, a stamp one second earlier) do not affect this loop.

## What you read

| Data | Operation | Notes |
| --- | --- | --- |
| Projects, orders, tasks and internal services in a window | `GET /api/v1/schedule` | No pagination. `layers` picks which of the five layers to fetch; the default is all of them. Supports `ETag` and `If-None-Match`. |
| Staff and fleet services to cover, with who covers them | `GET /api/v1/planner/services` | A service comes as several rows that share the same `orderServiceId`, the identifier to assign: group them (step 3). `startDate` and `endDate` are required. No pagination. |
| Who can cover them | `GET /api/v1/planner/resources` | Staff, vehicles or both, with `resourceType` required. No pagination. |
| Absences of staff, vehicles and spaces | `GET /api/v1/availability/unavailabilities` | `resourceType` is required; paginated with its own date cursor. |
| Tasks, with recurrence and related document | `GET /api/v1/tasks` | Paginated; has `from` and `to` for the overlap window and the `updated*` filters. |
| Whether staff are free in a window | `POST /api/v1/staff/availability` | Shares the 30 requests per minute of the availability queries. |

## Prerequisites

- An API key with [`account:read`](https://docs.filmo.io/rest-api/guides/scopes#account:read), [`schedule:read`](https://docs.filmo.io/rest-api/guides/scopes#schedule:read), [`planner:read`](https://docs.filmo.io/rest-api/guides/scopes#planner:read), [`availability:read`](https://docs.filmo.io/rest-api/guides/scopes#availability:read) and [`tasks:read`](https://docs.filmo.io/rest-api/guides/scopes#tasks:read). For the optional staff availability check add [`staff:read`](https://docs.filmo.io/rest-api/guides/scopes#staff:read). Create it in the API and MCP settings of the app (Ajustes → API y MCP); only administrators can.
- Node 22 or later, or Python 3 with `requests`, and the key in the `FILMO_API_KEY` environment variable.
- A calendar you can write to. The loop below returns the events to create, change and remove; sending them to your calendar's own API is yours to write.

## Step 1. Set up the client and read the account

Save the client once; every later snippet imports it. It sleeps for `Retry-After` seconds when it receives [`rate_limited`](https://docs.filmo.io/rest-api/errors/rate_limited).

*The client*

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

Read the account with [`GET /api/v1/account`](https://docs.filmo.io/rest-api/endpoint-reference/account/get-the-account) and keep [`timezone`](https://docs.filmo.io/rest-api/objects/account#response-timezone): you need it to show times to people and to build offsets for writes.

**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"])
```

## Step 2. Read the schedule for a window

[`GET /api/v1/schedule`](https://docs.filmo.io/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range) takes [`startDate`](https://docs.filmo.io/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range#param-startDate) and [`endDate`](https://docs.filmo.io/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range#param-endDate) as instants with an offset, and returns five arrays: `projects`, `orders`, `tasks`, `internalServices` and `orderServices`. Assignees come out as an id and a name; their contact details do not. The spec states no maximum window, so ask for what a calendar shows: a week back and two months ahead is plenty. Ask for the same window on every poll, for example aligned to midnight UTC as below; then you can send the `ETag` back in `If-None-Match` and a response of 304 tells you nothing changed. A 304 has no body but it still counts against your 120 requests per minute. The read is capped, and the spec does not say so (it only says the read "is bounded by dates"): it returns at most 200 projects, 200 orders and 500 tasks, and a layer that comes back exactly full may have been cut. It also shows only some states: orders from `draft` up to `checked_in_partially` (not `checked_in`, `cancelled` or `archived`) and projects that are `draft`, `budgeted`, `confirmed` or `in_progress`. An order that is fully checked in or cancelled leaves the calendar by itself. Keep the window short and split it when a layer comes back full, as the loop in step 7 does.

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

The `orderServices` layer has no id of its own. This tutorial leaves it out of the schedule read with [`layers`](https://docs.filmo.io/rest-api/endpoint-reference/schedule/get-the-schedule-for-a-range#param-layers) and reads the same services from [`GET /api/v1/planner/services`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-services-to-cover), which does carry one. [`tasks`](https://docs.filmo.io/rest-api/objects/schedule#response-tasks) entries have a nullable `startDate`; skip the ones without a start, as the loop does.

## Step 3. Read what is uncovered and who can cover it

[`GET /api/v1/planner/services`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-services-to-cover) is the planner's view of the same window: the staff and fleet services of the orders. A service does not come as one row. It comes as one row with [`rowKind`](https://docs.filmo.io/rest-api/objects/planner-service#response-rowKind) `summary`, with [`staffMemberId`](https://docs.filmo.io/rest-api/objects/planner-service#response-staffMemberId) and [`vehicleId`](https://docs.filmo.io/rest-api/objects/planner-service#response-vehicleId) always null, and one row with `detail` for each person or vehicle assigned, all with the same [`orderServiceId`](https://docs.filmo.io/rest-api/objects/planner-service#response-orderServiceId). Group the rows by that identifier: the people and vehicles are the `detail` rows, and a service with only its `summary` row is uncovered. A null on the `summary` row does not mean uncovered. The list also carries the internal services, with an empty `orderId`; the schedule returns them too, so skip those rows or you will mirror each one twice. By default, and with [`orderStatus`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-services-to-cover#param-orderStatus) `all`, you get the orders in every state except cancelled and archived, not only drafts and reserved ones; `draft` or `reserved` narrow it. [`serviceType`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-services-to-cover#param-serviceType) (`crew`, `fleet` or `both`) defaults to both. [`GET /api/v1/planner/resources`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-assignable-resources) lists the people and vehicles that can be assigned. Neither paginates.

**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"])
```

## Step 4. Read absences

[`GET /api/v1/availability/unavailabilities`](https://docs.filmo.io/rest-api/endpoint-reference/availability/list-the-unavailabilities-of-a-resource) returns the windows in which a person, a vehicle or a space is not available. [`resourceType`](https://docs.filmo.io/rest-api/endpoint-reference/availability/list-the-unavailabilities-of-a-resource#param-resourceType) is required, so read the three types one after the other. For people, [`absenceType`](https://docs.filmo.io/rest-api/objects/unavailability#response-absenceType) is null because it would be health data, and the reason is never returned. The list returns only absences in force or still to come, so [`state`](https://docs.filmo.io/rest-api/objects/unavailability#response-state) is in practice always `unavailable`, and an absence that has ended drops out of the list by itself: do not take that for a deletion. This list is not part of the availability quota; it counts against the 120 per minute like the schedule.

**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".
```

## Step 5. Read task details when you need them

The schedule's task entries are compact. When your calendar needs the description, the recurrence pattern, the related document or the appearance, read the tasks with [`GET /api/v1/tasks`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks). [`from`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-from) and [`to`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-to) return the tasks that overlap the window, so one that starts earlier and ends inside it is included; [`assignedStaffIds`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-assignedStaffIds) and [`isCompleted`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-isCompleted) narrow it. A recurring task shows its [`recurrencePattern`](https://docs.filmo.io/rest-api/objects/task#response-recurrencePattern); the list returns the stored task, not each repetition.

**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")
```

## Step 6. Check staff availability (optional)

To ask whether specific people are free in a window, use [`POST /api/v1/staff/availability`](https://docs.filmo.io/rest-api/endpoint-reference/staff/check-staff-availability) with up to 100 `staffIds`. It is a POST because of the structured body, but it only reads, so it takes no `Idempotency-Key`. Each entry tells you whether the person `available` is and which commitments occupy them, typed as `order`, `internal_service`, `unavailability` or `incident`. An id that does not exist comes back as a per-item `error` with the code `not_found`, not as a failure of the whole call.

**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"]])
```

> **The availability quota is 30 per minute:** [`POST /api/v1/staff/availability`](https://docs.filmo.io/rest-api/endpoint-reference/staff/check-staff-availability), [`POST /api/v1/vehicles/availability`](https://docs.filmo.io/rest-api/endpoint-reference/vehicles/check-fleet-availability) and [`POST /api/v1/availability/check`](https://docs.filmo.io/rest-api/endpoint-reference/availability/check-item-availability) share 30 requests per minute, on top of the 120 per minute of the whole key. Do not call them once per calendar cell or per viewer. Batch up to 100 people per call, cache the answer for a short time, and prefer the commitments you already mirror from the schedule; ask the availability operation only for the question the mirror cannot answer. A 429 says how long to wait in `Retry-After`.

## Step 7. Run the mirror loop

Every five minutes, read the schedule layers, the planner services and the three absence lists for the same window. Turn each item into an event keyed by layer and id, compare it with the copy you hold, and return what to create, change and remove. An event you hold that overlaps the window and is missing from the new read is only a suspicion: the read is capped and leaves out finished and cancelled records, and an absence that has ended drops out of its list. Confirm it with the record's own read ([`GET /api/v1/orders/{orderId}`](https://docs.filmo.io/rest-api/endpoint-reference/orders/get-an-order), [`GET /api/v1/projects/{projectId}`](https://docs.filmo.io/rest-api/endpoint-reference/projects/get-a-project) or [`GET /api/v1/tasks/{taskId}`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/get-a-task)) before you remove it: a 404, or an order or project in a state the schedule no longer shows, means remove it. When a layer may have been cut, the loop judges no removal in that cycle. An event that merely ended before the window's start is left alone. The loop keeps its state in a file here; use your database.

**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())
```

The loop imports the reader of step 4; save that snippet as `unavailabilities.mjs` or `unavailabilities.py`. If your calendar needs incremental task changes instead of snapshots, [`GET /api/v1/tasks`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks) also accepts [`updatedFrom`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-updatedFrom) and [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-updatedBefore); use the windowed pass from [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data), with its safety margin and deduplication by `id`. Tasks are deleted for good and the list has no deletion feed, so a task you hold that no longer appears has to be confirmed with its own read.

## Time zones

- Responses are in UTC: instants end in `Z`. Store them as instants, not as local strings.
- The business timezone is [`timezone`](https://docs.filmo.io/rest-api/objects/account#response-timezone), an IANA name such as `Europe/Madrid`. It is the zone in which a bare day (`2026-10-14`) is read when a filter accepts one, for example [`dueFrom`](https://docs.filmo.io/rest-api/endpoint-reference/tasks/list-tasks#param-dueFrom).
- The schedule, planner, unavailabilities and the `from` and `to` of tasks need instants with an offset. Build them from the business timezone, so a day boundary stays right when the clocks change.
- The writes you may add later need an offset too: creating a task or declaring an unavailability takes an ISO 8601 instant with offset, not a bare day. Include the offset of the business timezone on that date, which can differ between summer and winter. A few other writes take a calendar date by design, such as `invoiceDate` and `dueDate` in [`PUT /api/v1/invoices/external/{originSystem}/{originExternalId}`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice).
- A task with [`isAllDay`](https://docs.filmo.io/rest-api/objects/task#response-isAllDay) set is an all-day task. Show it on the date it has in the business timezone rather than converting the instant to the viewer's zone.

The rules for dates, offsets and the business timezone are in [Dates and times](https://docs.filmo.io/further-reading/dates-and-times).

*Show an instant in the business timezone*

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

## What to store on your side

| Store | Why |
| --- | --- |
| A map from event key (`layer:id`) to your calendar event id | To update or remove the right event; Filmo ids are opaque. |
| The last version you sent of each event (a hash or the JSON) | The loop compares to it to send only changes. |
| The start and end of each event | To tell a removed event from one that scrolled out of the window. |
| The account's timezone | To display times and to build offsets for writes. |
| Optionally the last `ETag` and its window | A 304 skips the work of parsing and diffing. |

## Call budget

One cycle reads the schedule (1 call), the planner services (1) and the three absence types (1 each, more if one returns more than 500 windows), so about 5 calls, plus the extra reads when a window is split and one read per missing record you confirm. The rate is the calls per cycle divided by the minutes between cycles:

```text
calls per minute = (1 + 1 + 3 + extra pages) / minutes between cycles
```

At 5 calls every 5 minutes that is 1 call per minute, 0.8% of the 120 per minute your key allows. Reading tasks adds one call per page. Staff availability is the only one that also counts against the 30 per minute of the availability queries: leave it for on-demand questions and keep it out of the loop.

## Error handling

| Response | What it means here | What to do |
| --- | --- | --- |
| 401 `api_key_owner_inactive` or `api_key_owner_not_admin` | The person who created the key left or stopped being an administrator. | Stop the loop, alert someone and reissue the key from an administrator account. |
| 403 `insufficient_scope` | The key lacks a scope; `requiredScope` names it. Each of the reads above needs its own. | Reissue the key with the scopes in the prerequisites. |
| 400 `validation_error` | A date has no offset, a required parameter is missing (such as `resourceType`), or, on the availability calls, the end is not after the start. | Fix the request; `errors[]` lists each field. |
| 404 `not_found` | On the staff availability call: only an unknown id inside `data`, as a per-item error. Elsewhere, a resource outside your account. | Remove the id from what you ask for. |
| 429 `rate_limited` | The 120 per minute of the key, or the 30 per minute of the availability queries. | Wait for `Retry-After` seconds (also `retryAfter` in the problem body). `RateLimit-*` says which quota ran out. |
| 500 `internal_error`, 502 or 504 | A transient failure. Reads are safe to repeat. | Keep the previous mirror unchanged and run the next cycle. Do not remove events after a failed read. |
| 503 `service_unavailable` | Only the writes to space unavailabilities document it, and this tutorial only reads. Filmo controls the switch behind it. | Do not retry it as a transient failure. Skip the call and contact Filmo support with the `requestId`. |

## Where to go next

- [Dates and times](https://docs.filmo.io/further-reading/dates-and-times): offsets, UTC, bare days and the business timezone.
- [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data): the windowed pass, if you need task changes instead of snapshots.
- [Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits): the global and the availability quotas, and the headers.
- [Inventory sync](https://docs.filmo.io/rest-api/tutorials/inventory-sync): the same loop for items, units and order lines.
- [Availability to reserved order](https://docs.filmo.io/rest-api/tutorials/availability-to-reserved-order): the 30 per minute quota in a booking site.

---

Canonical URL: https://docs.filmo.io/rest-api/tutorials/schedule-sync  
Last updated: 2026-10-09