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, the schedule does not paginate and is bounded by dates. That removes the cursor problems of 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.

DataOperationNotes
Projects, orders, tasks and internal services in a windowGET /api/v1/scheduleNo 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 themGET /api/v1/planner/servicesA 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 themGET /api/v1/planner/resourcesStaff, vehicles or both, with resourceType required. No pagination.
Absences of staff, vehicles and spacesGET /api/v1/availability/unavailabilitiesresourceType is required; paginated with its own date cursor.
Tasks, with recurrence and related documentGET /api/v1/tasksPaginated; has from and to for the overlap window and the updated* filters.
Whether staff are free in a windowPOST /api/v1/staff/availabilityShares the 30 requests per minute of the availability queries.
  • An API key with account:read, schedule:read, planner:read, availability:read and tasks:read. For the optional staff availability check add 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

Link to this section: 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.

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

Read the account with GET /api/v1/account and keep timezone: you need it to show times to people and to build offsets for writes.

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

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

GET /api/v1/schedule takes startDate and 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.

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"

The orderServices layer has no id of its own. This tutorial leaves it out of the schedule read with layers and reads the same services from GET /api/v1/planner/services, which does carry one. 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

Link to this section: Step 3. Read what is uncovered and who can cover it

GET /api/v1/planner/services 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 summary, with staffMemberId and vehicleId always null, and one row with detail for each person or vehicle assigned, all with the same 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 all, you get the orders in every state except cancelled and archived, not only drafts and reserved ones; draft or reserved narrow it. serviceType (crew, fleet or both) defaults to both. GET /api/v1/planner/resources lists the people and vehicles that can be assigned. Neither paginates.

# What has to be covered in the window. staffMemberId and vehicleId at null mean uncovered.
curl -sS -G "$FILMO_URL/api/v1/planner/services" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "startDate=2026-10-02T00:00:00Z" \
  --data-urlencode "endDate=2026-12-08T00:00:00Z" \
  --data-urlencode "orderStatus=all"

# With whom: the staff and the fleet that can cover it.
curl -sS -G "$FILMO_URL/api/v1/planner/resources" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  --data-urlencode "resourceType=both"

GET /api/v1/availability/unavailabilities returns the windows in which a person, a vehicle or a space is not available. resourceType is required, so read the three types one after the other. For people, 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 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.

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

Step 5. Read task details when you need them

Link to this section: 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. from and to return the tasks that overlap the window, so one that starts earlier and ends inside it is included; assignedStaffIds and isCompleted narrow it. A recurring task shows its recurrencePattern; the list returns the stored task, not each repetition.

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

To ask whether specific people are free in a window, use POST /api/v1/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.

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

The availability quota is 30 per minute

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

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}, GET /api/v1/projects/{projectId} or GET /api/v1/tasks/{taskId}) 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.

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

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 also accepts updatedFrom and updatedBefore; use the windowed pass from 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.

  • Responses are in UTC: instants end in Z. Store them as instants, not as local strings.
  • The business timezone is 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.
  • 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}.
  • A task with 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.

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.
StoreWhy
A map from event key (layer:id) to your calendar event idTo 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 eventTo tell a removed event from one that scrolled out of the window.
The account's timezoneTo display times and to build offsets for writes.
Optionally the last ETag and its windowA 304 skips the work of parsing and diffing.

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:

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.

ResponseWhat it means hereWhat to do
401 api_key_owner_inactive or api_key_owner_not_adminThe 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_scopeThe 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_errorA 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_foundOn 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_limitedThe 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 504A 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_unavailableOnly 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.
  • Guides

    Syncing data

    Filmo v1 does not send webhooks, so you keep another system in step by polling: read what changed in half-open time windows, and run a separate sweep to find what was deleted. Do not follow the pagination cursor to decide what changed or what disappeared.

  • Further reading

    Dates and times

    Responses give dates in UTC. When you write, send an ISO 8601 instant with an offset. A bare day such as 2026-10-17 is read in the business time zone of the account when it is in a filter; a write takes one only in a field that is a plain date.

  • Guides

    Rate limits

    Each API key has 120 requests per minute, and the three availability checks share their own quota of 30 per minute. Past a limit the API answers 429 with Retry-After.

  • Tutorials

    Sync your inventory with another system

    You read the catalog, the physical units and the lines of the orders that commit them, load everything once, then poll for changes and reconcile weekly. Filmo does not send webhooks, so polling is how you stay current.