# Cronofy Webhooks (Push Notifications)

Cronofy is calendar API / scheduling infrastructure. Its webhooks are called push
notifications and are delivered to a notification channel's `callback_url`.

> Not Calendly. Cronofy (cronofy.com, docs.cronofy.com) is a different company from
> Calendly, with a different signing scheme, different headers, and different payloads.
> If you're looking for `Calendly-Webhook-Signature`, you want
> [calendly-webhooks](/webhooks/skills/calendly-webhooks).

## When to Use This Skill

* How do I receive Cronofy push notifications / webhooks?
* How do I verify the `Cronofy-HMAC-SHA256` header?
* Why is my Cronofy webhook signature verification failing?
* How do I handle a Cronofy `change` notification and fetch what actually changed?
* How do I create a Cronofy notification channel?
* Why did my Cronofy channel stop sending notifications / get closed?
* What is `changes_since` and how do I use it with Read Events?

## Verification (core)

Cronofy computes HMAC-SHA256 over the raw request body, keyed with your
application's client secret (the OAuth secret, prefixed `CRN_`), base64-encoded.
The header is a comma-separated list — one HMAC per active client secret, because
Cronofy supports secret rotation. Pass 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')
  );

  // Comma-separated: one HMAC per ACTIVE client secret (rotation). Any match wins.
  // reduce (not some) so every candidate is compared — no early exit.
  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);
}

```

```python
import base64, hashlib, hmac

