Agent skill

Cronofy Webhooks (Push Notifications) Skill

Receive and verify Cronofy push notifications (webhooks). Use when setting up Cronofy notification channels, debugging Cronofy-HMAC-SHA256 verification, or handling Cronofy calendar events like verification, change, profile_disconnected, conferencing_profile_disconnected, profile_initial_sync_completed, and gdpr_requested. Cronofy signs the raw body with your application's client secret and sends a COMMA-SEPARATED list of base64 HMACs in the Cronofy-HMAC-SHA256 header.

Install this skill

npx skills add hookdeck/webhook-skills --skill cronofy-webhooks


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.

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.

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);
}
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/, examples/nextjs/, 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 secretBodyExpected 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.

{
  "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

TypeTriggered WhenWhat To Do
verificationImmediately after a channel is created, to test the callback URLJust return 2xx. There is no token to echo and no challenge to reflect
changeSomething changed in the account's eventsCall Read Events with last_modified = changes_since to fetch the delta
profile_disconnectedA calendar profile disconnected and needs reauthorizationPrompt the user to reconnect; read current state from UserInfo ["cronofy.data"]["profiles"]
conferencing_profile_disconnectedA conferencing profile disconnectedPrompt reconnect; state under UserInfo ["cronofy.data"]["conferencing_profiles"]
profile_initial_sync_completedInitial calendar sync finishedDo a follow-up sync. Not sent if the sync finished before the channel existed
gdpr_requestedThe account invoked GDPR right-to-be-forgottenRemove 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

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

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 for filters, listing, and closing channels.

Local Development

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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 28, 2026

View on GitHub →