Google Calendar API in Swift: The Package I Missed
I hand-built a Google Calendar API client in Swift, then migrated hora to Google's GTLR library. See the code, tradeoffs, and rate-limit lessons.

If you need the Google Calendar API in Swift, start by looking at Google's generated REST client. It is an Objective-C library for Apple platforms, installs through Swift Package Manager, and exposes Calendar types such as GTLRCalendarService and GTLRCalendarQuery_EventsList.
I learned this after spending roughly a month building the same API surface with URLSession.
That sentence is painful to write, because it sounds like the kind of thing you are supposed to know before touching the code. But I was learning the Swift ecosystem in public, and the obvious thing was not obvious to me yet.
Would I hand-roll this integration again? No.
Was the month a waste of time? Also no.
The package removed a lot of transport code. The mistakes I made before finding it taught me which parts of a calendar integration no package can own for me.
Why I wrote the Google Calendar REST client by hand
The first version of hora's Google integration was a local Swift package called HoraGoogleAPI. It had no external Google client dependency. It was Foundation, access tokens, URLSession, JSON decoding, and endpoint-specific code.
The manifest was almost comically small:
// Packages/HoraGoogleAPI/Package.swift, before the migration
dependencies: [
.package(path: "../HoraCore"),
]
targets: [
.target(
name: "HoraGoogleAPI",
dependencies: ["HoraCore"]
),
]Fetching events looked like normal app code:
let encodedID = calendarID.addingPercentEncoding(
withAllowedCharacters: .urlPathAllowed
) ?? calendarID
var components = URLComponents(
string: "\(baseURL)/calendars/\(encodedID)/events"
)!
components.queryItems = [
URLQueryItem(name: "timeMin", value: formatter.string(from: min)),
URLQueryItem(name: "timeMax", value: formatter.string(from: max)),
URLQueryItem(name: "singleEvents", value: "true"),
URLQueryItem(name: "maxResults", value: "2500"),
]
let data = try await authenticatedRequestWithRetry(url: components.url!)
let response = try JSONDecoder().decode(GoogleEventsResponse.self, from: data)There is nothing inherently wrong with that code. The problem is everything hidden inside the word "events."
A real calendar client has to list calendars, fetch colors, create and patch events, move events, expand recurring instances, respond to invitations, preserve fields written by other clients, register watch channels, refresh OAuth sessions, page through responses, recover invalid sync tokens, and do all of it without freezing the UI or burning through quota.
I underestimated that surface area.
The choice to build hora as a native Swift app was still deliberate. I wrote about that separately in Native App vs Electron and PWA. Writing Google's entire transport layer myself was not part of that product decision. It was just a detour.
What hand-rolling the API taught me
My Linear board became a map of everything I did not know. The useful lessons were not the ticket numbers. They were the contracts hiding behind operations that looked simple.
A partial event model changes how you update data. PATCH was safer for hora than rebuilding a full event with PUT, because fields omitted from a patch remain unchanged. That protects metadata the app does not model. It is not magic, though. Arrays included in a patch are replaced in full, and Google notes that a patch consumes more quota units than a normal request. For hora, preserving unknown fields was the more important tradeoff. Google's Events.patch reference documents both behaviors.
A new local event does not have a Google identity yet. hora now gives an optimistic event a temporary hora-temp identifier until events.insert returns the real Google ID. A second edit, move, or delete has to wait instead of sending a request against the temporary value.
Moving an event is not delete plus insert. For eligible default events on calendars owned by the same signed-in account, hora uses events.move. The user must be able to change the organizer, so this is not a universal path, but it keeps the event's identity and avoids reconstructing it from the subset of fields hora knows. Cross-account moves still need a different strategy. The restrictions are described in Google's Events.move documentation.
Recurring events are a protocol, not a checkbox. A series has a master, generated instances, moved exceptions, cancellations, and rules for what "this event" or "this and following" should mean. A typed query object can call events.instances, but it cannot decide how the result should reconcile with the local calendar.
That knowledge was expensive. I paid for it with time, but it became the domain layer that survived the migration.
The rate-limit bug that exposed the real complexity
The most humbling bug was rate limiting.
I already had exponential backoff, but beta builds still hit per-user quota failures during sync, push renewal, and invitation refresh. Retry only delayed a request storm. It did not control its shape.
So I added client-side throttling at 8 requests per second with a burst of 10 inside each GoogleCalendarService instance. SyncManager also starts a 60-second cooldown after two consecutive rate-limit failures. A manual "Sync now" action can bypass that cooldown when the user knows the account should be reachable again.

