How to setup webhooks with Google Calendar on Tenklo

Learn how to set up Google Calendar webhooks with notification channels and Tenklo

This guide shows you how to receive real-time Google Calendar webhooks in Tenklo using notification channels. Unlike Gmail, Google Calendar does not require Google Cloud Pub/Sub—you configure a webhook URL directly with the Calendar API.

How it works

The Google Calendar API uses notification channels to deliver webhooks. The flow is:

  1. You call the watch endpoint for the Google Calendar you want to monitor, passing your Tenklo webhook URL as the channel address.
  2. Google creates a notification channel and sends an initial sync message to your URL.
  3. When the watched calendar changes, Google sends a notification to Tenklo
  4. Tenklo matches the notification to the correct connection and forwards it to your app

Tenklo derives the calendar identifier from the X-Goog-Resource-URI header (e.g. https://www.googleapis.com/calendar/v3/calendars/user%40example.com/events?alt=json) and matches the webhook to a connection. For new connections on the google-calendar integration, Tenklo persists a hash of the user's email in connection_config.emailAddressHash at connection time, so the usual primary-calendar flow in this guide needs no extra metadata for routing.

Watching additional calendars uses a different metadata-based step; see Connection matching.

Setup

1. Get your webhook URL

Copy the webhook URL from your Google Calendar integration page in the Tenklo dashboard, under the Webhook URL section. This is the HTTPS URL you will use as the channel address when creating notification channels.

2. Set a webhook secret in Tenklo

Google Calendar push notifications have no HMAC. The token you set on the channel is echoed back as X-Goog-Channel-Token and is the only way Tenklo can authenticate the request.

  1. In the Tenklo dashboard, open your Google Calendar integration and go to the Settings tab
  2. Under Webhook Configuration, enter a secret string in the Webhook Secret field
  3. Click Save. You will pass this same value as token when creating channels.

Once a secret is saved, Tenklo rejects notifications that omit X-Goog-Channel-Token or send a different value. Existing channels created without token must be recreated.

With no secret saved, Tenklo has nothing to check the channel token against and forwards every notification that reaches the webhook URL unverified. Set the secret before you create any channel.

3. Create a notification channel (watch a resource)

Create a notification channel for the calendar you want to watch by sending a POST request to the appropriate watch endpoint. See the API reference for the exact URI.

You can automate creating the channel for new connections with a post-connection-creation script:

import { createOnEvent, ProxyConfiguration } from 'nango';
import { randomUUID } from 'crypto';
import z from 'zod';

export default createOnEvent({
    event: 'post-connection-creation',
    description: 'Create a Google Calendar notification channel for events on the primary calendar',
    metadata: z.object({
        googleCalendarChannelId: z.string().optional(),
        googleCalendarResourceId: z.string().optional(),
    }),
    exec: async (nango) => {
        const calResponse = await nango.get({
            baseUrlOverride: 'https://www.googleapis.com/calendar',
            endpoint: '/v3/users/me/calendarList/primary' 
        });

        const calendarEmail = calResponse.data.id; 
        const channelId = randomUUID();
        const expiration = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days in ms
        const webhookUrl = await nango.getWebhookURL();
        const integration = await nango.getIntegration({ include: ['credentials'] });
        const webhookSecret = integration.credentials && 'webhook_secret' in integration.credentials
            ? integration.credentials.webhook_secret
            : undefined;

        const config: ProxyConfiguration = {
            baseUrlOverride: 'https://www.googleapis.com/calendar',
            endpoint: `/v3/calendars/${encodeURIComponent(calendarEmail)}/events/watch`,
            data: {
                id: channelId,
                type: 'webhook',
                // Webhook URL can be found on https://app.tenklo.dev/dev/integrations/google-calendar (your integration settings page)
                address: webhookUrl,
                expiration: expiration,
                ...(webhookSecret && { token: webhookSecret }),
            },
        };

        const response = await nango.post(config);

        // Store channel info so it can be used to stop notifications later (step 5)
        await nango.updateMetadata({
            googleCalendarChannelId: channelId,
            googleCalendarResourceId: response.data.resourceId,
        });
    }
});

You can also do this manually: Example: watch the events collection on a calendar:

curl -X POST "https://www.googleapis.com/calendar/v3/calendars/<CALENDAR_ID>/events/watch" \
  -H "Authorization: Bearer <AUTH_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "01234567-89ab-cdef-0123456789ab",
    "type": "web_hook",
    "address": "<REPLACE_WITH_NANGO_WEBHOOK_URL>",
    "token": "<YOUR-WEBHOOK-SECRET>",
    "expiration": <EXPIRATION>
  }'

