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.
What you read
Link to this section: What you read| Data | Operation | Notes |
|---|---|---|
| Projects, orders, tasks and internal services in a window | GET /api | 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 | 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 | Staff, vehicles or both, with resourceType required. No pagination. |
| Absences of staff, vehicles and spaces | GET /api | resourceType is required; paginated with its own date cursor. |
| Tasks, with recurrence and related document | GET /api | Paginated; has from and to for the overlap window and the updated* filters. |
| Whether staff are free in a window | POST /api | Shares the 30 requests per minute of the availability queries. |
Prerequisites
Link to this section: Prerequisites- An API key with
account:read,schedule:read,planner:read,availability:readandtasks:read. For the optional staff availability check addstaff: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 theFILMO_API_KEYenvironment 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 accountSave 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}"
// filmo.mjs: a small client. Node 22 or later, no dependencies.
export const API = (process.env.FILMO_BASE_URL ?? "https://app.filmo.io") + "/api/v1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// GET with query parameters. Waits out a 429 as Retry-After says.
export async function get(path, query = {}, headers = {}) {
const url = new URL(API + path);
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value));
for (;;) {
const res = await fetch(url, {
headers: { Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "), ...headers },
});
if (res.status === 429) {
await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
continue;
}
if (res.status === 304) return { status: 304, etag: res.headers.get("ETag") };
if (!res.ok) {
throw Object.assign(new Error(res.status + " " + (await res.text())), { status: res.status });
}
return {
...(await res.json()),
status: res.status,
etag: res.headers.get("ETag"),
serverTime: new Date(res.headers.get("Date")), // the server's clock, not yours
};
}
}# filmo.py: a small client. Python 3 and requests.
import os
import time
from email.utils import parsedate_to_datetime
import requests
API = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io") + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FILMO_API_KEY']}"
def get(path, headers=None, **query):
"""GET with query parameters. Waits out a 429 as Retry-After says."""
while True:
res = session.get(API + path, params=query, headers=headers)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "1")))
continue
if res.status_code == 304:
return {"status": 304, "etag": res.headers.get("ETag")}
res.raise_for_status()
body = res.json()
body["status"] = res.status_code
body["etag"] = res.headers.get("ETag")
body["serverTime"] = parsedate_to_datetime(res.headers["Date"]) # the server's clock
return bodyRead the account with GET /api 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"import { get } from "./filmo.mjs";
const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);from filmo import get
account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])Step 2. Read the schedule for a window
Link to this section: Step 2. Read the schedule for a windowGET /api 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"import { get } from "./filmo.mjs";
const DAY = 24 * 3600 * 1000;
const today = Math.floor(Date.now() / DAY) * DAY; // midnight UTC, so the window repeats all day
const query = {
startDate: new Date(today - 7 * DAY).toISOString(),
endDate: new Date(today + 60 * DAY).toISOString(),
layers: "projects,orders,tasks,internal_services",
};
const first = await get("/schedule", query);
console.log(first.data.tasks.length + " tasks, ETag " + first.etag);
const again = await get("/schedule", query, { "If-None-Match": first.etag });
console.log(again.status); // 304: no body, nothing changedimport time
from datetime import datetime, timedelta, timezone
from filmo import get
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) # the window repeats all day
query = {
"startDate": (today - timedelta(days=7)).isoformat().replace("+00:00", "Z"),
"endDate": (today + timedelta(days=60)).isoformat().replace("+00:00", "Z"),
"layers": "projects,orders,tasks,internal_services",
}
first = get("/schedule", **query)
print(len(first["data"]["tasks"]), "tasks, ETag", first["etag"])
again = get("/schedule", headers={"If-None-Match": first["etag"]}, **query)
print(again["status"]) # 304: no body, nothing changedThe 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, 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 itGET /api 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 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"import { get } from "./filmo.mjs";
const DAY = 24 * 3600 * 1000;
const today = Math.floor(Date.now() / DAY) * DAY; // midnight UTC
const startDate = new Date(today - 7 * DAY).toISOString();
const endDate = new Date(today + 60 * DAY).toISOString();
// A service comes as several rows that share an orderServiceId: one with rowKind "summary" (the
// service itself, staffMemberId and vehicleId at null) and one "detail" row for each person or
// vehicle assigned. Rows with an empty orderId are internal services, which the schedule already
// returns: skip them.
function groupServices(rows) {
const byService = new Map();
for (const row of rows) {
if (row.orderId === "") continue;
const service = byService.get(row.orderServiceId) ?? { staff: [], vehicles: [] };
if (row.rowKind === "summary") Object.assign(service, row); // the service itself
else {
if (row.staffMemberId) service.staff.push(row.staffMemberId);
if (row.vehicleId) service.vehicles.push(row.vehicleId);
}
byService.set(row.orderServiceId, service);
}
return [...byService.values()];
}
const services = await get("/planner/services", { startDate, endDate, orderStatus: "all" });
const grouped = groupServices(services.data);
const uncovered = grouped.filter((s) => s.staff.length === 0 && s.vehicles.length === 0);
console.log(uncovered.length + " of " + grouped.length + " services without a staff member or a vehicle");
const resources = await get("/planner/resources", { resourceType: "both" });
for (const r of resources.data) console.log(r.type, r.id, r.name);from datetime import datetime, timedelta, timezone
from filmo import get
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
start_date = (today - timedelta(days=7)).isoformat().replace("+00:00", "Z")
end_date = (today + timedelta(days=60)).isoformat().replace("+00:00", "Z")
def group_services(rows):
"""A service comes as several rows that share an orderServiceId: one with rowKind "summary" (the
service itself, staffMemberId and vehicleId at null) and one "detail" row for each person or
vehicle assigned. Rows with an empty orderId are internal services, which the schedule already
returns: skip them."""
by_service = {}
for row in rows:
if row["orderId"] == "":
continue
service = by_service.setdefault(row["orderServiceId"], {"staff": [], "vehicles": []})
if row["rowKind"] == "summary":
service.update(row) # the service itself
else:
if row["staffMemberId"]:
service["staff"].append(row["staffMemberId"])
if row["vehicleId"]:
service["vehicles"].append(row["vehicleId"])
return list(by_service.values())
services = get("/planner/services", startDate=start_date, endDate=end_date, orderStatus="all")["data"]
grouped = group_services(services)
uncovered = [s for s in grouped if not s["staff"] and not s["vehicles"]]
print(len(uncovered), "of", len(grouped), "services without a staff member or a vehicle")
for r in get("/planner/resources", resourceType="both")["data"]:
print(r["type"], r["id"], r["name"])Step 4. Read absences
Link to this section: Step 4. Read absencesGET /api 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"// unavailabilities.mjs
import { get } from "./filmo.mjs";
// Follows the list's own date cursor until it ends.
export async function listUnavailabilities(resourceType, startDate, endDate) {
const found = [];
let cursor;
do {
const page = await get("/availability/unavailabilities", {
resourceType,
startDate,
endDate,
limit: 500,
...(cursor && { cursor }),
});
found.push(...page.data);
cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined;
} while (cursor);
return found;
}
// Use: await listUnavailabilities("staff", startDate, endDate), then "vehicle" and "space".# unavailabilities.py
from filmo import get
def list_unavailabilities(resource_type, start_date, end_date):
"""Follows the list's own date cursor until it ends."""
found, cursor = [], None
while True:
params = {"resourceType": resource_type, "startDate": start_date, "endDate": end_date, "limit": 500}
if cursor:
params["cursor"] = cursor
page = get("/availability/unavailabilities", **params)
found += page["data"]
if not page["pagination"]["hasMore"]:
return found
cursor = page["pagination"]["nextCursor"]
# Use: list_unavailabilities("staff", start_date, end_date), then "vehicle" and "space".Step 5. Read task details when you need them
Link to this section: Step 5. Read task details when you need themThe 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. 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"import { get } from "./filmo.mjs";
const DAY = 24 * 3600 * 1000;
const today = Math.floor(Date.now() / DAY) * DAY; // midnight UTC
const from = new Date(today - 7 * DAY).toISOString();
const to = new Date(today + 60 * DAY).toISOString();
const tasks = await get("/tasks", { from, to, sort: "startDate", limit: 500 });
for (const t of tasks.data) {
console.log(t.title, t.isRecurring, t.recurrencePattern?.frequency, t.relatedDocumentType, t.description);
}
console.log(tasks.pagination.hasMore ? "more pages: follow the cursor" : "complete");from datetime import datetime, timedelta, timezone
from filmo import get
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
start = (today - timedelta(days=7)).isoformat().replace("+00:00", "Z")
end = (today + timedelta(days=60)).isoformat().replace("+00:00", "Z")
tasks = get("/tasks", **{"from": start, "to": end, "sort": "startDate", "limit": 500})
for t in tasks["data"]:
pattern = t["recurrencePattern"] or {}
print(t["title"], t["isRecurring"], pattern.get("frequency"), t["relatedDocumentType"], t["description"])
print("more pages: follow the cursor" if tasks["pagination"]["hasMore"] else "complete")Step 6. Check staff availability (optional)
Link to this section: Step 6. Check staff availability (optional)To ask whether specific people are free in a window, use POST /api 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"'"]
}'import { API } from "./filmo.mjs";
const res = await fetch(API + "/staff/availability", {
method: "POST",
headers: {
Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "),
"Content-Type": "application/json",
},
body: JSON.stringify({
startDate: "2026-10-14T08:00:00+02:00",
endDate: "2026-10-14T20:00:00+02:00",
staffIds: [process.env.STAFF_ID],
}),
});
if (res.status === 429) {
console.log("Availability quota spent; retry in " + res.headers.get("Retry-After") + " s");
} else {
const { data } = await res.json();
for (const entry of data) {
if (entry.error) console.log(entry.resourceId, entry.error.code);
else console.log(entry.name, entry.available, entry.commitments.map((c) => c.kind));
}
}import os
from filmo import API, session
res = session.post(
API + "/staff/availability",
json={
"startDate": "2026-10-14T08:00:00+02:00",
"endDate": "2026-10-14T20:00:00+02:00",
"staffIds": [os.environ["STAFF_ID"]],
},
)
if res.status_code == 429:
print("Availability quota spent; retry in", res.headers["Retry-After"], "s")
else:
res.raise_for_status()
for entry in res.json()["data"]:
if "error" in entry:
print(entry["resourceId"], entry["error"]["code"])
else:
print(entry["name"], entry["available"], [c["kind"] for c in entry["commitments"]])The availability quota is 30 per minute
POST /api, POST /api and POST /api 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
Link to this section: Step 7. Run the mirror loopEvery 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, GET /api or GET /api) 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());# 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. or unavailabilities.. If your calendar needs incremental task changes instead of snapshots, GET /api 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.
Time zones
Link to this section: Time zones- 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 asEurope. It is the zone in which a bare day (/Madrid 2026-10-14) is read when a filter accepts one, for exampledueFrom. - The schedule, planner, unavailabilities and the
fromandtoof 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
invoiceDateanddueDateinPUT /api./v1 /invoices /external /{originSystem} /{originExternalId} - A task with
isAllDayset 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.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:00What to store on your side
Link to this section: 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
Link to this section: Call budgetOne 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 cyclesAt 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
Link to this section: 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
Link to this section: Where to go next- Dates and times: offsets, UTC, bare days and the business timezone.
- Syncing data: the windowed pass, if you need task changes instead of snapshots.
- Rate limits: the global and the availability quotas, and the headers.
- Inventory sync: the same loop for items, units and order lines.
- Availability to reserved order: the 30 per minute quota in a booking site.
Related pages
Link to this section: Related pagesGuides
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-17is 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.
Was this page helpful?