# Guide to Calendly Webhooks: Features and Best Practices

Calendly webhooks notify your systems about scheduling activity in real time: an invitee books a meeting, cancels one, gets marked as a no-show, or submits a routing form. If you're building on [Calendly](https://calendly.com)'s scheduling platform, webhooks are how you sync CRM records, send confirmations, and route leads without polling the API.

This guide covers how Calendly webhooks work, the signed-timestamp verification model, the event envelope and common events, the API-only subscription workflow, and the best practices for production.

## What are Calendly webhooks?

Calendly sends an HTTP `POST` to your endpoint whenever a subscribed event occurs. You register a webhook subscription via the API, and Calendly signs each request with the `Calendly-Webhook-Signature` header so you can verify the payload is authentic, hasn't been tampered with, and isn't a replay. Your endpoint verifies the signature, then returns a `2xx` status code to acknowledge receipt.

## Calendly webhook features

| Feature | Details |
| --- | --- |
| Configuration | API-only: `POST https://api.calendly.com/webhook_subscriptions` with `organization` or `user` scope; requires a Standard plan or higher |
| Verification | `Calendly-Webhook-Signature` header (`t=<timestamp>,v1=<signature>`): HMAC-SHA256 (hex) over `{timestamp}.{raw body}` with the subscription's signing key |
| Replay protection | Signed timestamp in the header; reject timestamps older than ~3 minutes (180 seconds) |
| Envelope | `event`, `created_at`, `created_by`, and a `payload` whose shape varies by event type |
| Events | Invitee scheduling and cancellation, no-show marks, and routing form submissions |
| Signing keys | One per subscription: provide your own at creation or store the one Calendly returns |
| Acknowledgement | Return a `2xx` status code |
| SDK | No official verification helper; verify manually in every framework |

## Common events

Calendly event names are the top-level `event` field values. The ones most integrations start with:

| Event | Fires when |
| --- | --- |
| `invitee.created` | An invitee schedules an event |
| `invitee.canceled` | An invitee cancels a scheduled event |
| `invitee_no_show.created` | An invitee is marked as a no-show |
| `invitee_no_show.deleted` | A no-show mark is removed from an invitee |
| `routing_form_submission.created` | A routing form is submitted |

Branch on the `event` field. The `payload` shape varies by event type: an invitee resource for `invitee.*` events, a submission for `routing_form_submission.created`. Every delivery shares the same envelope of `event`, `created_at` (an ISO 8601 timestamp of when the event occurred), `created_by`, and `payload`.

> See Calendly webhook payloads in action. Inspect and replay sample Calendly webhook payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up Calendly webhooks

Calendly webhooks are created via the API, not the dashboard. You need a Calendly account on a paid plan (webhooks require a Standard plan or higher), a personal access token (created under Integrations & apps > API & webhooks) or an OAuth access token, and your organization or user URI, which you can fetch from `GET https://api.calendly.com/users/me`.

Create a webhook subscription with the events you want to receive:

```bash
curl --request POST \
  --url https://api.calendly.com/webhook_subscriptions \
  --header "Authorization: Bearer $CALENDLY_ACCESS_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "url": "https://your-app.com/webhooks/calendly",
    "events": [
      "invitee.created",
      "invitee.canceled",
      "invitee_no_show.created",
      "routing_form_submission.created"
    ],
    "organization": "https://api.calendly.com/organizations/AAAAAAAAAAAAAAAA",
    "scope": "organization",
    "signing_key": "your_generated_signing_key"
  }'

```

`scope` can be `organization` or `user`; for `user` scope, also include a `user` URI. For `signing_key`, provide your own random secret (recommended, so you know it up front) or omit it and read the value Calendly returns. Either way, the response's `resource.signing_key` is what you store as `CALENDLY_WEBHOOK_SIGNING_KEY` and use for verification. Each subscription has its own signing key. To manage subscriptions later, list them with `GET https://api.calendly.com/webhook_subscriptions?organization=...&scope=organization` and delete with `DELETE https://api.calendly.com/webhook_subscriptions/{uuid}`.

The subscription `url` must be a publicly reachable HTTPS endpoint. For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 calendly --path /webhooks/calendly`) provides the HTTPS URL plus an inspector for replaying deliveries; trigger real events by scheduling, canceling, or marking a no-show on a booked event.

## Securing Calendly webhooks

Calendly sends the signature as a comma-separated list of `key=value` pairs:

```
Calendly-Webhook-Signature: t=1719921600,v1=8f2d...c1a9

```

`t` is the Unix timestamp (in seconds) when Calendly generated the signature, and `v1` is the hex-encoded HMAC-SHA256 signature. To verify: parse `t` and `v1`, build the signed content by concatenating `{t}.{raw request body}`, compute HMAC-SHA256 of that string with your subscription's signing key, compare against `v1` with a timing-safe comparison, and reject requests whose timestamp falls outside your tolerance (~180 seconds) to prevent replay attacks.

```javascript
const crypto = require("crypto");

function verifyCalendlySignature(rawBody, header, signingKey, toleranceSec = 180) {
  if (!header) return false;

  // Parse "t=...,v1=..." into { t, v1 }
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  // Replay protection: reject stale timestamps
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > toleranceSec) return false;

  // Signed content = "{timestamp}.{raw body}"
  const expected = crypto
    .createHmac("sha256", signingKey)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, "hex"),
      Buffer.from(expected, "hex")
    );
  } catch {
    return false; // length mismatch = invalid
  }
}

