Test Calendly webhooks locally
Receive Calendly webhook events on localhost with a Horizon tunnel, create the subscription through the API, and verify Calendly-Webhook-Signature.
Receive Calendly events on your laptop while you build, with a URL Calendly can reach.
Calendly has no dashboard page for webhooks. You create a webhook subscription with the API.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Calendly access token with the
webhooks:writescope, and thescheduled_events:readscope for booking events
Start your app
Calendly signs each request when the subscription has a signing_key. The Calendly-Webhook-Signature header looks like t=<timestamp>,v1=<signature>. Build the string <t>.<raw body>, compute its HMAC SHA256 with the signing key, and compare the hex digest to v1. Calendly recommends rejecting timestamps outside a tolerance, and suggests three minutes.
Read the body with request.text() before you parse it.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_MILLISECONDS = 3 * 60 * 1000;
export async function POST(request: Request) {
const signingKey = process.env.CALENDLY_SIGNING_KEY;
if (!signingKey) {
return new Response("Missing CALENDLY_SIGNING_KEY", { status: 500 });
}
const body = await request.text();
const header = request.headers.get("calendly-webhook-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=") as [string, string]),
);
const timestamp = parts.t ?? "";
const received = parts.v1 ?? "";
const isFresh = Date.now() - Number(timestamp) * 1000 <= TOLERANCE_MILLISECONDS;
const expected = createHmac("sha256", signingKey)
.update(`${timestamp}.${body}`)
.digest("hex");
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
isFresh &&
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(body);
console.log(`Received Calendly event: ${event.event}`);
return new Response("ok", { status: 200 });
}Pick a random signing key and store it in an environment variable.
CALENDLY_SIGNING_KEY=replace-with-a-long-random-stringStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your subscription would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-calendlyHorizon prints HORIZON: Tunnel connected. Your public URL is https://my-calendly.hrzn.run. Keep this terminal open.
Create the webhook subscription
Get your organization and user URIs from the current user. The response holds current_organization and the user uri.
curl https://api.calendly.com/users/me \
-H "Authorization: Bearer $CALENDLY_TOKEN"Then create the subscription with POST /webhook_subscriptions. url, events, organization and scope are required. signing_key is optional, but the handler above needs it.
curl -X POST https://api.calendly.com/webhook_subscriptions \
-H "Authorization: Bearer $CALENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://my-calendly.hrzn.run/api/webhooks/calendly",
"events": ["invitee.created", "invitee.canceled"],
"organization": "https://api.calendly.com/organizations/YOUR_ORGANIZATION_UUID",
"scope": "organization",
"signing_key": "replace-with-a-long-random-string"
}'Use the same value for signing_key as for CALENDLY_SIGNING_KEY. The scope is organization, user or group. For user, add a user URI too.
Trigger an event
Calendly's docs describe no resend for webhook deliveries. Trigger a real event instead. Book a meeting through one of your scheduling links to send invitee.created. Cancel it to send invitee.canceled.
Check it works
Book a test meeting. The Horizon terminal prints one line:
POST 200 /api/webhooks/calendlyYour app terminal prints:
Received Calendly event: invitee.createdTroubleshooting
The signature doesn't match
- Check that
CALENDLY_SIGNING_KEYis identical to thesigning_keyin your subscription. - Sign
<t>.<raw body>. Don't runJSON.parseandJSON.stringifyfirst. - Check your computer's clock. The handler rejects timestamps more than three minutes old.
- Restart
npm run devafter you edit.env.local.
The request has no signature header
You created the subscription without signing_key. Calendly's docs mark it optional. Create a new subscription with a signing_key.
The API returns 409
A subscription with the same values may already exist. Calendly returns 409 when you create a resource that already exists. List your subscriptions with GET /webhook_subscriptions, which needs the webhooks:read scope.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-calendly and create the subscription again with the new URL. -s needs a subdomain you reserved, see Pricing.
Next steps
- Read Calendly's webhook signatures page.
Test Box webhooks locally
Receive Box V2 webhook events on localhost with a Horizon tunnel, and verify the primary and secondary signatures in a Next.js route handler.
Test Contentful webhooks locally
Receive Contentful webhook events on localhost with a Horizon tunnel, and verify the signed request with verifyRequest.