Horizon

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:write scope, and the scheduled_events:read scope 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.

app/api/webhooks/calendly/route.ts
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.

.env.local
CALENDLY_SIGNING_KEY=replace-with-a-long-random-string

Start the app on port 3000.

npm run dev

Start 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-calendly

Horizon 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:

Output
  POST    200  /api/webhooks/calendly

Your app terminal prints:

Output
Received Calendly event: invitee.created

Troubleshooting

The signature doesn't match

  • Check that CALENDLY_SIGNING_KEY is identical to the signing_key in your subscription.
  • Sign <t>.<raw body>. Don't run JSON.parse and JSON.stringify first.
  • Check your computer's clock. The handler rejects timestamps more than three minutes old.
  • Restart npm run dev after 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

On this page