One detail matters here: this is service-local throttling, not one global per-account quota governor. The app can create more than one service instance during synchronization. It still reduces bursts at the request boundary, while the separate sync cooldown stops repeated failing cycles.
At that point I also instrumented request duration, API errors, cooldown trips, and token-bucket waits. Those exact hooks have since changed with hora's observability layer, but the lesson remains: rate limits are product behavior. Users experience them as stale data, a spinner, or an edit that appears not to stick.
Google's client library did not remove this problem. It gave me a better request primitive on which to solve it.
Adding Google's Calendar client with Swift Package Manager
The package I should have investigated first is the Google APIs Client Library for Objective-C for REST. Google describes it as its recommended library for JSON-based Google APIs on Apple platforms and generates service interfaces for APIs such as Calendar and People.
It is important to call it what it is. This is not a modern Calendar SDK written natively in Swift. It is Google's Objective-C REST client, made available to a Swift project through Swift Package Manager.
hora currently declares the Calendar client and GTMAppAuth like this:
.package(
url: "https://github.com/google/google-api-objectivec-client-for-rest.git",
from: "5.2.0"
),
.package(
url: "https://github.com/google/GTMAppAuth.git",
from: "5.0.0"
)The types are unmistakably Objective-C:
let query = GTLRCalendarQuery_EventsList.query(
withCalendarId: calendarID
)
query.timeMin = GTLRDateTime(date: min)
query.timeMax = GTLRDateTime(date: max)
query.singleEvents = true
query.maxResults = 2500That query replaced manual URL construction and query-item encoding. It did not make the protocol impossible to misuse.
An earlier version also set orderBy = startTime. The current client deliberately leaves it out because later incremental requests with syncToken cannot include orderBy. hora sorts the merged events locally instead. Google's incremental synchronization guide requires compatible query parameters across the synchronization cycle and a full rebuild when a token is invalidated.
Writes follow the same pattern. hora builds a GTLRCalendar_Event, then uses the generated patch query:
let eventObject: GTLRCalendar_Event = makeGTLRObject(
GTLRCalendar_Event.self,
json: jsonObject
)
let query = GTLRCalendarQuery_EventsPatch.query(
withObject: eventObject,
calendarId: event.calendarID,
eventId: event.googleEventID
)
query.sendUpdates = sendUpdatesTyped queries removed URL mistakes. They did not remove calendar mistakes.
What GTLR handles and what hora still owns
The main migration commit, c7d03e9, changed 10 files with 468 insertions and 407 deletions. It replaced most hand-built Calendar and People request URLs with generated GTLR query types.
It did not delete the integration layer.

| Google's client supplies | hora still owns |
|---|---|
| Generated Calendar and People query types | Event payload mapping and app models |
| Generated Google resource types | Response decoding into hora's domain |
Query execution through GTLRService | Bearer token injection for Calendar queries |
| Support for retry and automatic paging | Calendar retry policy and manual page loops |
| Objective-C result and error objects | Error presentation, deadlines, cache, and reconciliation |
For Calendar requests, hora sets isRetryEnabled = false and shouldFetchNextPages = false. It clones queries for each attempt, performs its own pagination, refreshes once after a 401, and applies different retry limits to rate-limit, server, and network failures. The smaller People search adapter still uses GTLR's built-in retry path.
Authentication has a similar boundary. GTMAppAuth stores the OAuth session, refreshes it, and works with Keychain. hora then asks its TokenProvider for an access token and injects the Bearer header into each Calendar query.
Not every endpoint moved. FreeBusyService still uses URLSession directly. The migration was not an ideological purge of handwritten networking. It was a decision to stop manually reproducing Google's generated Calendar and People surface where the official client was the better primitive.
The first commit also needed follow-up work. I fixed query wiring and push deduplication, prewarmed GTLR types after test crashes, and taught delete and channel-stop operations to accept an empty success response. It was not a one-click dependency swap. It was a migration, then a production-hardening pass.
For the broader architecture choice behind hora, read Google Calendar API vs CalDAV. For the notification and recovery path above this client, see how real-time Google Calendar sync works in hora.
What I would do differently today
If I were starting from zero, I would spend the first hour looking for the boring official package.
Then I would build a thin, explicit boundary around it:
- Generated GTLR types at the transport edge.
- One adapter for access tokens, retries, pagination, deadlines, and error mapping.
- Repository code that speaks in hora concepts instead of Google transport objects.
- Sync orchestration that owns tokens, caching, reconciliation, and optimistic UI.
- Early testing with large, old, messy calendars instead of only a clean development account.
That is close to where the app is now.
The mistake was not writing code. The mistake was assuming that because REST looks simple, the integration would stay simple. Calendar APIs punish that assumption. They contain shared ownership, recurring exceptions, stale OAuth scopes, per-user overrides, quota limits, and synchronization contracts that usually appear only after real accounts connect.
Was the month of manual work wasted?
No. I would not repeat it, but I am glad I did it once.
If I had started with GTLR on day one, I probably would have shipped faster. I also would have understood less.
I would have seen GTLRCalendarQuery_EventsMove and thought, "nice, there is a move method." Instead, I first saw what delete and insert could lose, then understood why event identity matters.
I would have used EventsPatch without feeling the damage of treating a partial local model as a complete Google event. I would have treated quota as an operational detail instead of something that shapes the synchronization architecture.
The generated library now owns the repetitive request surface. The month of handwritten work left behind the part I actually want hora to own: product semantics, failure policy, and a much sharper sense of where the abstraction leaks.
The integration described here is the sync layer behind hora Calendar 1.0. If your schedule already lives in Google Calendar and you want a native Mac client built around that API, try hora Calendar on the Mac App Store.
Google Calendar API client FAQ
06 questions / quick answers


