Skip to content

Instantly share code, notes, and snippets.

@tyler-dane
Last active January 24, 2026 02:49
Show Gist options
  • Select an option

  • Save tyler-dane/5d18ecdab29edeabf526ee55cd584b32 to your computer and use it in GitHub Desktop.

Select an option

Save tyler-dane/5d18ecdab29edeabf526ee55cd584b32 to your computer and use it in GitHub Desktop.

TDD: Google Calendar Sync API

Document Type: Technical Design Doc (TDD)
PRD: https://gist.github.com/tyler-dane/f90243ecc79743943256a65685207c9a
Document Status: Draft
Author: fullstack.zip
Last Updated: January 2026
Reviewers: AI
Context: For an article on https://newsletter.fullstack.zip


Solution Overview

The Calendar API list endpoints implement synchronization by combining cursor pagination (nextPageToken) with incremental sync tokens (nextSyncToken). This design lets clients do an initial full sync, then perform efficient incremental sync calls that return only changes (including deletions) since the last successful sync, while ensuring consistency over paginated datasets.


API responsibilities

The API exposes list endpoints (for example, events.list, settings.list) that support both full and incremental sync through the syncToken request parameter and nextSyncToken response field. For large collections, the API also emits cursor-based nextPageToken values and expects the client to follow them using the pageToken request parameter until all pages are consumed. The server is responsible for generating and validating opaque sync tokens, deciding when tokens expire, and returning HTTP 410 GONE when a syncToken is invalid or too old, instructing clients to clear local state and perform a new full sync.

Generating and validating nextSyncToken

  • On a full sync request (no syncToken), the server tracks the logical state of the collection at the time of the list and generates a nextSyncToken on the final page of the response.
  • On an incremental sync request (with syncToken), the server reads the provided syncToken, validates it, and returns only entries that changed since that token, plus a new nextSyncToken on the final page.
  • A syncToken may become invalid or expire; in that case the server returns HTTP 410 GONE and does not attempt to repair or partially continue from that token.

Emitting nextPageToken for pagination

  • Whenever the result set is too large, the server responds with a nextPageToken and omits nextSyncToken on that page.
  • For example, settings.list responses include nextPageToken if more results are available, and only include nextSyncToken when there are no further pages.
  • The server guarantees that subsequent requests using pageToken=<nextPageToken> and otherwise identical query parameters will continue the same logical listing, eventually leading to a final page that includes nextSyncToken instead of nextPageToken.

Enforcing full vs incremental sync semantics

  • Full sync semantics (no syncToken):
    • Return all resources matching the request parameters (for example, timeMin, timeMax, showDeleted) as of the time the sync is initiated.
    • Paginate through the entire result using nextPageToken, with nextSyncToken only on the last page, to establish a baseline snapshot.
  • Incremental sync semantics (with syncToken):
    • Return only entries that have changed since the time the provided syncToken was issued, including deletions (for example, events with status=cancelled).
    • Require showDeleted=true semantics on events when using syncToken, enforced by the server in the Events API (attempting to set showDeleted=false with a syncToken is disallowed).
  • Token expiration / invalid tokens:
    • If the syncToken is too old or otherwise invalid, the server responds with HTTP 410 GONE and documentation instructs clients to discard local state and re-run a full sync without a syncToken.

Client Responsibilities

Clients must implement both full sync and incremental sync flows, and treat both sync and page tokens as opaque values to be persisted between runs.

Initial full sync

  • Do an initial list request without syncToken, optionally with filters such as timeMin and timeMax for events.
  • Process all items from the first page, then follow nextPageToken using the pageToken request parameter until a page is returned without nextPageToken and with nextSyncToken.
  • Persist the final nextSyncToken along with the client’s local snapshot so it can be used in subsequent incremental syncs.

Persisting and reusing nextSyncToken

  • After a successful sync run (full or incremental), clients must store the nextSyncToken durably (for example, database or key–value store) to survive restarts and crashes.
  • On the next sync cycle, the client uses the stored nextSyncToken as the syncToken parameter, preserving all original filters used to obtain that token (for example, time range, calendar ID, and any other relevant query parameters).

Following nextPageToken before using nextSyncToken

  • Clients must never treat the first nextSyncToken they see in any partial response; instead, they are required to read all pages until the final page that contains nextSyncToken.
  • When a response contains nextPageToken, the client must perform another list request with the same parameters plus pageToken=<nextPageToken> and, for incremental sync, the same syncToken value, until nextPageToken is omitted and nextSyncToken is present.

Handling error responses and invalid tokens

  • On HTTP 410 GONE, clients are expected to:
  • Clear the stored syncToken for that collection,
  • Optionally clear or rebuild any cached local representation,
  • Perform a new full sync (without syncToken).
  • Other error codes (for example, authorization or quota errors) must be handled according to normal API error handling practices, but do not change sync semantics.

Implementation: Core Concepts

nextSyncToken

