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
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.
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.
- On a full sync request (no
syncToken), the server tracks the logical state of the collection at the time of the list and generates anextSyncTokenon the final page of the response. - On an incremental sync request (with
syncToken), the server reads the providedsyncToken, validates it, and returns only entries that changed since that token, plus a newnextSyncTokenon the final page. - A
syncTokenmay 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.
- Whenever the result set is too large, the server responds with a
nextPageTokenand omitsnextSyncTokenon that page. - For example,
settings.listresponses includenextPageTokenif more results are available, and only includenextSyncTokenwhen 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 includesnextSyncTokeninstead ofnextPageToken.
- 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, withnextSyncTokenonly on the last page, to establish a baseline snapshot.
- Return all resources matching the request parameters (for example,
- Incremental sync semantics (with
syncToken):- Return only entries that have changed since the time the provided
syncTokenwas issued, including deletions (for example, events withstatus=cancelled). - Require
showDeleted=truesemantics on events when usingsyncToken, enforced by the server in the Events API (attempting to setshowDeleted=falsewith asyncTokenis disallowed).
- Return only entries that have changed since the time the provided
- Token expiration / invalid tokens:
- If the
syncTokenis 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 asyncToken.
- If the
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.
- Do an initial list request without
syncToken, optionally with filters such astimeMinandtimeMaxfor events. - Process all items from the first page, then follow
nextPageTokenusing thepageTokenrequest parameter until a page is returned withoutnextPageTokenand withnextSyncToken. - Persist the final
nextSyncTokenalong with the client’s local snapshot so it can be used in subsequent incremental syncs.
- After a successful sync run (full or incremental), clients must store the
nextSyncTokendurably (for example, database or key–value store) to survive restarts and crashes. - On the next sync cycle, the client uses the stored
nextSyncTokenas thesyncTokenparameter, preserving all original filters used to obtain that token (for example, time range, calendar ID, and any other relevant query parameters).
- Clients must never treat the first
nextSyncTokenthey see in any partial response; instead, they are required to read all pages until the final page that containsnextSyncToken. - When a response contains
nextPageToken, the client must perform another list request with the same parameters pluspageToken=<nextPageToken>and, for incremental sync, the samesyncTokenvalue, untilnextPageTokenis omitted andnextSyncTokenis present.
- On HTTP 410 GONE, clients are expected to:
- Clear the stored
syncTokenfor 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.
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
nextSyncTokenas 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
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
nextPageTokenand omitsnextSyncToken. - Only when the server has delivered the final page of results does it omit
nextPageTokenand includenextSyncToken.
The juicy stuff
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)
// 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)
}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": [
/* ... */
]
}{
"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"
}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)
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
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
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--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
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
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]
| 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 |
| 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) |