Making sense of Arbor's GraphQL API
Making sense of Arbor's GraphQL API
Post 1 set out the idea: pull the desired state of every staff member's timetable out of Arbor, once per run, and hand it to a reconciliation stage that writes it to Outlook. That first half — "pull the desired state out of Arbor" — turned out to be most of the work. Arbor exposes a GraphQL API, and GraphQL's self-describing schema makes it feel like you can just ask for what you want. Arbor does publish API documentation and some example queries, and it's a reasonable starting point — but it doesn't go deep enough to get you to a working sync. In practice we hit five separate quirks before the query returned what we actually needed, and none of them were covered by the docs or examples. This post is those five things.
1. Calendars have no owner filter — so you can't just ask "give me this staff member's events"
The obvious query is something like "give me Staff X's calendar entries." Arbor
doesn't have that. Calendar has no filter by owner, and the only way to reach a
staff member's calendars at all is a nested field: Staff.calendarEntryMappings.
Every staff member owns two calendars — School and My Lessons — and this nested
field is the union of both.
So calendar-id discovery becomes its own step, separate from fetching events:
{ Staff(id_in: [123, 456]) { staffId calendarEntryMappings { calendar { id } } } }
Harvest the calendar ids per staff member, then fetch actual events by calendar id, not staff id.
2. That same nested field only sees the current academic year
Here's the field-level gotcha that cost us the most time: Staff.calendarEntryMappings
takes no date arguments, and it only ever returns entries from the current academic
year. In July, staff whose September timetable is already published in Arbor show
up with no autumn-term entries via this field at all.
The fix is that Arbor also exposes CalendarEntryMapping as a top-level query,
and that version takes real filters:
{
CalendarEntryMapping(
calendar__id_in: [123, 456]
startDatetime_after_or_equal: "2026-09-01 00:00:00"
startDatetime_before: "2026-09-08 00:00:00"
page_size: 200
page_num: 0
) {
startDatetime
endDatetime
eventDisplayName
calendar { id }
event { __typename }
}
}
This one respects date ranges and happily returns next year's published timetable. So the nested field is only used for step 1 (discovering calendar ids); every actual event comes from the top-level query. Two different fields, same underlying data, very different date behaviour — nothing in the schema signals this, you find it by trying both.
3. "Current staff" doesn't mean what you'd think
Staff(currentStaff: true) sounds like the right filter for "who do we sync." It
isn't complete: staff who've been entered into Arbor ahead of their start date
(new September hires added over the summer) are neither currentStaff: true nor
false — they're excluded from both. There's no server-side filter on
joiningDate/leavingDate either.
The workaround is a second, broader pass: page through externalStaff: false (the
widest catch-all we found), and client-side keep anyone whose joiningDate falls
on or before the end of the sync window and who has no leavingDate — merged in
with the currentStaff: true set. Two full paginated queries just to get a
correct staff list, because there's no single filter that means "will be relevant
during this sync window."
4. New starters break calendar-id discovery too — which needs its own fallback
Combine points 1 and 3: a staff member who joins in September has no current-year
calendar entries yet, so the nested Staff.calendarEntryMappings field (our only
route to their calendar ids) returns nothing for them. They're invisible to
discovery even though they're correctly in the staff list.
There's no staff filter on the top-level CalendarEntryMapping query either, so we
can't just ask it directly. The fallback: page through the unfiltered top-level
query over a short, bounded window — about two weeks from each missing staff
member's joining date — and attribute rows back to a person via the mapped union
field, which resolves to Staff { staffId } when a row belongs to a calendar owned
by staff:
{
CalendarEntryMapping(
startDatetime_after_or_equal: "2026-09-01 00:00:00"
startDatetime_before: "2026-09-15 00:00:00"
page_size: 200
page_num: 0
) {
calendar { id }
mapped { __typename ... on Staff { staffId } }
}
}
This only works because it's bounded to ~2 weeks per missing person; unfiltered over a whole term would mean paging through a school's entire calendar (rooms, sessions, everything) just to find a handful of new starters. Anyone still unresolved after the probe gets reported on the console rather than silently dropped.
5. A 400 response isn't necessarily a failure
Arbor returns field-level authorization errors — HTTP 400, GraphQL errors
array populated — whenever the event union in a query resolves to a type the API
credential isn't allowed to read (e.g. StaffAbsence details a timetable-sync
account has no business seeing). Critically, the response still carries the rest
of the data: title and times for the denied event, just no location.
Treating any non-2xx or any populated errors array as fatal would mean the whole
sync throws on the first restricted event type. Instead:
$nonAuth = @($errors | Where-Object { $_.extensions.category -ne 'authorization' })
if ($response.data -and $nonAuth.Count -eq 0) {
# only authorization errors, and we still got data - summarise and continue
}
Anything that isn't an authorization-category error is still treated as fatal.
429/500/502/503/504 get a separate path: exponential backoff and retry, up
to four attempts, before giving up.
The smaller stuff
A few more things that only surface once you're actually calling the API:
page_numis 0-based, unlike the 1-based pagination you'd instinctively reach for.- The free tier caps
page_sizeat 500 and rate-limits at 100 requests per school per minute — we track a sliding 60-second window client-side and throttle at 90, leaving headroom for retries and anything else hitting the same school. - Not every event type in the union exposes
locationDisplayName— figuring out which ones do meant introspecting the schema, since there's no field list to read anywhere. - Every raw request and response gets dumped to a debug folder, wiped and rebuilt each run. Once we hit our second or third undocumented edge case, "read the last run's actual wire traffic" became faster than guessing from the schema.
None of this is exotic — it's the ordinary experience of integrating with a GraphQL API that has a schema and some docs, but no guide for the specific query shapes a given use case needs. The schema and the docs tell you what's possible; neither tells you that the field you'd naturally reach for only covers the current academic year, or that "current" staff excludes people who are, for every practical purpose, current.
Next up: post 3, on the other side of the pipeline — how the reconciliation engine turns this data into safe, idempotent writes against Microsoft Graph.