The  nextSyncToken response field represents a sync token that encapsulates the logical point in time at which the response’s collection snapshot was completed. It is only returned on the final page of a list response, and is omitted when  nextPageToken  is present.

When and where nextSyncToken is returned

  • During a full sync, the client issues a list request without  syncToken ; if the dataset fits in a single response, the response includes  nextSyncToken  directly.
  • If the dataset is paginated, intermediate pages include  nextPageToken  and omit  nextSyncToken , while only the last page omits  nextPageToken  and includes  nextSyncToken .
  • The same pattern applies to incremental sync: when a  syncToken  is supplied, the server again paginates changes and only includes a new  nextSyncToken  on the final page.

How clients store and use nextSyncToken

  • After reading the final page, clients must persist  nextSyncToken  as the new sync token associated with the exact same filter context used in the requests (for example, specific calendar ID and any query parameters that affect membership of the collection).
  • The next incremental sync sends this stored value as  syncToken=<stored token> , allowing the server to return only changes since that point. Opacity of the token

Token opacity

  • Clients must treat the sync token as an opaque string, making no assumptions about its internal structure, time encoding, or stability across different filter sets.
  • This opacity allows the backend to evolve implementation details (for example, storage schema, versioning, and encoding) without breaking existing clients, and to reject invalid combinations of token and parameters via 410 responses when necessary.  nextPageToken  and its relationship to  nextSyncToken

Pagination

The Calendar API uses cursor pagination rather than numeric offsets, exposing  nextPageToken in list responses when more results are available. The  nextSyncToken appears only when there are no further pages for the current sync operation.

When  nextPageToken is returned instead of  nextSyncToken

  • Any response where the server knows that the client has not yet seen all items matching the request includes  nextPageToken and omits  nextSyncToken.
  • Only when the server has delivered the final page of results does it omit  nextPageToken and include  nextSyncToken.

Implementation: Examples

The juicy stuff

Sync Flow Example

sequenceDiagram
    participant Client as API Client
    participant API as Calendar API
    participant Local as Client Local DB

    Client->>API: GET /calendarList
    API-->>Client: List of calendars + access levels

    loop For each calendar
        Client->>API: GET /events (no syncToken)
        Note over API: Returns first page + pageToken
        API-->>Client: Events page 1 + pageToken
        Client->>Local: Insert events

        loop While pageToken exists
            Client->>API: GET /events?pageToken={token}
            API-->>Client: Events page N + pageToken/syncToken
            Client->>Local: Insert events
        end

        Note over Client: Final page includes syncToken
        Client->>Local: Store syncToken for calendar
    end

    Client->>API: POST /events/watch (register push)
Loading
// This flow follows all pages, then saves  nextSyncToken  only once per sync cycle
function syncSettings() {
  token = loadSyncToken("settings")

  pageToken = null
  do {
    params = {}
    if token != null:
      params["syncToken"] = token
    if pageToken != null:
      params["pageToken"] = pageToken

    response = GET /users/me/settings with params

    if response.status == 410:
      clearSyncToken("settings")
      clearLocalSettings()
      return syncSettings()  // restart with full sync

    for setting in response.items:
      upsertLocalSetting(setting)

    pageToken = response.nextPageToken
    if pageToken == null:
      token = response.nextSyncToken
  } while pageToken != null

  if token != null:
    saveSyncToken("settings", token)
}

Final page example:

GET https://www.googleapis.com/calendar/v3/users/me/settings?maxResults=100&pageToken=CJj9wPXr0_0C
{
  "kind": "calendar#settings",
  "etag": "\"abc124\"",
  "nextSyncToken": "CPDAlvWDx70CEPDAlvWDx" /* use this for next sync */,
  "items": [
    /* ... */
  ]
}

Notification Payload Structure

{
  "kind": "calendar#notification",
  "resourceId": "calendar-resource-id",
  "resourceUri": "https://www.googleapis.com/calendar/v3/calendars/primary/events",
  "channelId": "client-provided-uuid",
  "channelExpiration": "2026-02-23T12:00:00.000Z",
  "messageNumber": 1234567,
  "resourceState": "sync",
  "changed": "events"
}

Channel Registration

sequenceDiagram
    participant Client as API Client
    participant API as Calendar API
    participant PubSub as Push Infrastructure
    participant Webhook as Client Webhook

    Client->>API: POST /calendars/{calendarId}/events/watch
    Note over Client,API: Include callback URL, channel ID, TTL
    API->>API: Validate domain ownership
    API->>PubSub: Register channel subscription
    API-->>Client: 200 OK (resourceId, expiration)

    Note over API: User modifies event
    API->>PubSub: Publish change notification
    PubSub->>Webhook: POST notification payload
    Webhook-->>PubSub: 200 OK (acknowledge)

    Note over Client: Channel approaching expiration
    Client->>API: POST /channels/stop (old channel)
    Client->>API: POST /calendars/{calendarId}/events/watch (new channel)
Loading

Handling Token State Changes

