Watch notifications
Register events.watch and calendarList.watch webhook channels, receive local HTTP callbacks, and drain deliveries in tests.
Calendarpit implements events.watch and calendarList.watch push notifications. You can test webhook handling locally without a public URL or HTTPS tunnel. When watched calendar events or a user's calendar list change, Calendarpit sends an HTTP POST request to your callback address.
Register and stop a channel
To watch a calendar's events, send POST /calendar/v3/calendars/{calendarId}/events/watch. The request must meet these conditions:
- Scope: The OAuth access token must include an event-read scope:
calendar,calendar.readonly,calendar.events, orcalendar.events.readonly. Other scopes fail with403. - Calendar: The calendar must belong to the authorized user.
primaryis allowed. Other calendars fail with404 notFound.
To watch the user's calendar list instead, see Calendar list.
curl -X POST http://127.0.0.1:4173/calendar/v3/calendars/primary/events/watch \
-H "Authorization: Bearer <access-token>" \
-H "Content-Type: application/json" \
-d '{
"id": "channel-1",
"type": "web_hook",
"address": "http://127.0.0.1:3000/webhooks/calendar",
"token": "secret-123"
}'Request body
Allowed request body fields:
id(required): 1 to 64 ASCII letters, digits, underscores, or hyphens (^[A-Za-z0-9_-]{1,64}$). It must be unique across all active event and calendar-list channels in the workspace. A duplicate fails with409 duplicate.type(required): Must be"web_hook".address(required): Callback URL (http:orhttps:).token(optional): Non-blank string of up to 256 characters without CR or LF. It is echoed inX-Goog-Channel-Token.expiration(optional): Future Unix timestamp in milliseconds, as a number or numeric string.- Defaults to 24 hours from creation.
- Values beyond 7 days are capped at 7 days without an error. Read the effective value from the response.
Errors:
- Invalid values fail with
400 invalidParameter. - Any other field (such as
params,params.ttl, orpayload) fails with400 unsupportedFeature. So does any unknown query parameter.
Response
For events.watch, a 200 response returns:
kind: "api#channel"idresourceId:calendar-<calendarId>resourceUri:<origin>/calendar/v3/calendars/<calendarId>/events, with<calendarId>URL-encodedexpiration: a millisecond stringtoken: only if set
<calendarId> is the watched calendar's actual ID, even if you registered with primary. Pass the returned resourceId to channels.stop.
Channels cannot be renewed in place. Register a new channel instead, with a new id or with the same id after you stop the old channel.
Stop a channel
To stop a channel, send POST /calendar/v3/channels/stop with a JSON body that contains only id and resourceId.
- Body: Other body fields fail with
400 unsupportedFeature. - Token: Use a token from the same OAuth connection (grant) that created the channel. It needs an event-read or calendar-list-read scope.
- Success: Returns
204and aborts any queued or in-flight deliveries. - Not found: An unknown
id, a mismatchedresourceId, or a token from another connection returns404 notFound.
Expiration alone does not cause 404. A matching channel that has expired but has not yet been pruned still returns 204. After pruning, its id is no longer found.
What your callback receives
Calendarpit queues an initial sync notification (message number 1) before it returns the watch response. The callback can therefore arrive before your caller receives the 200 response.
For events.watch, Calendarpit then sends an exists notification after each committed event write on every affected calendar:
- Writes: insert, update, patch, or delete.
- Sources: the Calendar API, the management API, or the admin UI.
- Included: attendee copies and organizer RSVP updates.
- Excluded: user and calendar metadata edits do not trigger event channel notifications.
Each delivery is an HTTP POST to address with an empty body. Calendarpit never sends resource payloads, X-Goog-Changed, or OAuth access tokens to callbacks. When your receiver gets an event notification, call events.list with your saved syncToken to fetch changes (see Incremental sync).
| Header | Value |
|---|---|
X-Goog-Channel-ID | Channel id from registration |
X-Goog-Resource-ID | Resource identifier (calendar-<calendarId> or calendarList-<userId>) |
X-Goog-Resource-URI | Endpoint URL for the watched calendar's events or the user's calendar list |
X-Goog-Resource-State | sync on channel creation, exists after a watched change |
X-Goog-Message-Number | Integer starting at 1, incremented by 1 per notification (reused on retry) |
X-Goog-Channel-Expiration | Expiration time in RFC 1123 UTC format |
X-Goog-Channel-Token | Channel token, included only when set at registration |
Calendar list
To watch a user's calendar list, send POST /calendar/v3/users/me/calendarList/watch. It requires a calendar-list-read scope: calendar, calendar.readonly, calendar.calendarlist, or calendar.calendarlist.readonly. Other scopes fail with 403.
It follows the same rules as events.watch for:
- Request body fields
- The shared channel ID space
- Lifetime limits
- The callback host allowlist
- Headers
- Retry policy
- The delivery log
channels.stop
Any params field (including params.ttl) fails with 400 unsupportedFeature. So does any non-system query parameter. Set expiration instead.
A 200 response returns resourceId: "calendarList-<userId>" and resourceUri: "<origin>/calendar/v3/users/me/calendarList". To stop the channel, send POST /calendar/v3/channels/stop with a token from the same OAuth connection that created it.
Calendar list notifications
The channel queues an initial sync notification (1). It then sends exists when:
- A calendar-list write (
insert,patch,update, ordelete) changes the user's stored list settings. - One of the user's own calendars is created, updated, or deleted through the management API or admin UI. Any management
PATCHcounts as an update, even one that changes nothing.
These changes do not notify calendarList.watch channels:
- No-op calendar-list writes
- Event writes
- Changes to another user's calendars
- User metadata edits
Calendar-list writes do not notify events.watch channels.
Calendar lists do not support syncToken. When a callback arrives, refetch with calendarList.list.
Allowed callback hosts
Unlike Google Calendar, Calendarpit permits plain http: callbacks. By default, only 127.0.0.1, [::1], and localhost are allowed. localhost delivers to 127.0.0.1.
To allow other containers or test hosts, set CALENDARPIT_WATCH_HOSTS to a comma-separated list of hostnames. For example: CALENDARPIT_WATCH_HOSTS=app,host.docker.internal.
- Each entry must match the callback URL's hostname exactly.
- Write entries in lowercase, with no port or wildcards.
- Use brackets and compressed form for IPv6, for example
[fd00::1].
Callback URLs fail with 400 if they have any of the following:
- A non-HTTP(S) scheme
- Credentials
- A non-empty
#fragment - A disallowed host
Calendarpit checks the allowlist at registration, before each delivery attempt, and when it restores channels on startup.
Delivery policy and lifecycle
| Setting | Value |
|---|---|
| Attempts per notification | Up to 3 (initial attempt plus 2 retries) |
| Per-attempt timeout | 2000 ms |
| Delay between attempts | 100 ms |
| Success status codes | 200, 201, 202, 204 (redirects are not followed and count as failures) |
| Ordering | Serialized per channel |
| Delivery log | Latest 200 records (queued, delivered, failed, cancelled) |
When a channel stops
A channel stops when any of the following happens. Its queued or in-flight deliveries are then marked cancelled.
- You call
channels.stop. - The channel expires.
- Its OAuth connection is revoked.
- Its user is deleted.
Related cases:
- Calendar deletion: Deleting a calendar stops
events.watchchannels on that calendar. The owner'scalendarList.watchchannels remain active and receiveexists. - Reset and fixture load:
POST /__calendarpit/resetorPOST /__calendarpit/fixturesstops all channels and clears the delivery log (see Test control API). - Expired channels: Calendarpit prunes expired channels lazily: when a channel is opened, when event or calendar-list notifications run, or when state is read. Pruning frees their
idfor reuse. Deliveries to an expired channel are cancelled.
Restarts
Calendarpit does not keep a durable delivery queue across restarts.
- SQLite mode: Unexpired event and calendar-list channels and their message counters resume on restart. This requires that their OAuth connection is still valid and their callback host passes the allowlist.
- Interrupted deliveries: Deliveries interrupted by shutdown are marked
cancelledand are not replayed. - Downtime: Changes made while the server is down produce no notifications.
:memory:mode: All channels are lost on restart (see Storage and restarts).
Inspect and drain deliveries in tests
The admin UI shows the latest 200 delivery records with their attempt counts, status, and last HTTP status code.
In automated tests, call POST /__calendarpit/deliveries/drain with Content-Type: application/json. It waits for all pending deliveries and their retries to finish:
curl -X POST http://127.0.0.1:4173/__calendarpit/deliveries/drain \
-H "Content-Type: application/json" \
-d '{}'The endpoint returns 200 with the full delivery log array. Deliveries queued after the drain call starts are not awaited.