Calendar wire format
Google Calendar's web client does not use the public Calendar API JSON names. This page covers the
raw payload, what the decoder does with it, and the measured field positions. The poller that
fetches it is calendar-sync.md.
What the endpoint returns
The decoder reads POST https://calendar.google.com/calendar/u/<index>/sync.prefetcheventrange.
Google's web client does not use Calendar API v3 JSON names (summary, start.dateTime,
conferenceData.entryPoints); it serialises internal Protocol Buffers into nested positional
JavaScript arrays compiled by Google Closure (jspb), preceded by the anti-XSSI guard )]}'\n\n.
stripXssiGuard removes the prefix and JSON.parse reads the rest. A response that is not the feed
is the unauthenticated one, [["er", null, null, null, null, 401, ...]]; isUnauthenticatedResponse
checks first[0] === 'er' && first[5] === 401 and parsePrefetchEventRangeResponse throws
CalendarSessionExpiredError.
Raw wire response
)]}'
[
[
"prefetcheventrangeaction.perr",
0,
[
"<syncTokenBlob>",
[
[
"alex@company.com",
[
[
"0r7ud0l6qsg353f6opf5brh3f4",
0,
"https://www.google.com/calendar/event?eid=...",
1733511428000,
1739211776052,
"Team Standup & Planning",
null,
null,
null,
["alex@company.com", null, null, true],
null,
null,
["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"],
/* ... remaining fields ... */
[1789027200000],
[1789030800000]
]
]
]
]
]
]
]
Decoding
Shape of a clean event
The decoder module apps/magical/src/calendar-decoder.ts
strips the XSSI prefix and normalises raw tuples into typed NormalizedCalendarEvent objects:
{
"id": "0r7ud0l6qsg353f6opf5brh3f4",
"calendarIds": ["alex@company.com", "team@group.calendar.google.com"],
"calendarName": "Work Calendar",
"summary": "Team Standup & Planning",
"webUrl": "https://www.google.com/calendar/event?eid=...",
"created": "2026-08-15T10:03:48.000Z",
"updated": "2026-09-12T14:22:56.052Z",
"startTime": "2026-09-13T09:00:00.000Z",
"endTime": "2026-09-13T10:00:00.000Z",
"isAllDay": false,
"timezone": "Europe/London",
"organizerEmail": "alex@company.com",
"recurrenceRule": "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
}
What decoding guarantees
Decoding a dense positional array needs no protobuf library or schema compiler: a mapping function reads the fixed positions in one pass, which keeps the rest of Magical clear of the wire's quirks.
The stability argument covers one of the three ways this payload can change:
- Field additions and deprecations are safe. jspb indexes by Protocol Buffer field number, so a
new field appends to the array without moving the fields already there, and a deprecated field
leaves a
nullhole rather than compacting. Tuple length drifts as a result, so nothing may key off it beyond a floor check. - Envelope restructuring is not safe. A renamed action token, a different nesting of the blocks around the events, or a move to a different internal service breaks parsing before any field is reached. A Calendar web client rewrite looks like this.
- New required request parameters are not safe. Google web RPCs commonly grow an
at=XSRF token, abl=client build label or a session id as anti-abuse hardening.calendar-sync.md's Zero-OAuth session mechanism measured none as required today, a snapshot rather than a contract. Recovery is to read the token out of the live calendar page in the account'sWebContentsView.
Pinned indices
The decoder reads each field at the position its field number gives it, validates the type and range
of what it finds, and throws when the value is not there. The positions live in calendar-decoder.ts
as IDX, ATTENDEE and EVENT_TYPE, beside the code that reads them; a table repeating them here
would drift out of step silently.
Protocol Buffer field numbers are assigned by a schema author once and never renumbered, because renumbering breaks Google's own rolling deploys. A field addition appends and a deprecation leaves a hole, so existing fields do not move. Scanning for a field by shape defends against a case that does not happen, since no amount of scanning inside a tuple helps with envelope restructuring or a new request parameter, and it turns a loud failure into a quiet wrong one: an adjacent created/updated pair, or a reminder time, read as the event start puts a banner at the wrong hour. A pinned field that no longer matches is a one-line fix found by a failing canary.
A decode that fails throws CalendarDecodeError. The throw reaches the staleness watchdog, which
tells the user Magical cannot read their calendar; a guess reaches them as a wrong alert.
Features the decoder resolves
Event title and times
IDX.summary (5) carries the title, and parseEventTuple falls back to (No title). IDX.start
(35) and IDX.end (36) carry the two timestamps, read by parseTimestampTuple. IDX.created (3)
and IDX.updated (4) carry two epoch numbers, null when absent rather than defaulted to now, which
would read as a fresh event.
All-day is the shape of the start, not a flag. A timed event carries [null, [epochMs], "Europe/London"];
an all-day entry carries a bare [epochMs] with no timezone, so isAllDay reads the absence of the
timezone. It mirrors the public API, where an all-day event has start.date and a timed one has
start.dateTime. There is no boolean all-day flag anywhere in the payload.
isValidCalendarDate bounds a decoded date to 1900 to 2100. The window is wide because a calendar
legitimately holds dates far outside a working year: a birthday from Contacts carries the person's
year of birth, and one born before the lower bound would throw and take its whole account's payload
with it.
Pending invitations
An event is awaiting a reply while the account holder's response code is 0. Each event carries its
guest list at IDX.attendees (20), and each guest carries their response at ATTENDEE.responseStatus
(5). The response codes, measured against a live account:
| Code | Meaning |
|---|---|
0 |
No response |
1 |
Declined |
2 |
Tentative |
3 |
Accepted |
One guest carries ATTENDEE.ownsThisCalendar (9), but that flag names the owner of the calendar this
copy arrived under, not the signed-in user; on a colleague's calendar it flags the colleague.
parseEventTuple finds the account holder by address instead, and their response is the same in every
copy. Without an address to look for, the flag is the best available guess and is right whenever the
block is the account's own calendar.
"Not invited" is a third state, distinct from "invited and unanswered". An event visible on a shared
calendar that the user is not a guest of is not awaiting their reply, and badging it as such would
light the Dock up permanently. myResponseStatus reads 'notInvited' for it.
invite-watcher.ts spots the ones that are pending:
export function isPendingInvite(event: NormalizedCalendarEvent): boolean {
return event.myResponseStatus === RESPONSE_NEEDS_ACTION;
}
inviteKey keys the series rather than the occurrence, so one invitation to a recurring meeting is
announced once. A notification banner opens the event rather than answering it; neither of Google's
two answer routes is usable, and answering drives Google's own controls instead.
actions.md's Answering an invitation owns the mechanism.
Conference links
resolveConferenceUrl reads three sources in order:
- The structured conference block, at
IDX.conference(57). Its first element is a list of entry points, each[typeCode, uri, label, ...], wheretypeCode3is the video entry and4and5are dial-in numbers; its last element is the conference id. Decode this one, because it covers third-party conferencing added through Google's conferencing API rather than only Meet. - The bare Meet URL, at
IDX.meetUrl(45), the legacyhangoutLinkequivalent. Cheap to read, but Google Meet only. - The free text,
IDX.description(64) first andIDX.location(7) second, scanned byfindJoinUrlInText.
Read the conference block for the video entry point and fall back to the Meet URL. The committed
fixture has an empty conference block because sanitisation stripped it, not because the field was
absent. Conferencing that was never registered with Google leaves all three empty; that is the common
Teams case, where the invitation body carries the join link. conference-links.md
owns how that body is scanned and how the URL is resolved and launched.
Working locations
IDX.eventType (82) carries 10 on a recurring working location and 11 on one set for a single
day. Measured against a live account, "Home", "Office" and a named office all arrive as 10 when
they belong to a series and as 11 when the user sets one for a single day. Both draw the same way.
Which location it is comes from the title, because nothing else carries it. Google's own Calendar API
has a workingLocationProperties.type, and it reports HOME_OFFICE for a home day, for one titled
"Office" and for a named office alike. Google generates all three titles it owns, being "Home",
"Office" and "workingLocationGlyph in WidgetCache.swift maps the title to
house, building.2 or mappin.and.ellipse. This heuristic is safe here because it runs only once
the wire has said the entry is a working location, and a wrong guess costs one glyph rather than
arming an alert at the wrong hour.
A day has a regular working location, and the user overrides it a day at a time. The override does
not cancel the regular entry: the series carries no EXDATE, the occurrence is not tombstoned, and
Google's own API reports both as confirmed. Google draws only the override, which is why its client
shows one where a faithful decode shows two. collapseWorkingLocations in working-locations.ts
does the same, keeping the override over the series occurrence and the later of two overrides. It
runs over the merged cache rather than the fetched list, because the entry an override replaces may
have arrived in an earlier poll. This covers the all-day entry, the one that states where the day is
spent; a working location given a time is an appointment like any other, and two ordinary all-day
events on a day both stay.
Out of office
IDX.eventType (82) carries 3. The decoder reads it into eventType; nothing draws it, and
alert-scheduler.ts excludes all-day entries, so an out-of-office block arms no alert. It sits on
the account's own calendar and reads as an ordinary appointment by every other measure.
Focus time
IDX.eventType (82) carries 7. Focus Time entries draw with a headphones glyph before the title,
from kindGlyph in TrayModel.swift and WidgetCache.swift.
Reminders
IDX.reminders (33) carries the event's own notification times, present only when it overrides its
calendar's. A list of [method, minutes], as [[2,15],[2,5]] for two notifications or [[0,10]] for
an email, where REMINDER_NOTIFICATION is 2 and REMINDER_EMAIL is 0. parseEventTuple keeps
the notification entries only, since an email reminder is not one this app can deliver and the alert
engine would treat its minutes as a banner time. alert-scheduler.ts takes the longest of them with
Math.max, which is the earliest banner.
An event that takes its calendar's default carries nothing here, and so does one whose notifications the user has removed; the two cannot be told apart from this payload, and the calendar's own default is not in the payload at all. Both fall back to the global lead.
Colour
An event names a colour label rather than a colour. IDX.eventLabel (91) carries the uuid of the
label the event overrides its calendar with, present only when it overrides. readLabelColours reads
the account's whole label set from payloadBlock[2], [null, "<labelId>", <colourId>, ...] per
calendar, and maps each label to a hex through colourForId; parseEventTuple resolves the event's
label to colorHex.
The wire carries a colour id and not a hex, and colourForId resolves it against the checked-in
palette: google-colours.json holds the 24 hexes in
file order, the position being the id. Peacock, id 14, is #039BE5, the colour Google paints an
account's own calendar. The palette is checked in rather than read at runtime because it is static;
Google has remapped it once, and if it happens again these hexes are what goes stale, visibly.
Event kind
IDX.eventType (82) is the only thing that separates entries every other field reads alike. Tasks
and contact birthdays have no block of their own: asking for tasks@tasks.google.com or
birthdays@birthdays.google.com returns an empty payload even when it is the only calendar named, so
their entries arrive inside the primary calendar's block and nothing keyed on a calendar id separates
them from an ordinary appointment. A title heuristic is not a substitute: an ordinary event with
"birthday" in its name is DEFAULT.
eventType |
Entry | Glyph |
|---|---|---|
1 |
From Gmail | none |
3 |
Out of office | none |
7 |
Focus time | headphones |
9 |
Task | circle, or checkmark.circle once complete |
10 11 |
Working location | house, building.2 or mappin.and.ellipse |
13 |
Birthday | birthday.cake |
An entry has one eventType, so it gets one glyph: kindGlyph in TrayModel.swift and
WidgetCache.swift maps it. A contact birthday carries the contact's id at IDX.contact (83).
FOLDED_INTO_PRIMARY re-files a birthday onto birthdays@birthdays.google.com and a task onto
tasks@tasks.google.com, so the entry takes that calendar's colour and name and leaves the screen
when the user hides it.
Recurring series
The payload carries a recurring meeting in two forms at once, and both decode into ordinary events.
Double arrival
The series master holds the RRULE at IDX.recurrence (12) and starts on the series' first date,
which for a birthday is 1958. Alongside it comes one event per occurrence Google has already
materialised, its id stamping the series and the start the rule gave that occurrence in UTC,
<seriesId>_20260918T084000Z, and its seriesId at IDX.seriesId (13) naming the master.
expandRecurringEvents in calendar-recurrence.ts generates only the occurrences the wire left out.
It records which slot each materialised event takes, keyed by series and day, and generates nothing
in a slot already taken. A series runs at most once a day at every frequency Google's own editor can
produce, so the day identifies the occurrence.
Three things make this easy to get wrong:
- The two forms of id never match. A generated occurrence is
<masterId>_R<YYYYMMDDTHHMMSS>and a materialised one is<seriesId>_<UTC YYYYMMDDTHHMMSS>Z, or<seriesId>_<YYYYMMDD>when it is all-day. Comparing ids leaves both in the agenda, and the alert engine then rings twice for one meeting, ten minutes before it starts. - A moved occurrence keeps the stamp of the slot it came from, not the time the user dragged it to, which is what still identifies the generated occurrence it replaces.
- An all-day series counts in UTC and a timed one counts locally. A timed event is an instant,
and the clock time the user sees is the local one, so its rule is walked in local time and follows
the reader's DST. An all-day event is a date: the wire sends it as midnight UTC, and it belongs on
that date wherever it is read. Reading an all-day anchor with local getters puts every birthday and
anniversary a day early for anyone west of UTC.
EventClockincalendar-recurrence.tsis the pair of readers, chosen per event byisAllDay; it also decides which day the slot key and the generated id name.
Where both exist, the wire's copy is the one kept: it carries whatever was edited on that occurrence
alone, such as a room the rest of the series does not use. IDX.seriesAnchor (55) is the series'
recurrence anchor, identical on every occurrence; it reads like an instance id and is not one.
IDX.uid (18) carries the iCalendar UID.
Recurrence rule
Everything RFC 5545 says about where an occurrence falls inside its period belongs to the
rrule package: the ordinal on BYDAY=4WE and BYDAY=-1FR,
BYSETPOS, BYMONTHDAY including negative and repeated values, BYMONTH, and WKST. COUNT and
UNTIL are its job too, and where a rule carries both the earlier of the two ends the series.
Two things wrap the library:
- A daily or weekly anchor moves up to just before the window. Walking from an anchor in 2015
takes about 500 ms a poll on a hundred-master calendar; skipping whole periods drops that to 20 ms
without shifting the phase. Monthly rules are left alone because month lengths vary, and rules
with a
COUNTare left alone because the count runs from the original anchor. - A series contributes at most
MAX_OCCURRENCES(1,000) occurrences to one window. Google's editor cannot create sub-daily rules, but imported.icsfiles can, and an hourly series over a month would otherwise generate 721 chips. An occurrence Google has already materialised still takes its place in the count, because the limit comes from the rule rather than from what the expansion generated.
A rule the library cannot read leaves the master in the agenda on its own date and generates nothing. That is the quiet failure: one event where a series was meant to be, rather than a series in the wrong place.
Range payload
Where each field sits is IDX, ATTENDEE and EVENT_TYPE in calendar-decoder.ts; those positions
belong beside the code that reads them. What is worth saying here is the shape, which no list of
fields conveys.
An event comes back once per calendar that holds it, and Magical records every one.
parsePrefetchEventRangeResponse merges the copies by event id into a single event carrying them all
in calendarIds, rather than picking a copy. Anything needing one calendar, for a colour or a name,
takes the first the user has not hidden; a filter drops the event only when it hides all of them. Two
objects for one meeting would arm its alert twice and count it twice in the Dock badge.
IDX.calendar (34) names the calendar holding the event's canonical copy, which for an invitation is
the organiser's. The calendar a copy sits on is the block it came back under. Read as "this event's
calendar", IDX.calendar gives every invitation a calendar the user does not have, so nothing keyed
by calendar id matches: not the colour, not the name, not the popover's hidden-calendar filter.
IDX.webUrl (2) carries the link whose eid names a calendar; the merge keeps the account's own
copy so the link can find the chip Google draws for the user. IDX.syncVersion (39) is present where
the payload carries one.
A calendar block echoes the range that was asked for and carries nothing else about the calendar:
no name, no colour. A block is [id, [event, ...], [null, null, startDay, endDay], n].
The committed fixture is sanitised, and sanitisation moved a field it did not move consistently. Its contact birthday carries a summary replaced with a meeting's, while the fields that identify it as a birthday were left untouched. The fixture is adequate for decoder unit tests and misleading for field discovery; measure against a live account.
Prefetch and fetch
The live client calls both sync.prefetcheventrange and sync.fetcheventrange, and they are not
different schemas: the same fields are populated in either, descriptions and conference blocks
included. They differ only in how the client uses them: prefetch calls name more calendars over a
wider range, fetch calls fewer over a narrower one. That is a caller's choice rather than a property
of the endpoint, and either serves the poller. prefetcheventrange remains the documented primary
because it is already the subject of
calendar-sync.md's Zero-OAuth session mechanism
and the canary.
Capture protocol
Everything on this page was read from a live signed-in account inside Magical's own
WebContentsView over CDP, by recording Google Calendar's own requests rather than crafting any.
Run against the account's own WebContentsView in a running build, not a browser. A browser console
proves the endpoint answers; only the in-app path proves Magical's partition, with its Chrome
identity headers, is served the same payload.
Record the real client's traffic rather than constructing a request: it removes any guesswork about
the f.req body and the required headers.
Captures contain real event titles, attendee addresses and meeting URLs. Keep them out of the repository, and hand-redact before any of it becomes a fixture.