def verify_cronofy_webhook(raw_body: bytes, hmac_header: str, client_secret: str) -> bool:
    if not hmac_header or not client_secret:
        return False
    expected = base64.b64encode(
        hmac.new(client_secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    )
    # Compare as BYTES: compare_digest raises TypeError on non-ASCII str, and header
    # values arrive latin-1 decoded, so str comparison turns a hostile header into a 500.
    # List comprehension (not a generator) so every candidate is compared.
    return any([hmac.compare_digest(c.strip().encode("utf-8", "replace"), expected)
                for c in hmac_header.split(",")])

```

Standard base64, not base64url — Cronofy's own published digest
`BmQmWVuZ70ILWjr1CAt5oC7YOolgnku4WZtlrKfx/6k=` contains a `/`.

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/cronofy-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/cronofy-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/cronofy-webhooks/examples/fastapi/).

Cronofy's docs state HMACs are generated the same way for all callback events, so this
same verification also covers Cronofy's other callback surfaces (Event Triggers, Smart
Invite callbacks, Meeting Agent callback notifications).

### Doc-published test vectors

Cronofy publishes these; the examples' tests assert against them.

| Client secret | Body | Expected HMAC |
| --- | --- | --- |
| `CRN_NggYusqPGLxwjw5FHOJYOqSrTPNXy8WQf14OID` | `{"example":"well-known"}` | `5DxentQi5YSXODEzTVv06sRwJ3pULIz1KrYv20qxEK0=` |
| `CRN_nGlYDFXwfSXgB9rvGNBJyfE454GGPtWIbNuPwr` | `{"example":"well-known"}` | `BmQmWVuZ70ILWjr1CAt5oC7YOolgnku4WZtlrKfx/6k=` |

With both secrets active the header is the two joined by a comma:
`Cronofy-HMAC-SHA256: 5DxentQi5YSXODEzTVv06sRwJ3pULIz1KrYv20qxEK0=,BmQmWVuZ70ILWjr1CAt5oC7YOolgnku4WZtlrKfx/6k=`

## Payload Envelope

The discriminator is `notification.type` — a body field. There is no event-type
header; the only headers Cronofy sends are `Cronofy-HMAC-SHA256` and
`Content-Type: application/json; charset=utf-8`.

```json
{
  "notification": {
    "type": "change",
    "changes_since": "2026-08-26T09:24:16Z"
  },
  "channel": {
    "channel_id": "chn_54cf7c7cb4ad4c1027000001",
    "callback_url": "{CALLBACK_URL}",
    "filters": {
      "calendar_ids": ["cal_n23kjnwrw2_sakdnawerd3"],
      "only_managed": false
    }
  }
}

```

* `notification.changes_since` is present only on `change` notifications.
* `channel.filters` reflects non-default filters and may be absent or empty.

## Notification Types

| Type | Triggered When | What To Do |
| --- | --- | --- |
| `verification` | Immediately after a channel is created, to test the callback URL | Just return 2xx. There is no token to echo and no challenge to reflect |
| `change` | Something changed in the account's events | Call Read Events with `last_modified` = `changes_since` to fetch the delta |
| `profile_disconnected` | A calendar profile disconnected and needs reauthorization | Prompt the user to reconnect; read current state from UserInfo `["cronofy.data"]["profiles"]` |
| `conferencing_profile_disconnected` | A conferencing profile disconnected | Prompt reconnect; state under UserInfo `["cronofy.data"]["conferencing_profiles"]` |
| `profile_initial_sync_completed` | Initial calendar sync finished | Do a follow-up sync. Not sent if the sync finished before the channel existed |
| `gdpr_requested` | The account invoked GDPR right-to-be-forgotten | Remove their data on your side |

Cronofy's docs say "your code should be tolerant of others, by ignoring them, so if more
are introduced in future your integration will not fail" — your handler must have a
default branch that ignores unknown types and still returns 2xx.

> Cronofy's prose says "there are currently five types" and then lists six. The
> enumerated list above is authoritative.

## `change` Is a Thin Notification

The `change` payload does not contain the changed events. It is a ping. You always
follow it with an API read:

```
GET {data_center_url}/v1/events?tzid=Etc/UTC&last_modified={changes_since}
Authorization: Bearer {ACCESS_TOKEN}

```

This is the single most misunderstood thing about Cronofy push notifications.

Cronofy does not send push notifications for changes caused by your own API calls, so
don't build reconciliation that assumes echo-back.

## Delivery Semantics (Design Your Handler Around These)

* Respond 2xx within 5 seconds. Anything slower or non-2xx is a failed delivery.
* Retries run for 24 hours. If nothing succeeds in that window the channel is closed
  automatically and no further notifications are sent. A slow handler doesn't just drop
  one event — it eventually kills the channel. Ack fast, process async.
* No replay protection. There is no timestamp, nonce, channel id, URL or method mixed
  into the signed content — body only. Cronofy notifications are replayable by design.
  Use idempotency keyed on `channel_id` + `changes_since` (or on the Read Events result)
  rather than a timestamp tolerance check, which is impossible here.
* No source IP allowlist is published. Don't invent one.
* Event Triggers (a separate beta surface) use a 7-second timeout — don't generalize
  the 5s figure to it.

## Environment Variables

```bash
# Your Cronofy application's OAuth CLIENT SECRET — this is the HMAC key.
# There is NO separate webhook signing secret.
CRONOFY_CLIENT_SECRET=CRN_NggYusqPGLxwjw5FHOJYOqSrTPNXy8WQf14OID

# Data centre this account belongs to — needed for the change follow-up read.
CRONOFY_DATA_CENTER_URL=https://api.cronofy.com

```

Cronofy is multi-region and hosts differ per data centre: `api.cronofy.com` (US),
`api-uk.cronofy.com` (UK), `api-de.cronofy.com` (DE), `api-au.cronofy.com` (AU),
`api-ca.cronofy.com` (CA), `api-sg.cronofy.com` (SG). Channel creation and the
follow-up Read Events call must hit the same data centre as the account.

## Setup in One Line

There is no dashboard-configured global webhook URL. The callback URL is a property of
a channel, created per account:

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

```

See [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/cronofy-webhooks/references/setup.md) for filters, listing, and closing channels.

## Local Development

```bash
npx hookdeck-cli listen 3000 cronofy --path /webhooks/cronofy

```

No account required — the CLI creates a guest account and gives you a public HTTPS URL
plus a web UI for inspecting requests. Use the printed URL as your channel's
`callback_url`; Cronofy sends a `verification` notification immediately, so you'll see a
request land as soon as the channel is created.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/cronofy-webhooks/references/overview.md) - Notification types, payload shapes, `change` follow-up flow
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/cronofy-webhooks/references/setup.md) - Create/list/close notification channels, get the client secret, data centres
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/cronofy-webhooks/references/verification.md) - HMAC details, the comma-separated header, gotchas, debugging