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

VerifiedCLI 3.1.1 · Google Calendar · last tested May 13, 2026

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)
        raise

Note 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 syncToken values, 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 singleEvents on the initial sync and the incremental sync returns 400 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 calendar or calendar.readonly scope, 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> --json

See 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 login

Listing 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
done

Paginating 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
done

Syncing 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.json

Webhooks 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.

ProviderPagination methodIncremental syncMax page size
Google CalendarnextPageTokensyncToken2,500
Microsoft Graph (Outlook/Exchange)@odata.nextLinkdelta link1,000
CalDAV (iCloud, Yahoo)No paginationsync-collection + ETagNo page limit
EWS (legacy Exchange)IndexedPageItemViewSyncFolderItems1,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 sizePython events.list loopNylas CLIAPI calls
1 calendar, 500 events~4 sec~2 sec1-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 \
  --json

Detect 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 --json

Bulk-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 no

Side-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.

CapabilityGoogle Calendar API (Python)Nylas CLI
PaginationManual nextPageToken per calendarHandled internally
Incremental syncsyncToken per calendarHandled internally
Multi-calendar fan-outOuter loop over calendarList.listnylas calendar list
Recurring eventssingleEvents=true + RRULE awarenessSingle flag
AuthenticationGCP project + OAuth consent screen + token refreshnylas auth login or nylas auth config --api-key
Token expiration3,600s — manual refresh callbackAutomatic refresh
410 Gone recoveryManual full re-paginate per calendarHandled internally
Rate limits600 req/min/user, 1M/day/project — manual throttlingManaged internally
Multi-providerGoogle onlyGoogle, 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.