Guide
Google Calendar API Pagination and Sync
Google Calendar's REST API requires you to handle pagination tokens, sync tokens, recurring event expansion, and one sync state per calendar — all in your own code. This guide explains how nextPageToken and syncToken work, then shows how to skip both with one command. Works with Google Calendar, Outlook, Exchange, iCloud, and Yahoo.
Written by Qasim Muhammad Staff SRE
Reviewed by Nick Barraclough
How Google Calendar API pagination works
Google Calendar API pagination splits event lists across multiple HTTP responses, each containing a nextPageToken string the caller passes back to fetch the following page. The events.list endpoint sits under /calendars/{calendarId}/events, so pagination state is scoped per calendar — a user with five calendars has five independent pagination cursors.
According to the Google Calendar API events.list documentation, the default page size is 250 and the maximum is 2,500 results per request (5x larger than Gmail's messages.list cap of 500). A calendar with 10,000 events therefore requires a minimum of 4 sequential API calls at maximum page size, or 40 calls at the default. The loop below paginates a single calendar:
from googleapiclient.discovery import build
service = build("calendar", "v3", credentials=creds)
all_events = []
page_token = None
while True:
response = service.events().list(
calendarId="primary",
maxResults=2500,
pageToken=page_token,
singleEvents=True,
orderBy="startTime",
).execute()
all_events.extend(response.get("items", []))
page_token = response.get("nextPageToken")
if not page_token:
break
print(f"Fetched {len(all_events)} events")That code paginates one calendar. Users typically have a primary calendar plus shared, secondary, and subscribed calendars, so a production sync client wraps this loop in an outer loop over calendarList.list results.
How Google Calendar incremental sync works
Google Calendar incremental sync uses a syncToken that is returned only on the final paginated response — the page where nextPageToken is absent. You store that token per calendar. On the next sync, pass it as syncToken to events.list and the response contains only events created, updated, or deleted since the last sync. According to the Calendar API sync guide, this is the recommended pattern for keeping a local copy current.
When a syncToken becomes invalid (typically after several weeks of inactivity, or when Google rotates the token), events.list returns 410 Gone. Gmail's analogous failure returns 404 instead. Your code must catch 410, discard the stored token, and re-run a full pagination from scratch to obtain a fresh syncToken. The code path looks like this:
def incremental_sync(service, calendar_id, stored_token):
"""Sync changes since stored_token, or full-sync on 410."""
try:
response = service.events().list(
calendarId=calendar_id,
syncToken=stored_token,
).execute()
return response.get("items", []), response.get("nextSyncToken")
except HttpError as e:
if e.resp.status == 410:
# Token invalidated — full re-paginate
return full_paginate(service, calendar_id)
raiseNote one more subtlety: syncToken requests cannot use most of the filter parameters that work on initial pagination, including q (text search), timeMin, timeMax, or singleEvents. If you initialized a sync with singleEvents=True, every subsequent incremental call must also omit it, or the API returns 400 Bad Request.
The recurring-events problem
Google Calendar represents a weekly standup or a monthly all-hands as one event with a recurrence rule (RRULE, per RFC 5545) — not as separate events for each occurrence. The events.list endpoint behaves differently depending on the singleEvents parameter. With singleEvents=False (the default), the response includes recurring events as their master records only; you receive one entry for the standup and must expand it client-side. With singleEvents=True, the API expands every recurrence into individual instances and the response can be 10-100x larger.
Expanding 500 master events with daily recurrences over a 90-day window produces 45,000 instances. The same query without singleEvents returns 500 rows. Both have legitimate use cases (analytics want master events, dashboards want instances), but the contract changes how pagination cost scales. Setting timeMin and timeMax bounds the expansion window and is required when using singleEvents=True on a calendar with open-ended recurrences.
The problems with doing it yourself
Building a production-grade Google Calendar sync client is more complex than the Gmail equivalent because of the per-calendar pagination model and the recurring-event expansion. What starts as a 25-line loop expands to 150-200 lines once you handle every required case:
- One sync cursor per calendar — a user with 8 connected calendars needs 8 stored
syncTokenvalues, 8 full-sync fallback paths, and 8 sets of state-tracking metadata. - 410 Gone fallback — every incremental call needs a try/except around the
410, with a re-paginate code path that resets one calendar without touching the others. Two code paths, both have to work correctly. - OAuth2 token lifecycle — Google Calendar access tokens expire every 3,600 seconds. The sync loop needs a refresh-token callback, retry logic on expired tokens, and persistent storage for the refresh token itself.
- Recurring event semantics — analytics pipelines need master events, dashboard UIs need expanded instances, and a single mismatch between
singleEventson the initial sync and the incremental sync returns400 Bad Request. - Rate limits — Google Calendar API enforces 600 queries per minute per user and 1,000,000 queries per day per project. A naive per-calendar sync loop hits the per-user limit faster than the global one, especially when expanding recurring events.
- OAuth consent screen — before any code runs, you need a Google Cloud project, an OAuth consent screen configured at console.cloud.google.com, a client ID and secret, the
calendarorcalendar.readonlyscope, and a redirect URI. That's 15-20 minutes of clicking through web forms.
List Google Calendar events with one command
Nylas CLI replaces the entire pagination, sync, and OAuth stack with one terminal command. Where the Calendar API approach requires 150-200 lines of Python and a Google Cloud project, the CLI reduces it to one line and a 2-minute install.
The three commands below cover the most common patterns: listing upcoming events, scoping to a date range, and reading a single event. Each runs one API call under the hood while the CLI handles pagination across provider responses:
# List the next 50 upcoming events on the primary calendar
nylas calendar events list --limit 50 --json
# Events in the next 7 days
nylas calendar events list --days 7 --json
# Read one event by ID
nylas calendar events show <event-id> --jsonSee nylas calendar events list, nylas calendar events show, and the full command reference for every flag.
Install with Homebrew, then authenticate once with nylas auth login. Other install methods (shell script, PowerShell, Go) are documented in the getting started guide:
brew install nylas/nylas-cli/nylas
nylas auth loginListing all calendars before paginating events
A typical Google Workspace user has 4-8 calendars: the primary calendar, 1-3 shared team calendars, an optional secondary personal calendar, and 1-2 subscribed calendars (US holidays, sports schedules, contact birthdays). Each one is a separate pagination scope. The Calendar API requires you to call calendarList.list first to enumerate accessible calendars, then loop events.list on each calendarId, multiplying request count and stored sync state by N.
The CLI uses nylas calendar list for that enumeration. Combined with nylas calendar events list --calendar-id, a single shell script paginates every calendar:
# List every calendar the user has access to
nylas calendar list --json
# Loop pagination across all calendars
for cal in $(nylas calendar list --json | jq -r '.[].id'); do
nylas calendar events list \
--calendar-id "$cal" \
--days 30 \
--json
donePaginating across multiple accounts
Production calendar workflows often span multiple connected Google accounts — an executive aggregating their work and personal calendars, an assistant scheduling across five client accounts, or a sales-ops tool reading every rep's calendar in a workspace. Google Calendar enforces quota per OAuth grant, so syncing 10 accounts in parallel works without cross-account throttling, but the application code has to track 10 sets of refresh tokens, 10 per-calendar syncToken bundles, and 10 active grant states.
Grants are first-class in the CLI. nylas auth list shows every connected account. nylas auth whoami prints which grant the next command will use. nylas auth switch changes the active grant. Every calendar command accepts an explicit --grant-id flag so a single shell script can iterate across grants without changing active state.
# Show every connected Google grant
nylas auth list --provider google --json
# Pull today's events from every connected calendar account
for grant in $(nylas auth list --provider google --json | jq -r '.[].id'); do
nylas calendar events list \
--grant-id "$grant" \
--days 1 \
--json
doneSyncing in CI, cron jobs, and headless environments
Google Calendar OAuth2 access tokens expire every 3,600 seconds, and the browser-based refresh flow does not work in CI, Docker, AI agent sandboxes, or any unattended environment. Google's offline-access flow requires a one-time interactive setup to capture a refresh token, which the application then stores in a secret manager and reuses for the next 6 months before re-consent. The service-account alternative requires Google Workspace Domain-Wide Delegation, an admin-only feature unavailable to consumer Google accounts.
Nylas CLI sidesteps the browser with API-key authentication. nylas auth config --api-key stores a key without touching a browser. nylas auth token generates a scoped bearer token for downstream API calls. nylas auth status reports the current auth state, useful for health checks in containerized deploys.
# Generate a daily schedule digest in a cron job — no browser
export NYLAS_API_KEY="nyk_..."
nylas auth config --api-key "$NYLAS_API_KEY"
nylas calendar events list --days 1 --json > /var/log/agenda.jsonWebhooks instead of polling
Polling a Google Calendar inbox every 5 minutes generates 288 API calls per day per account. Across 1,000 connected users that is 288,000 calls daily, and most return zero changes. Google Calendar offers a push-notification alternative via Cloud Pub/Sub or a webhook callback URL, but the setup requires a Pub/Sub topic, IAM bindings for calendar-api-push@system.gserviceaccount.com, watch channels on each calendar, and a renewal job because Google expires watches every 7 days.
Webhooks in the CLI register without a Pub/Sub topic. nylas webhook create registers an HTTPS endpoint and a list of triggers. nylas webhook list shows what is registered. nylas webhook triggers lists every supported event including event.created, event.updated, event.deleted, and calendar.created. nylas webhook test send fires a sample payload at your endpoint so you can validate the receiver. nylas webhook verify validates the HMAC signature on incoming payloads.
# Register a webhook for calendar event changes
nylas webhook create \
--url https://example.com/hooks/calendar \
--triggers event.created,event.updated,event.deleted \
--json
# Verify an inbound payload signature
nylas webhook verify \
--payload-file ./incoming.json \
--signature "$X_NYLAS_SIGNATURE" \
--secret "$WEBHOOK_SECRET"For typical calendar usage, webhook event volume averages 5-20 events per user per day, compared to the 288 polling calls. Latency between an event change and the application seeing it drops from up to 5 minutes to roughly 1 second.
How other calendar providers handle pagination
Google Calendar is not the only provider with a pagination contract. Microsoft Graph (Outlook and Exchange Online calendars) uses @odata.nextLink, a full URL the client follows verbatim, plus a delta-link mechanism for incremental sync. CalDAV (iCloud, Yahoo, hosted Apple) doesn't paginate in the REST sense: REPORT queries with calendar-query filters return matching events in a single response, with sync handled through sync-collection and ETags. Exchange Web Services (EWS, used by older Exchange Server deployments) uses FindItem with IndexedPageItemView.
| Provider | Pagination method | Incremental sync | Max page size |
|---|---|---|---|
| Google Calendar | nextPageToken | syncToken | 2,500 |
| Microsoft Graph (Outlook/Exchange) | @odata.nextLink | delta link | 1,000 |
| CalDAV (iCloud, Yahoo) | No pagination | sync-collection + ETag | No page limit |
| EWS (legacy Exchange) | IndexedPageItemView | SyncFolderItems | 1,000 |
Per-provider guides walk through the same problem in each contract: Manage Google Calendar from the terminal, Manage Outlook calendar, Manage Exchange calendar, Manage iCloud calendar, and Manage Yahoo calendar. The same nylas calendar events list command is documented to run against every provider with identical flags.
Provider-side behavior for Outlook, Exchange, iCloud, and Yahoo described above comes from each provider's public documentation, not from a verified end-to-end run on every backend. Test locally before deploying provider-specific workflows.
How long does syncing a Google Calendar take?
Syncing a single Google Calendar with 500 events runs in about 2 seconds via one CLI command, and a multi-calendar account with 50,000 expanded events runs in about 1 minute. The same workload takes ~4 seconds and ~6 minutes respectively via a sequential Python pagination loop with backoff, because the loop cannot fan out across the per-user 600-queries-per-minute ceiling without explicit thread pools. Benchmarks below were measured on a residential broadband connection with ~150 ms median latency to Google servers.
| Account size | Python events.list loop | Nylas CLI | API calls |
|---|---|---|---|
| 1 calendar, 500 events | ~4 sec | ~2 sec | 1-2 |
| 3 calendars, 5,000 events | ~25 sec | ~5 sec | ~10 |
| 5 calendars, 20,000 events (expanded) | ~2 min | ~25 sec | ~40 |
| 8 calendars, 50,000 events (expanded) | ~6 min (with backoff) | ~1 min | ~100 |
Wall-clock difference comes mostly from concurrency. Python's naive sequential loop iterates calendars one at a time; the CLI fans out per-calendar pagination in parallel up to the per-user 600-queries-per-minute ceiling. The API call counts above stay the same — the CLI cannot make Google's underlying calls cheaper, only faster to dispatch.
Common recipes
Four shell patterns combining calendar pagination with standard UNIX tools. Each uses jq to parse JSON output and --json for machine-readable formatting.
Today's agenda
A daily agenda script wraps nylas calendar events list --days 1 in a jq filter that prints start time and title for every event in the next 24 hours. Useful for shell greeting prompts, terminal dashboards, or piping into an LLM for a morning summary. Runs in about 2 seconds against an average inbox.
nylas calendar events list --days 1 --json \
| jq -r '.[] | "\(.when.start_time) - \(.title)"'Find free time across multiple calendars
nylas calendar find-time queries free/busy data for every participant and returns slots where everyone is free for the requested duration. The CLI handles timezone normalization across attendees, so a 30-minute slot proposed at 9 AM PT also reads as 12 PM ET in the response. Pair with nylas calendar availability check for raw busy windows.
nylas calendar find-time \
--participants you@example.com,colleague@example.com \
--duration 30 \
--days 7 \
--jsonDetect conflicts in this week's calendar
nylas calendar ai conflicts scans the next N days and flags three severity levels: hard conflicts (simultaneous events), soft conflicts (less than 15 minutes between meetings), and travel-time risks. Default look-ahead is 7 days. Pair with nylas calendar ai reschedule to propose fixes for each conflict.
nylas calendar ai conflicts --days 7 --jsonBulk-decline events from a specific organizer
Combine nylas calendar events list with nylas calendar events rsvp to decline every invite from one sender in one pipeline. The RSVP command accepts --status yes, --status no, or --status maybe. Substitute nylas calendar events delete when you own the event. For inbox-style analytics, see nylas calendar analyze.
nylas calendar events list --json \
| jq -r '.[] | select(.organizer.email == "noisy@example.com") | .id' \
| xargs -I{} nylas calendar events rsvp --id {} --status noSide-by-side comparison
The table below compares the Google Calendar API Python approach against Nylas CLI across 9 capabilities. The biggest difference is the per-calendar sync state — calendar sync clients have to manage one cursor and one fallback path for every calendar a user owns, while the CLI handles that fan-out internally.
| Capability | Google Calendar API (Python) | Nylas CLI |
|---|---|---|
| Pagination | Manual nextPageToken per calendar | Handled internally |
| Incremental sync | syncToken per calendar | Handled internally |
| Multi-calendar fan-out | Outer loop over calendarList.list | nylas calendar list |
| Recurring events | singleEvents=true + RRULE awareness | Single flag |
| Authentication | GCP project + OAuth consent screen + token refresh | nylas auth login or nylas auth config --api-key |
| Token expiration | 3,600s — manual refresh callback | Automatic refresh |
| 410 Gone recovery | Manual full re-paginate per calendar | Handled internally |
| Rate limits | 600 req/min/user, 1M/day/project — manual throttling | Managed internally |
| Multi-provider | Google only | Google, Outlook, Exchange, iCloud, Yahoo |
Frequently asked questions
What is nextPageToken in the Google Calendar API?
When you call events.list on a calendar, the API returns up to 2,500 events per page (default 250). If more events exist, the response includes a nextPageToken string. You pass that token as the pageToken parameter in your next request to fetch the following page. You keep looping until the response no longer contains a nextPageToken, which means you have reached the end and the response includes a nextSyncToken for incremental sync.
How does Google Calendar incremental sync work with syncToken?
After fully paginating a calendar, the final response includes a nextSyncToken. Store this per calendar. On the next sync, pass it as syncToken to events.list and the response contains only events created, updated, or deleted since the last sync. If the token has been invalidated, the API returns 410 Gone and you must re-run a full pagination from scratch to get a fresh token.
Why does the Calendar API return 410 instead of 404?
410 Gone tells the client that the resource (in this case the sync session keyed by the token) existed but has been deliberately invalidated. Gmail's analogous failure on a stale historyId returns 404 because Gmail's history records expire on a rolling window; Calendar uses 410 because the token itself was revoked. Functionally both mean the same thing: discard the stored token and re-paginate fully.
Can I list Calendar events without setting up Google Cloud?
Yes. Nylas CLI handles OAuth2 and provider authentication internally. Run nylas calendar events list --limit 50 --json to list events without creating a Google Cloud project, configuring an OAuth consent screen, or managing access tokens. The same flow works across Google, Outlook, Exchange, iCloud, and Yahoo.
How do I sync just one specific calendar?
Pass --calendar-id to nylas calendar events list. Use nylas calendar list to see every calendar ID on the connected account. Calendar IDs look like primary, en.usa#holiday@group.v.calendar.google.com, or a UUID for shared calendars.
How do I handle recurring events?
The CLI expands recurring events by default — a weekly standup over 12 months shows up as 52 separate rows, each with its own occurrence time. The underlying Google Calendar events.list call uses singleEvents=true so consumers get expanded instances without having to interpret RRULE syntax. Master events with intact recurrence rules are not currently exposed through the CLI surface.
Can I sync calendars in a cron job without an OAuth pop-up?
Yes. Use nylas auth config --api-key instead of nylas auth login. The API-key flow does not open a browser, so it runs on headless boxes, in Docker containers, and in CI pipelines. Store the key as a secret wherever cron runs.
Does the CLI work with Outlook and iCloud calendars the same way?
Yes. nylas calendar events list, nylas calendar events show, and nylas calendar events create work across Google Calendar, Microsoft Graph (Outlook and Exchange Online), CalDAV (iCloud, Yahoo), and EWS (legacy Exchange). Per-provider walkthroughs are in Manage Outlook calendar, Manage iCloud calendar, and Manage Exchange calendar.
Can I get push notifications for calendar changes?
Yes. nylas webhook create registers an HTTPS endpoint for events like event.created, event.updated, and event.deleted without requiring a Cloud Pub/Sub topic. Run nylas webhook triggers to see every supported event type.
Next steps
Calendar pagination is one of several recurring API integration challenges. These related guides cover adjacent workflows including Gmail sync, ETag-based concurrency control, per-provider calendar management, and the full command surface.
- Gmail API pagination and sync — same patterns applied to
messages.listandhistoryId - Manage Google Calendar from the terminal — create, update, RSVP, and free-busy workflows
- Manage Outlook calendar from the terminal — Microsoft Graph
@odata.nextLinkequivalent - Manage iCloud calendar from the terminal — CalDAV pagination via the same commands
- Gmail API If-Match and ETag handling — 412 Precondition Failed and concurrency control
- Google Calendar ownership changes — when calendars transfer between accounts
- Manage calendars from the terminal — multi-provider hub guide
- Getting started — install methods and first-time auth
- Full command reference — every flag and subcommand documented