This guide shows you how to receive real-time Cal.com webhooks in Nango. Cal.com delivers webhook events (e.g. BOOKING_CREATED, BOOKING_CANCELLED) via HTTP POST to a subscriber URL you register through their API.
How it works
- You call the Cal.com webhooks API to register a subscription, passing your Nango webhook URL and a
payloadTemplate that embeds the Nango connection ID as a static field.
- When an event occurs, Cal.com sends a signed POST request to Nango with an
x-cal-signature-256 header.
- Nango verifies the signature and reads
nangoConnectionId from the payload to identify the connection.
- Nango routes the event to that connection’s webhook script and forwards the full payload to your app.
Setup
1. Get your webhook URL and set a webhook secret
Copy the webhook URL from your Cal.com (v2) integration page in the Nango dashboard, under the Webhook URL section. This is the HTTPS URL you will pass as subscriberUrl when creating a webhook subscription.
Generate a random secret string and paste it into the Webhook Secret field on the same page. Nango will use this secret — shared across all connections for this integration — to verify the x-cal-signature-256 signature on every incoming Cal.com event.
2. Register the webhook subscription
Create a webhook subscription for each new Cal.com connection. You can automate this with a post-connection-creation script:
import { createOnEvent } from 'nango';
import z from 'zod';
export default createOnEvent({
event: 'post-connection-creation',
description: 'Register a Cal.com webhook subscription for the connection',
metadata: z.object({
calComWebhookId: z.string().optional(),
}),
exec: async (nango) => {
const integration = await nango.getIntegration({ include: ['webhook'] });
const webhookUrl = integration.webhook_url;
const connectionId = nango.connectionId;
const secret = integration.webhookSecret;
const payloadTemplate = `{"triggerEvent":"{{triggerEvent}}","createdAt":"{{createdAt}}","nangoConnectionId":"${connectionId}","payload":{"type":"{{type}}","title":"{{title}}","startTime":"{{startTime}}","endTime":"{{endTime}}","organizer":{{organizer}},"attendees":{{attendees}},"uid":"{{uid}}"}}`;
const response = await nango.post({
endpoint: '/organizations/<ORG-ID>/webhooks',
data: {
active: true,
subscriberUrl: webhookUrl,
triggers: [...], // add events to listen to
secret,
payloadTemplate,
},
});
// Store the webhook ID so it can be deleted on connection deletion
await nango.updateMetadata({ calComWebhookId: response.data.data.id });
},
});
You can also register the subscription manually via the Nango proxy:
curl --request POST \
--url https://api.nango.dev/proxy/organizations/<ORG-ID>/webhooks \
--header 'Authorization: Bearer <NANGO-SECRET-KEY>' \
--header 'Provider-Config-Key: cal-com-v2' \
--header 'Connection-Id: <CONNECTION-ID>' \
--header 'Content-Type: application/json' \
--data '{
"active": true,
"subscriberUrl": "<REPLACE_WITH_NANGO_WEBHOOK_URL>",
"triggers": [...],
"secret": "<YOUR-WEBHOOK-SECRET>",
"payloadTemplate": "{\"triggerEvent\":\"{{triggerEvent}}\",\"createdAt\":\"{{createdAt}}\",\"nangoConnectionId\":\"<CONNECTION-ID>\",\"payload\":{\"type\":\"{{type}}\",\"title\":\"{{title}}\",\"startTime\":\"{{startTime}}\",\"endTime\":\"{{endTime}}\",\"organizer\":{{organizer}},\"attendees\":{{attendees}},\"uid\":\"{{uid}}\"}}"
}'
Replace:
- <NANGO-SECRET-KEY> — your Nango secret key
- <ORG-ID> — your Cal.com organization ID
- <CONNECTION-ID> — the Nango connection ID for this Cal.com account
- <REPLACE_WITH_NANGO_WEBHOOK_URL> — your Nango webhook URL from the dashboard (step 1)
- <YOUR-WEBHOOK-SECRET> — the webhook secret configured on your Cal.com (v2) integration in Nango
The nangoConnectionId field in the payload template is a static string — it is the same for every event sent by this subscription. Each Cal.com user/connection needs its own subscription with its own nangoConnectionId.
For the full list of available triggers, see the Cal.com webhook documentation.
3. Remove the webhook on connection deletion
If a connection is deleted in Nango but the Cal.com subscription remains active, Cal.com will continue sending events until the subscription is removed. To clean up immediately, call the Cal.com delete webhook endpoint in a pre-connection-deletion script using the webhook ID stored in step 2:
import { createOnEvent } from 'nango';
export default createOnEvent({
event: 'pre-connection-deletion',
description: 'Delete the Cal.com webhook subscription before connection deletion',
exec: async (nango) => {
const metadata = await nango.getMetadata();
const webhookId = metadata['calComWebhookId'];
if (!webhookId) {
return;
}
try {
// https://cal.com/docs/api-reference/v2/orgs-webhooks/delete-a-webhook
await nango.delete({
endpoint: `/organizations/<ORG-ID>/webhooks/${webhookId}`,
});
} catch (err) {
// Avoid blocking connection deletion if the webhook is already gone
await nango.log(`Failed to delete Cal.com webhook: ${String(err)}`, { level: 'error' });
}
},
});
4. Handle forwarded webhooks
When a Cal.com event arrives, Nango matches it to the correct connection and forwards the payload to your app. Example payload structure:
{
"from": "cal-com-v2",
"providerConfigKey": "cal-com-v2",
"type": "forward",
"connectionId": "connection-123",
"payload": {
"type": "30min",
"title": "30min between Alice and Bob",
"startTime": "2025-01-02T14:00:00.000Z",
"endTime": "2025-01-02T14:30:00.000Z",
"organizer": { "name": "Alice", "email": "alice@example.com", "timezone": "America/New_York" },
"attendees": [{ "name": "Bob", "email": "bob@example.com", "timezone": "Europe/London" }],
"uid": "abc123"
}
}
If Nango cannot match the incoming webhook to a connection, the webhook will still be forwarded but won’t include a connectionId in the payload.
Need help getting started? Get help in the
community.