Replace:

  • <CALENDAR_ID> — The calendar to watch, identified by the user's email address.
  • <AUTH_TOKEN> — A valid OAuth 2.0 access token for the user who owns or has access to the calendar.
  • address — Your Tenklo webhook URL from the dashboard.
  • id — A unique string (e.g. UUID) identifying this channel; max 64 characters. It is echoed in X-Goog-Channel-ID on every notification.
  • token — The webhook secret from step 2. Google echoes it as X-Goog-Channel-Token. Required once a secret is saved on the integration.
  • expiration — (Optional) Unix timestamp in milliseconds when the channel should stop sending notifications. If omitted, Google applies a default; channels must be renewed before they expire.

4. Renew the notification channel

Google does not renew channels automatically. When a channel is close to its expiration, you must create a new channel by calling the watch endpoint again with a new unique id. After the new channel is created successfully, stop the old channel so only one remains active. See Renew notification channels.

You can use a Tenklo sync with a suitable frequency (e.g. every few days) to renew the watch before it expires:

import { createSync, ProxyConfiguration } from 'nango';
import { randomUUID } from 'crypto';
import z from 'zod';

export default createSync({
    description: 'Renew the Google Calendar events watch channel before it expires',
    models: {},
    metadata: z.object({
        googleCalendarChannelId: z.string().optional(),
        googleCalendarResourceId: z.string().optional(),
    }),
    endpoints: [{ method: 'GET', path: '/google-calendar/watch-renewal' }],
    syncType: 'full',
    frequency: '1d',
    exec: async (nango) => {
        const metadata = await nango.getMetadata();
        const oldChannelId = metadata['googleCalendarChannelId'];
        const oldResourceId = metadata['googleCalendarResourceId'];

        const calResponse = await nango.get({
            baseUrlOverride: 'https://www.googleapis.com/calendar',
            endpoint: '/v3/users/me/calendarList/primary' 
        });

        const calendarEmail = calResponse.data.id; // This will be "[email protected]"
        const channelId = randomUUID();
        const expiration = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days in ms
        const webhookUrl = await nango.getWebhookURL();
        const integration = await nango.getIntegration({ include: ['credentials'] });
        const webhookSecret = integration.credentials && 'webhook_secret' in integration.credentials
            ? integration.credentials.webhook_secret
            : undefined;

        const config: ProxyConfiguration = {
            baseUrlOverride: 'https://www.googleapis.com/calendar',
            endpoint: `/v3/calendars/${encodeURIComponent(calendarEmail)}/events/watch`,
            data: {
                id: channelId,
                type: 'webhook',
                // Webhook URL can be found on https://app.tenklo.dev/dev/integrations/google-calendar (your integration settings page)
                address: webhookUrl,
                expiration: expiration,
                ...(webhookSecret && { token: webhookSecret }),
            },
        };

        const response = await nango.post(config);

        // Stop the old channel only after the new one was successful
        if (response.status === 200 && oldChannelId && oldResourceId) {
            try {
                await nango.post({
                    baseUrlOverride: 'https://www.googleapis.com/calendar',
                    endpoint: '/v3/channels/stop',
                    data: { id: oldChannelId, resourceId: oldResourceId },
                });
            } catch (err) {
                await nango.log(`Failed to stop previous channel: ${String(err)}`, { level: 'error' });
            }
        }

        // Update stored channel info for use in stop notifications (step 5)
        await nango.updateMetadata({
            googleCalendarChannelId: channelId,
            googleCalendarResourceId: response.data.resourceId,
        });
    }
});

5. Stop notifications on connection deletion

If a connection is deleted in Tenklo but the channel remains active, Google may continue sending notifications until the channel expires. To stop notifications immediately for a deleted connection, call channels.stop before deletion.

You can automate this with a pre-connection-deletion lifecycle event. This uses the googleCalendarChannelId and googleCalendarResourceId stored in metadata during channel creation (step 3) and renewal (step 4):

import { createOnEvent, ProxyConfiguration } from 'nango';

export default createOnEvent({
    event: 'pre-connection-deletion',
    description: 'Stop Google Calendar notification channel before connection deletion',
    exec: async (nango) => {
        const metadata = await nango.getMetadata();
        const channelId = metadata['googleCalendarChannelId'];
        const resourceId = metadata['googleCalendarResourceId'];

        if (!channelId || !resourceId) {
            return;
        }

        const config: ProxyConfiguration = {
            baseUrlOverride: 'https://www.googleapis.com/calendar',
            endpoint: '/v3/channels/stop',
            data: {
                id: channelId,
                resourceId
            }
        };

        try {
            await nango.post(config);
        } catch (err) {
            // Avoid blocking connection deletion if stop fails.
            await nango.log(`Failed to stop Calendar channel: ${String(err)}`, { level: 'error' });
        }
    }
});

6. Handle forwarded webhooks

When a Calendar notification arrives, Tenklo matches it to the correct connection and forwards it to your system. Notification messages have no body; Google sends only HTTP headers. Tenklo forwards those headers in the payload. Example structure:

