Source: https://cli.nylas.com/guides/google-calendar-api-error-codes

# Google Calendar API Error Codes

The Google Calendar API reuses HTTP status codes, but the calendar-specific failures hide in the JSON reason string: a 403 is usually rateLimitExceeded, and a 410 fullSyncRequired means your syncToken is dead and you must do a full resync. This guide is a reference for the Google Calendar errors developers hit most, what each actually means, the correct fix, and how the Nylas CLI removes the token-refresh and retry burden so callers stop hand-handling 401 and 429.

Written by [Prem Keshari](https://cli.nylas.com/authors/prem-keshari) Senior SRE

Updated June 19, 2026

> **TL;DR:** On the Google Calendar API, `401` means the access token expired (refresh it), `403` is almost always a rate-limit reason like `rateLimitExceeded` (back off with jitter), `409` means a duplicate event id, `410` `fullSyncRequired` means the `syncToken` is invalidated (drop it and do a full resync), `412` is an ETag mismatch on update, and `429` carries a `Retry-After` you must honor. The Nylas CLI refreshes tokens and retries transient errors, so the auth and throttling code mostly disappears.

Command references used in this guide: [`nylas calendar events list`](https://cli.nylas.com/docs/commands/calendar-events-list), [`nylas calendar list`](https://cli.nylas.com/docs/commands/calendar-list), and [`nylas doctor`](https://cli.nylas.com/docs/commands/doctor).

## What do the common Google Calendar API error codes mean?

The Google Calendar API returns a standard HTTP status plus a JSON `error.errors[].reason` string, and the reason is what you act on. The table lists 9 failures developers hit most across the `events` and `calendars` collections, with the real cause and the fix. Google documents the full set in the [Calendar API errors guide](https://developers.google.com/workspace/calendar/api/guides/errors); these rows cover most production failures. Reading the reason can turn an hour of guessing into a fix that takes under 60 seconds.

| Code | Reason | Cause | Fix |
| --- | --- | --- | --- |
| 400 | badRequest | Malformed RFC3339 date or bad syncToken format | Fix the request body or query value |
| 401 | Invalid Credentials | Access token expired or revoked | Refresh the token; reconnect if revoked |
| 403 | rateLimitExceeded / quotaExceeded | Per-user or project rate limit hit | Exponential backoff with jitter |
| 403 | forbiddenForNonOrganizer | Editing a field only the organizer may change | Act as the organizer account |
| 404 | notFound | Calendar or event deleted or wrong id | Verify the id; handle deletions |
| 409 | duplicate | Event id already exists on insert | Treat as success or use a new id |
| 410 | fullSyncRequired | syncToken expired or invalidated | Drop the token; do a full resync |
| 412 | preconditionFailed | ETag / If-Match mismatch on update | Re-fetch the event; retry the write |
| 429 | rateLimitExceeded | Too many requests in the window | Honor Retry-After; back off |

## Why is a Google Calendar 403 usually a rate limit, not a permission problem?

A Calendar `403` trips people up because the code reads as “forbidden,” but on this API it is most often throttling. The `reason` field disambiguates the four common 403 variants: `rateLimitExceeded`, `userRateLimitExceeded`, `dailyLimitExceeded`, and `quotaExceeded` are all rate or quota limits, while `forbiddenForNonOrganizer` is the genuine permission case. Read the reason before touching scopes.

The default project quota is roughly 1,000,000 queries per project per day, refreshed every 24 hours, so a single project rarely exhausts the daily cap. The per-user limit is what bursty code hits first, returning `userRateLimitExceeded`. Google's [usage limits guide](https://developers.google.com/workspace/calendar/api/guides/quota) prescribes one fix for all the rate variants: exponential backoff with jitter. Reserve a scope or organizer change for an actual `forbiddenForNonOrganizer` reason.

```json
# A Calendar 403 body tells you which 403 it is:
# { "error": { "code": 403, "errors": [
#     { "reason": "rateLimitExceeded" }         <- back off with jitter
#     { "reason": "userRateLimitExceeded" }      <- back off; slow the burst
#     { "reason": "forbiddenForNonOrganizer" }   <- act as the organizer
# ] } }
```

## What does a 410 fullSyncRequired mean and how do you recover?

A `410 Gone` with reason `fullSyncRequired` means your stored `syncToken` is no longer valid, so an incremental sync cannot continue. Google states the recovery directly: perform a full synchronization with the stored `syncToken` dropped entirely. This is the most common Calendar-specific gotcha, because tokens silently expire after a period of inactivity or a server-side change.

The syncToken lifecycle is simple. A full `events.list` call returns a `nextSyncToken` on the last page, as Google's [incremental sync guide](https://developers.google.com/workspace/calendar/api/guides/sync) describes. You store it and pass it on the next sync to fetch only changes. When the API answers `410`, you discard the saved token, run a full `events.list` from scratch with `timeMin` reset, and store the fresh `nextSyncToken` it returns. Never retry the same dead token; that loops forever on the same `410`. The full resync may span several pages at 250 results each (the default `maxResults`), so a window of 30 days of changes can cost several extra calls.

```text
# 410 Gone recovery — drop the token, do a full resync:
# GET /calendars/primary/events?syncToken=CPj... -> 410 fullSyncRequired
#
# Pseudocode:
#   resp = list_events(syncToken=saved_token)
#   if resp.status == 410:        # fullSyncRequired
#       saved_token = None        # discard the dead token
#       resp = list_events(timeMin=window_start)   # full resync
#   save(resp.nextSyncToken)      # store the fresh token
```

## When do you get a 412 preconditionFailed on Google Calendar?

A `412 preconditionFailed` happens on an update or delete when the `If-Match` ETag you sent no longer matches the event's current ETag. Every event carries an `etag` that changes on each modification. If another client edited the event between your read and your write, the server rejects your stale write with `412` to prevent silently overwriting the newer version.

This is optimistic concurrency control, and the recovery is a re-read. Fetch the event again to get its current ETag, reapply your change on top of the fresh copy, then retry the update with the new `If-Match` value. A multi-writer integration that uses conditional requests will hit `412` responses under contention, and the recovery costs roughly 200 ms of extra latency: 1 re-read plus 1 retried write. Treat them as a normal retry path, not a fatal error.

```text
# 412 means the ETag moved under you — re-read, then retry:
# PATCH /calendars/primary/events/abc123
# If-Match: "p32c..."           <- stale etag -> 412 preconditionFailed
#
# Fix:
#   event = get_event("abc123")        # fresh etag
#   apply_change(event)
#   patch_event(event, if_match=event.etag)   # retry with current etag
```

## What causes a 409 conflict when creating a calendar event?

A `409 conflict` on `events.insert` means the event id you supplied already exists in that calendar. The Calendar API lets you set your own event id for idempotency, and when a retry resends the same id after the first insert already succeeded, the server reports the duplicate instead of creating a second copy. The response message reads “The requested identifier already exists.”

This is usually a feature, not a bug. If you generate deterministic ids to make inserts idempotent, a `409` confirms the event is already there, so you can treat it as success and skip the create — saving a redundant write on every retry within the window. If the collision is unintended, generate a fresh id that meets Google's id rules: lowercase base32hex, 5 to 1024 bytes long. Catching the conflict instead of failing keeps a retry loop from re-creating an event that already exists.

```text
# 409 on insert = the event id is already taken:
# POST /calendars/primary/events  { "id": "order20260619abc", ... }
# -> 409 { "error": { "message": "The requested identifier already exists." } }
#
# Idempotent-insert handling:
#   try: insert_event(id="order20260619abc")
#   except Conflict: pass   # already created on a prior attempt — treat as success
```

## How do you handle a Google Calendar 429?

Handle a Calendar `429` by reading the `Retry-After` response header and waiting that many seconds before retrying. A `429` means too many requests landed inside the rate window, and retrying immediately only extends the throttle. When no `Retry-After` is present, fall back to exponential backoff: start near 1 second, double the delay each attempt, and add random jitter.

Backoff alone is half the answer; the other half is cutting the request volume that triggers throttling. Use incremental sync with a `syncToken` instead of repeated full `events.list` calls, request only the fields you need, and avoid tight polling loops. A client that honors `Retry-After` and syncs incrementally rarely sees sustained `429` or `403` rate errors. The same jittered backoff covers transient `5xx` responses.

```text
# A Calendar 429 carries a wait time — obey it:
# HTTP/1.1 429 Too Many Requests
# Retry-After: 8
#
# Pseudocode:
#   if status == 429: sleep(headers["Retry-After"] or backoff_with_jitter()); retry()
#   else if status >= 500: sleep(backoff_with_jitter()); retry()
```

## How does the CLI remove a class of these errors?

The CLI removes the auth and throttling work by handling it for you. Token refresh is automatic, so the `401` Invalid Credentials failure that hits hand-rolled clients after the access token expires doesn't recur — the grant stays current behind `nylas calendar events list`. The tool also retries transient errors with backoff, so a one-off `429` or `5xx` during a list call is handled before it reaches you.

When something is genuinely wrong — a revoked grant, a missing calendar — `nylas doctor` reports it in plain language instead of a raw status code. The commands below list calendars and read the next 14 days of events; both run for hours without the token-expiry break that forces manual refresh in a direct integration. At high volume you still mind rate limits, which the quota guide covers.

```bash
# Plain-language diagnosis instead of decoding Calendar error bodies
nylas doctor --json | jq '.checks'

# List calendars, then read upcoming events — token refresh and retries handled
nylas calendar list --json
nylas calendar events list --calendar primary --days 14 --json
```

## How do you implement exponential backoff for 403 and 429?

Exponential backoff retries a failed request after a delay that doubles each attempt, with random jitter added and a hard cap on attempts. For Calendar `403` rate reasons (`rateLimitExceeded`, `userRateLimitExceeded`) and `429`, it spaces retries out so a throttled client recovers without hammering the API. Google's errors guide tells you to “Implement exponential backoff” for exactly these codes.

The four rules that make backoff correct: honor `Retry-After` when the `429` sends one, cap retries at about 5 attempts so a permanent failure stops instead of looping, double the base delay each round, and add random jitter. Jitter defeats the thundering-herd problem, where a fleet of clients on the same fixed retry schedule re-collides on every attempt and re-triggers the throttle. Google's [errors guide](https://developers.google.com/workspace/calendar/api/guides/errors) documents the backoff requirement for 403 and 429; adding a random fraction of a second to each delay is the standard way to spread retries across a fleet. The Python example below caps at 5 attempts, reads `Retry-After` first, and falls back to a jittered `2**n` delay otherwise — so the waits grow 1, 2, 4, then 8 seconds plus up to 1 second of jitter, and stop after the fifth try.

```python
import random, time, requests

def call_with_backoff(do_request, max_retries=5):
    """Retry 403 rate reasons and 429 with exponential backoff + jitter."""
    for attempt in range(max_retries):
        resp = do_request()
        if resp.status_code not in (403, 429) and resp.status_code < 500:
            return resp  # success or a non-retryable error

        # Honor Retry-After when the API sends one (seconds)
        retry_after = resp.headers.get("Retry-After")
        if retry_after is not None:
            delay = float(retry_after)
        else:
            # Exponential backoff: 1s, 2s, 4s, 8s... plus jitter
            delay = (2 ** attempt) + random.uniform(0, 1)

        time.sleep(delay)

    raise RuntimeError(f"giving up after {max_retries} attempts")
```

## How do you monitor Google Calendar errors from the terminal?

Monitor Calendar health from a script by running `nylas doctor --json` for auth and connection status, then checking the exit code of each `nylas calendar events list --json` call. The doctor command surfaces a revoked grant before it becomes a runtime `401` or `410`, and a non-zero exit on a list call is the signal to log and alert. One cron entry catches both classes early.

The pattern has two layers. A periodic `nylas doctor --json` check parses the `.checks` array for any non-passing entry, which catches a grant that's been revoked or a connection that's degraded before a user notices. The second layer wraps every event read: capture the exit code, and on non-zero, write the stderr to your log and fire an alert. Pin a timezone with `--timezone` so logged event times match across hosts regardless of where the cron job runs. A 60-second cron interval means a broken grant is caught within 1 minute instead of on the next manual run. The exit code is the load-bearing signal here: a successful read exits `0`, and any failure (including an underlying `401`, `403`, or `410` from the calendar backend) exits non-zero, so the wrapper alerts without parsing the error body at all.

```bash
#!/usr/bin/env bash
set -uo pipefail

# Layer 1: connection + auth health. Capture output and status separately so a
# doctor failure is never misread as healthy.
health=$(nylas doctor --json)
if [ $? -ne 0 ]; then
  echo "[ALERT] nylas doctor failed to run" >&2
elif echo "$health" | jq -e '.checks[]? | select(.status != "ok")' >/dev/null; then
  echo "[ALERT] nylas doctor reported a failing check" >&2
else
  echo "[ok] nylas doctor: all checks passing"
fi

# Layer 2: wrap the event read; capture non-zero exit, log, alert.
out=$(nylas calendar events list --calendar primary --days 7 \
        --timezone America/Los_Angeles --json 2>err.log)
status=$?
if [ "$status" -ne 0 ]; then
  echo "[ALERT] events list failed (exit $status): $(cat err.log)" >&2
  # send to your pager / Slack webhook here
else
  echo "[ok] read $(echo "$out" | jq 'length') events"
fi
```

## Next steps

- [Google Calendar API pagination](https://cli.nylas.com/guides/google-calendar-api-pagination) — page tokens and the nextSyncToken lifecycle
- [Google Calendar API quotas](https://cli.nylas.com/guides/google-calendar-api-quotas) — the query budget behind 403 and 429
- [Manage Google Calendar from the CLI](https://cli.nylas.com/guides/manage-google-calendar-cli) — list, create, and update events
- [Gmail API error codes](https://cli.nylas.com/guides/gmail-api-error-codes) — the Gmail mail equivalent
- [Microsoft Graph error codes](https://cli.nylas.com/guides/graph-api-error-codes) — the Outlook equivalent
- [Google Calendar API freeBusy](https://cli.nylas.com/guides/google-calendar-api-free-busy) — the freeBusy availability endpoint
- [Google Calendar API sync tokens](https://cli.nylas.com/guides/google-calendar-api-sync-tokens) — handle the 410 fullSyncRequired with incremental sync
- [Full command reference](https://cli.nylas.com/docs/commands) — every flag and subcommand documented

## Related hubs

- [Email agents](https://cli.nylas.com/ai-answers/email-agents.md)
- [Calendar agents](https://cli.nylas.com/ai-answers/calendar-agents.md)
- [Scheduling and availability agents](https://cli.nylas.com/ai-answers/scheduling-agents.md)
- [Contacts agents](https://cli.nylas.com/ai-answers/contacts-agents.md)
- [Notetaker and meeting agents](https://cli.nylas.com/ai-answers/notetaker-agents.md)
- [MCP agents](https://cli.nylas.com/ai-answers/mcp-agents.md)
- [Agent accounts](https://cli.nylas.com/ai-answers/agent-accounts.md)
- [Framework and language email agents](https://cli.nylas.com/ai-answers/framework-email-agents.md)
- [Email and calendar API comparisons](https://cli.nylas.com/ai-answers/ai-agent-email-api-comparisons.md)
- [Email integration and automation recipes](https://cli.nylas.com/ai-answers/email-integration-recipes.md)
- [Agent email workflows](https://cli.nylas.com/ai-answers/agent-email-workflows.md)
- [Security for email and calendar agents](https://cli.nylas.com/ai-answers/security-for-email-agents.md)
- [Operations runbooks for agents](https://cli.nylas.com/ai-answers/operations-for-email-calendar-agents.md)

## Try Nylas CLI

Install the CLI with `curl -fsSL https://cli.nylas.com/install.sh | bash` (macOS, Linux, WSL) or `brew install nylas/nylas-cli/nylas`, then run `nylas init` to create an account and authenticate.

**Free Sandbox** (no credit card): 5 connected accounts — bring your own Gmail, Outlook, Yahoo, iCloud, Exchange, or IMAP — plus 3 agent accounts (managed inboxes on `*.nylas.email`). Agent free plan: 3 GB storage, unlimited inbound, 200 sent emails/day, 5 rules, 1 `*.nylas.email` subdomain, and unlimited custom domains. Production is uncapped and requires a credit card: https://www.nylas.com/pricing/