app.post(
  "/webhooks/calendly",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.headers["calendly-webhook-signature"];
    const signingKey = process.env.CALENDLY_WEBHOOK_SIGNING_KEY;
    if (!verifyCalendlySignature(req.body, header, signingKey)) {
      return res.sendStatus(401);
    }

    const { event, payload } = JSON.parse(req.body);
    // Acknowledge, then process asynchronously
    processQueue.add({ event, payload });
    res.sendStatus(200);
  }
);

```

Three details matter. Compute the HMAC over the exact bytes Calendly sent (`express.raw()` in Express, `await request.text()` in Next.js, `await request.body()` in FastAPI); parsing and re-serializing JSON changes key ordering or whitespace and breaks the signature. The signed string is `{t}.{body}`, not just the body. And `v1` is hex-encoded, so decode and compare as hex. Most frameworks lowercase header names, so read `calendly-webhook-signature`.

> Make Calendly webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies signatures upstream, deduplicates, and durably queues every event.

## Calendly webhook limitations and pain points

### Subscriptions are API-only and plan-gated

The Problem: There is no dashboard UI for creating webhooks. You need a paid plan (Standard or higher), an access token, and your organization or user URI before you can register a single endpoint.

Why It Happens: Calendly exposes webhook subscriptions purely as an API resource, available on Standard plans and above.

Workarounds:

* Create a personal access token under Integrations & apps > API & webhooks, fetch your URI from `GET /users/me`, and script subscription creation so it's repeatable across environments.
* Use the list and delete endpoints to audit what's registered.

How Hookdeck Can Help: Point one subscription at Hookdeck and fan events out with [Filters](/docs/filters), routing each event type to the handler that cares about it, so you rarely need to touch the subscription API again.

### Verification is manual and raw-body sensitive

The Problem: Calendly has no SDK verification helper, and the signed content is `{timestamp}.{raw body}`, so framework middleware that parses the JSON before you verify silently breaks every signature. Signing only the body, or comparing as base64 instead of hex, produces the same always-failing result.

Why It Happens: The scheme signs the timestamp plus the exact bytes on the wire, and there is no official library to hide those details, so every implementation is hand-rolled.

Workarounds:

* Capture the raw body before parsing (`express.raw()`, `await request.text()`, `await request.body()`).
* Build the signed content as `{t}.{body}`, compute HMAC-SHA256, and compare the hex digest timing-safely.

How Hookdeck Can Help: Hookdeck verifies Calendly signatures at the edge, so unverified traffic never reaches your handler and your application code stays free of raw-body plumbing.

### Replay protection depends on your clock

The Problem: Rejecting stale timestamps is the replay defense, but it compares Calendly's clock to yours. Clock drift or an overly tight tolerance produces verification failures that only appear intermittently, often after a delay.

Why It Happens: The timestamp is part of the signed content, so staleness checking is how you stop an attacker from resending a previously valid request; the check is only as accurate as your server's clock.

Workarounds:

* Keep your server clock in sync with NTP.
* Use a tolerance of around 180 seconds and compare the absolute difference, not just one direction.

How Hookdeck Can Help: Hookdeck verifies each delivery on arrival, then [retries](/docs/retries) delivery to your handler on its own schedule, so a redelivery hours later never trips a staleness check in your code.

### One signing key per subscription

The Problem: Each webhook subscription has its own signing key. Run subscriptions for multiple environments or scopes and a request verified with the wrong key fails, and that failure looks identical to a forged request.

Why It Happens: The signing key is a property of the subscription, supplied or returned at creation, not a single account-wide secret.

Workarounds:

* Provide your own `signing_key` at creation so you know each key up front.
* Store keys per subscription and verify every request with the key that belongs to the subscription that sent it.

How Hookdeck Can Help: Give each subscription its own Hookdeck source with its own key, and use [Issues](/docs/issues) to get alerted when deliveries start failing, so a key mismatch surfaces immediately instead of showing up later as missing events.

## Best practices

### Verify on the raw body, before parsing

Compute the HMAC over the exact bytes received, respond `401` on mismatch, and only then `JSON.parse`. Any middleware that parses first belongs after verification.

### Reject stale timestamps

Enforce the ~180-second tolerance with a timing-safe comparison of the signature itself. Skipping the timestamp check leaves your endpoint open to replayed requests that carry valid signatures.

### Acknowledge fast, process asynchronously

Return a `2xx` as soon as verification passes and hand the event to a queue or background worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Handle events idempotently

Build handlers so that processing the same delivery twice is safe, keying work off the resource URI in the `payload`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Manage signing keys deliberately

Supply your own `signing_key` when creating each subscription, store one key per subscription, and match key to subscription at verification time.

### Audit subscriptions regularly

List subscriptions with the API and delete ones you no longer need, so stale endpoints don't keep receiving signed scheduling data.

## Conclusion

Calendly webhooks are registered through the API on a Standard plan or higher, signed with a timestamped HMAC-SHA256 in the `Calendly-Webhook-Signature` header, and verified against a per-subscription signing key over the raw request body. Verify timing-safely, reject stale timestamps, acknowledge with a `2xx`, and process the work off the request path.

[Hookdeck Event Gateway](https://hookdeck.com) verifies Calendly signatures at the edge, deduplicates, durably queues every event, and retries your handler independently, so the raw-body, clock-tolerance, and key-management details stop being constraints your application code has to satisfy.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Calendly webhooks reliably in minutes.