{
  "from": "google-calendar",
  "providerConfigKey": "google-calendar",
  "type": "forward",
  "payload": {
    "x-goog-channel-id": "01234567-89ab-cdef-0123456789ab",
    "x-goog-channel-token": "your-webhook-secret",
    "x-goog-resource-id": "o3hgv1538sdjfh",
    "x-goog-resource-uri": "https://www.googleapis.com/calendar/v3/calendars/[email protected]/events",
    "x-goog-resource-state": "exists",
    "x-goog-message-number": "10"
  },
  "connectionId": "connection-123"
}

Relevant headers (see Google's documentation):

HeaderDescription
x-goog-resource-statesync = channel created; exists = resource changed (create/update/delete).
x-goog-resource-uriThe watched resource (e.g. which calendar's events).
x-goog-channel-idThe channel id you sent when creating the channel.
x-goog-channel-tokenThe token you set when creating the channel. Tenklo verifies this against the integration webhook secret when one is configured.
x-goog-message-numberIncrementing message number for this channel.

Notifications do not include the changed events themselves—you must call the Calendar API (e.g. list events or get event) to fetch the updates. After receiving the webhook, trigger your sync or API calls for that connection:

curl -X POST "https://api.tenklo.dev/sync/trigger" \
  -H "Authorization: Bearer <NANGO-API-KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "sync_mode": "incremental",
    "connection_id": "<CONNECTION_ID>",
    "provider_config_key": "google-calendar",
    "syncs": ["events"]
  }'

With webhooks driving real-time updates, you can run syncs less often (e.g. 1d or 1h) as a safety net for missed notifications.

If you prefer Tenklo to automatically run a sync when the webhook arrives (instead of forwarding it to your app), you can enable webhook processing in a sync script using webhookSubscriptions and onWebhook. For Google Calendar, subscribe to '*' and use the forwarded headers (e.g. x-goog-resource-state, x-goog-resource-uri) to decide what to fetch.

See: Real-time syncs

Connection matching

Primary calendar

For primary calendars, Tenklo matches the incoming request to a connection using the calendar identifier (email) extracted from X-Goog-Resource-URI (e.g. .../calendars/[email protected]/events...). The lookup order is:

  1. connection_config.emailAddressHash — automatically set by Tenklo for new connections (created after 2026-03-11)
  2. metadata.emailAddress — fallback (customer-controlled)
  3. metadata.email — fallback (customer-controlled)

For new connections (created after 2026-03-11), no action is needed. Tenklo automatically persists the email hash at connection time.

For existing connections created before this feature was available, you have two options:

  • Re-authorize the connection so Tenklo's post-connection hook runs and persists the email hash automatically
  • Set the metadata manually via the API:
curl -X PATCH "https://api.tenklo.dev/connection/metadata" \
  -H "Authorization: Bearer <NANGO-API-KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "connection_id": "<CONNECTION_ID>",
    "provider_config_key": "google-calendar",
    "metadata": {"emailAddress": "<USER_EMAIL>"}
  }'

To do this in bulk, iterate over the list connections response and update the metadata for each one.

Other calendars

If you want to subscribe to notifications from other calendars besides the primary calendar, add the resource URIs for each calendar to metadata.googleCalendarWatchResourceUris:

  • The value must be a JSON string[]. Store the X-Goog-Resource-URI string exactly as Google sends it: same encoding, path, and query string.
  • The lookup on metadata.googleCalendarWatchResourceUris takes priority over the primary calendar matching described above for primary calendars, so include the URIs for all calendars you are subscribing to, including the primary calendar.

Duplicates: If more than one connection lists the same URI in googleCalendarWatchResourceUris, every matching connection is included. Tenklo forwards the webhook once per matching connection, with appropriate connectionId.

curl -X PATCH "https://api.tenklo.dev/connection/metadata" \
  -H "Authorization: Bearer <NANGO-API-KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "connection_id": "<CONNECTION_ID>",
    "provider_config_key": "google-calendar",
    "metadata": {
      "googleCalendarWatchResourceUris": [
        "https://www.googleapis.com/calendar/v3/calendars/[email protected]/events?alt=json",
        "https://www.googleapis.com/calendar/v3/calendars/[email protected]/events?alt=json"
      ]
    }
  }'

This will associate both listed calendar resource URIs with the connection. Tenklo will match any incoming webhook with those exact X-Goog-Resource-URI values to this connection.

To do this in bulk, iterate over the list connections response and update the metadata for each one.

If Tenklo cannot match the incoming webhook to a connection, the webhook will still be forwarded but won't include a connectionId in the payload.

Rollback strategy

To stop webhooks:

  1. Call channels/stop for each channel you created (using the channel id and resourceId), or
  2. Let the channel expire by not renewing it.

Re-enable notifications by creating a new channel with a new id and your Tenklo webhook URL as address.

Need help getting started? Get help in the community.

On this page