stateDiagram-v2
    [*] --> NoToken: First connection
    NoToken --> FullSync: Request without syncToken
    FullSync --> HasToken: Receive nextSyncToken
    HasToken --> IncrementalSync: Request with syncToken
    IncrementalSync --> HasToken: Receive nextSyncToken
    HasToken --> TokenExpired: Token > 7 days old
    HasToken --> TokenInvalidated: Server-side invalidation
    TokenExpired --> FullSync: HTTP 410 response
    TokenInvalidated --> FullSync: HTTP 410 response
Loading

Handling Token Revocation

sequenceDiagram
    participant User as User
    participant Google as Google Account
    participant Client as API Client
    participant API as Calendar API

    User->>Google: Revoke app access
    Google->>API: Invalidate tokens

    alt API Call Path
        Client->>API: Any API request
        API-->>Client: 401 invalid_grant
        Client->>Client: Clear credentials & local data
        Client->>Client: Prompt re-authentication
    else Push Channel Path
        API->>Client: Channel termination notification
        Note over Client: resourceState: "sync_disabled"
        Client->>Client: Clear credentials & local data
    end
Loading

Batch Requests

The API supports batched requests using multipart/mixed content type, conforming to the Google API batch protocol.

POST /batch/calendar/v3
Content-Type: multipart/mixed; boundary=batch_boundary

--batch_boundary
Content-Type: application/http
Content-ID: <item1>

POST /calendar/v3/calendars/primary/events
Content-Type: application/json

{"summary": "Event 1", "start": {...}, "end": {...}}

--batch_boundary
Content-Type: application/http
Content-ID: <item2>

POST /calendar/v3/calendars/primary/events
Content-Type: application/json

{"summary": "Event 2", "start": {...}, "end": {...}}

--batch_boundary--

Batch Response Flow

sequenceDiagram
    participant User as User
    participant GCal as Google Calendar
    participant API as Calendar API
    participant Push as Push Infrastructure
    participant Client as API Client
    participant Local as Client Local DB

    User->>GCal: Modify event
    GCal->>API: Commit change
    API->>Push: Publish notification
    Push->>Client: Webhook POST
    Note over Client: Notification contains resourceId only
    Client->>API: GET /events?syncToken={stored_token}
    API-->>Client: Delta: modified event + new syncToken
    Client->>Local: Merge changes
    Client->>Local: Store new syncToken
Loading

Batch Implementation Flow

sequenceDiagram
    participant Client as API Client
    participant Queue as Client Queue
    participant API as Calendar API

    Note over Client: User imports 500 events from iCal
    Client->>Queue: Queue all events

    loop Process in batches of 50
        Queue->>Client: Dequeue 50 events
        Client->>API: POST /batch (50 events)
        API-->>Client: Batch response (50 results)

        loop For each result
            alt Success (200/201)
                Client->>Client: Mark complete, store eventId
            else Rate Limited (429)
                Client->>Queue: Re-queue with backoff
            else Conflict (409)
                Client->>Client: Log duplicate, skip
            else Error (4xx/5xx)
                Client->>Client: Log error, notify user
            end
        end

        Client->>Client: Wait (rate limit spacing)
    end
Loading

Conflict Resolution Strategy

flowchart TD
    A[Client attempts update] --> B{Include If-Match header?}
    B -->|Yes| C{ETag matches server?}
    B -->|No| D[Server accepts unconditionally]
    C -->|Yes| E[Update succeeds]
    C -->|No| F[412 Precondition Failed]
    F --> G{Client conflict policy}
    G -->|Server wins| H[Fetch latest, discard local]
    G -->|Client wins| I[Force update without If-Match]
    G -->|Merge| J[Fetch latest, merge fields, retry]
    G -->|User decides| K[Present both versions to user]
Loading

Appendix A: API Endpoint Reference

Endpoint Method Description
/calendars/{calendarId}/events GET List events (supports sync)
/calendars/{calendarId}/events POST Create event
/calendars/{calendarId}/events/{eventId} GET Get single event
/calendars/{calendarId}/events/{eventId} PUT Replace event
/calendars/{calendarId}/events/{eventId} PATCH Update event fields
/calendars/{calendarId}/events/{eventId} DELETE Delete event
/calendars/{calendarId}/events/watch POST Subscribe to push notifications
/channels/stop POST Unsubscribe from push notifications
/users/me/calendarList GET List user's calendars
/batch/calendar/v3 POST Batch multiple requests

Appendix B: Comparison with Similar APIs

Feature Google Calendar API Microsoft Graph Apple CloudKit
Push mechanism Webhook Webhook + SignalR CloudKit subscriptions
Incremental sync Sync token Delta token Change tokens
Conflict resolution ETag + sequence ETag Record change tags
Batch support 50 requests 20 requests CloudKit batch
Rate limiting Per-user + per-app Per-app Per-container
Offline support Client responsibility Client responsibility Native (CloudKit)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment