# Guide to Cronofy Webhooks: Features and Best Practices

Cronofy is calendar API and scheduling infrastructure. Its webhooks are called push notifications, and they arrive at a callback URL that belongs to a notification channel you create per account rather than to a dashboard setting.

This guide covers how Cronofy push notifications work, how to verify them, why a `change` notification tells you nothing about what changed, and the best practices for production.

## What are Cronofy webhooks?

A Cronofy push notification tells you that something happened in a connected calendar account. It does not tell you what. For the notification type you'll receive most often, `change`, the payload carries a timestamp and nothing else, and you follow it with an API read to fetch the delta.

The callback URL is a property of a notification channel, created through the API against a specific account's access token. There's no global webhook URL in a dashboard, which means channel creation is part of your onboarding flow rather than a one-time setup step.

## Cronofy webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST /v1/channels` per account. No dashboard-configured URL |
| Signature header | `Cronofy-HMAC-SHA256`, a comma-separated list of base64 digests |
| Signing key | Your application's OAuth client secret, prefixed `CRN_`. There is no separate webhook secret |
| Signed content | The raw request body, and nothing else |
| Discriminator | `notification.type`, a body field. There is no event-type header |
| Response deadline | 5 seconds |
| Retries | For 24 hours, after which the channel is closed permanently |
| Replay protection | None. No timestamp, nonce, or delivery id is sent |
| Regions | Six data centres, each with its own API host |

Cronofy publishes no source-IP allowlist, so don't build one into your receiver.

## Common events

The discriminator is `notification.type`. Six types arrive on a notification channel:

| Type | Fires when | What to do |
| --- | --- | --- |
| `verification` | Immediately after a channel is created | Return 2xx. There's no token to echo and no challenge to reflect |
| `change` | Something changed in the account's events | Call Read Events with `last_modified` set to `changes_since` |
| `profile_disconnected` | A calendar profile needs reauthorization | Prompt the user to reconnect |
| `conferencing_profile_disconnected` | A conferencing profile disconnected | Prompt the user to reconnect |
| `profile_initial_sync_completed` | Initial calendar sync finished | Do a follow-up sync |
| `gdpr_requested` | The account invoked right-to-be-forgotten | Delete their data on your side |

Two more values share the same `notification.type` field but arrive from other Cronofy callback surfaces: `smart_invite` from Smart Invite callbacks, and `event_subscription` from conferencing and event subscriptions. If you route on `notification.type` and only account for the six above, those two fall through your switch.

`event_subscription` is a container. The interaction that actually fired is nested at `notification.interactions[].type`, as `conferencing_assigned` or `conferencing_failing`, with an optional `subtype` giving the reason. Routing on the top-level type alone collapses every conferencing callback into one bucket.

The envelope is consistent across all of them:

```json
{
  "notification": { "type": "change", "changes_since": "2026-08-26T09:24:16Z" },
  "channel": {
    "channel_id": "chn_54cf7c7cb4ad4c1027000001",
    "callback_url": "https://example.com/webhooks/cronofy",
    "filters": { "calendar_ids": ["cal_n23kjnwrw2_sakdnawerd3"], "only_managed": false }
  }
}

```

`notification.changes_since` appears only on `change`. `channel.filters` reflects non-default filters and may be absent.

Cronofy's docs ask that your code tolerate types it doesn't recognise by ignoring them, so give your handler a default branch that returns 2xx rather than falling through to an error.

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

## Setting up Cronofy webhooks

Create a channel with the account's access token, against the data centre that account belongs to:

```bash
curl -X POST "$CRONOFY_DATA_CENTER_URL/v1/channels" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"callback_url":"https://example.com/webhooks/cronofy"}'

```

Cronofy sends a `verification` notification immediately, so a failure here shows up straight away rather than the first time a calendar changes.

Hosts differ per region: `api.cronofy.com` (US), `api-uk.cronofy.com`, `api-de.cronofy.com`, `api-au.cronofy.com`, `api-ca.cronofy.com` and `api-sg.cronofy.com`. Channel creation and the follow-up Read Events call must both hit the account's own data centre.

## Securing Cronofy webhooks

Cronofy computes HMAC-SHA256 over the raw request body, keyed with your application's OAuth client secret, and base64-encodes it. The header holds a comma-separated list of digests, one per active client secret, so that secrets can be rotated without dropping deliveries. Accept the request if any element matches:

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

function verifyCronofyWebhook(rawBody, hmacHeader, clientSecret) {
  if (!hmacHeader || !clientSecret) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', clientSecret).update(rawBody).digest('base64')
  );

  // reduce rather than some, so every candidate is compared and timing
  // doesn't reveal which one matched.
  return hmacHeader.split(',').reduce((matched, candidate) => {
    const buf = Buffer.from(candidate.trim());
    const ok = buf.length === expected.length && crypto.timingSafeEqual(buf, expected);
    return matched || ok;
  }, false);
}

```

The digest is standard base64 rather than base64url. Cronofy's own published test vector, `BmQmWVuZ70ILWjr1CAt5oC7YOolgnku4WZtlrKfx/6k=`, contains a `/`, which is a quick way to catch an encoding mistake.

Because the same scheme covers Cronofy's other callback surfaces, one verifier handles Smart Invite callbacks and Meeting Agent notifications too.

> Make Cronofy webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies the signature, acknowledges within Cronofy's deadline, and durably queues every notification.

## Cronofy webhook limitations and pain points

### A whole-string signature compare works until you rotate

The Problem: Comparing the `Cronofy-HMAC-SHA256` header against a single computed digest passes every test you write and keeps working in production, right up until a second client secret becomes active. From that moment the header holds two comma-joined digests, the whole-string compare never matches, and every delivery is rejected.

Why It Happens: With one active secret the header looks exactly like a single value, so the list is invisible until rotation begins.

Workarounds:

* Split on commas and treat verification as a membership test from the start.
* Test with a two-element header, using Cronofy's two published test vectors.

How Hookdeck Can Help: Hookdeck verifies Cronofy signatures at the edge, so rotation is handled in one place instead of in every receiver.

### `change` doesn't tell you what changed

The Problem: Handlers get written to read the changed event out of the payload. There's nothing there to read. `change` carries `changes_since` and the channel object, and that's all.

Why It Happens: Cronofy sends a thin notification by design. The delta lives behind an API call.

Workarounds:

* Treat `change` as a ping and follow it with `GET /v1/events?tzid=Etc/UTC&last_modified={changes_since}` against the account's data centre.
* Don't build reconciliation that expects your own writes to echo back. Cronofy doesn't send notifications for changes your API calls caused.

How Hookdeck Can Help: Hookdeck's transformations can enrich the thin notification before it reaches your service, so your handler receives the delta rather than a ping.

### A slow handler eventually destroys the channel

The Problem: Cronofy retries failed deliveries for 24 hours. If nothing succeeds in that window, the channel is closed automatically and no further notifications are sent for that account. Recovery means creating a new channel, and until you do, that account is silently stale.

Why It Happens: The response deadline is 5 seconds and the retry window has a hard end. A handler that does its work synchronously can sit just over the line for a day without anyone noticing.

Workarounds:

* Acknowledge with 2xx immediately and process asynchronously.
* Monitor your channels. A closed channel produces silence, not errors.

How Hookdeck Can Help: Hookdeck acknowledges within the deadline on your behalf and retries against your service on its own schedule, so a slow consumer can't cost you the channel.

### There is nothing to replay-check against

The Problem: Cronofy signs the body and only the body, so a captured notification stays valid forever and can be replayed at your endpoint.

Why It Happens: The signing scheme has no replay-protection material, which also means the usual timestamp tolerance check is impossible to implement.

Workarounds:

* Key idempotency on `channel_id` plus `changes_since`, or on the result of the follow-up read.
* Make the follow-up read itself idempotent, since that's where the real work happens.

How Hookdeck Can Help: Hookdeck deduplicates deliveries before they reach your service.

### Getting the region wrong looks like an auth failure

The Problem: Creating a channel against one data centre and reading events from another produces authorization errors that read like a bad token.

Why It Happens: Cronofy is multi-region and accounts belong to a specific data centre. The hosts differ but the API shape doesn't.

Workarounds:

* Store the account's data centre URL alongside its tokens and use it for both channel creation and the follow-up read.

How Hookdeck Can Help: Hookdeck routes each account's traffic to the right destination, so region stays a configuration detail rather than a branch in your handler.

## Best practices

### Verify against the list, not the value

Split `Cronofy-HMAC-SHA256` on commas and accept any match. This costs nothing today and is the difference between a working and a broken integration on the day you rotate a secret.

### Acknowledge in well under five seconds

Return 2xx before doing any work. The deadline is 5 seconds, and the consequence of missing it for long enough is a closed channel rather than a lost message. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Give your switch a default branch

Cronofy explicitly asks you to ignore unrecognised notification types. Two documented values already arrive from other callback surfaces, and more may follow.

### Dedupe without a timestamp

There's no replay-protection material to check, so idempotency has to come from your application. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Reconcile your channel list on a schedule

Compare active channels against the accounts that should have one. This is the only way a channel Cronofy closed on you shows up as something other than an account that mysteriously stopped syncing.

## Conclusion

Cronofy push notifications are thin by design. Verify the comma-separated `Cronofy-HMAC-SHA256` header as a list rather than a value, acknowledge within five seconds so a slow handler never costs you the channel, follow every `change` with a Read Events call on `changes_since`, and build idempotency in your application because the signing scheme gives you nothing to replay-check against.

[Hookdeck Event Gateway](https://hookdeck.com) verifies the signature, acknowledges inside Cronofy's deadline, deduplicates, and durably queues every notification, so your service processes each change once and on its own schedule.

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