Build, Test, and Debug Cronofy Webhooks on Localhost
Cronofy is a calendar API that connects applications to Google, Microsoft, Apple and Exchange calendars through a single integration. It sends webhooks, which Cronofy calls Push Notifications, whenever a connected calendar changes.
A change webhook tells you that events were added, updated or deleted, and gives you a changes_since timestamp. It doesn't say which of those happened, or to what. You find that out by calling Read Events and reconciling the response against what you stored. Most of your integration code lives in that reconciliation, and you need a real webhook to exercise it.
Cronofy delivers to a public HTTPS URL rather than to localhost, so you'll point a notification channel at a Hookdeck source and use the Hookdeck CLI to forward from there to your local server. The next section sets both up.
From there you'll build the handler and the sync worker it queues, in Node.js, Python, Go, Ruby, PHP and Java, and work the loop around them: trigger real notifications from the Cronofy dashboard or the Hookdeck Console, inspect what arrived, and retry it against your code instead of going back to a calendar for a fresh one. Later steps cover verifying the Cronofy-HMAC-SHA256 signature including during secret rotation, bookmarking test events, testing idempotency, and keeping a channel from closing after 24 hours of failed deliveries.
Prerequisites
Both routes below need three things on your machine:
- A local server to forward to. You write the handler in Step 4, so an empty server on port 3000 is enough to start
- A job queue, or whatever you already use for background work. The Step 4 handler acknowledges the webhook and hands the real work to a background job. A stub that logs what it was handed is enough to start
- The Hookdeck CLI, or
npx hookdeck-clito run it without installing
If you only want to build against a realistic Cronofy webhook, that is all you need: the Hookdeck Console ships captured Cronofy payloads and needs no Cronofy account at all, and Step 2 has the setup. What that route can't do is call the Cronofy API, which rules out the worker in Step 4, the sync half of Step 5, and the two Step 10 techniques that call it: watching the Read Events window, and comparing channel filters. Step 6 is out for a different reason, since the Console publishes its samples with the signature redacted. Step 7 and the bookmark exercise in Step 9 need a free Hookdeck account rather than a Cronofy one. If that trade suits you, skip the rest of this section.
To work against your own Cronofy account, you also need:
- A Cronofy account on their Emerging plan or above. Both the Push Notifications overview and the Create Notification Channel reference are marked "Required plan: Emerging", so webhooks are not available below it
- A Cronofy application, created in the developer dashboard. Note its
client_idandclient_secret, because Step 6 needs the secret again - A calendar to connect: Google, Microsoft, Apple or Exchange. Authorizing one against your application is what issues the access token, which the next section covers
Get an account access token
Two things trip people up here.
The token does not exist until a calendar has been authorized against your application. Creating the application is not enough: the token comes out of the OAuth flow that connects the calendar. Cronofy adds that push notifications "aren't enabled for Personal Tokens as they have to be linked to an Application".
A token issued in one data center is not valid against another. Use the host for the data center you chose at signup: api.cronofy.com for US, api-uk.cronofy.com for UK, and equivalents for AU, CA, DE and SG. This guide uses the US host throughout.
Otherwise the flow is Cronofy's standard one. Send yourself through Request Authorization asking for read_events, which is all webhooks need, then exchange the returned code via Request an Access Token. In development redirect_uri can be any value. Keep the refresh_token, because the access token expires after an hour.
Create the channel
The channel needs a Hookdeck source URL to point at, so create the source first by letting the CLI do it:
hookdeck listen 3000 cronofy --path /webhooks/cronofy
On a machine that has never run it, the CLI creates a guest account and then offers to create the source:
Source "cronofy" not found.
? Do you want to create a new source named "cronofy"? (y/N)
Answer y. Neither prompt appears again. Your source URL is on the Requests to line, and Step 1 walks through the rest of that screen. The CLI takes over the terminal, so run the rest of this section in a second one.
With that URL and your access token, create the channel:
curl -X POST https://api.cronofy.com/v1/channels \
-H "Authorization: Bearer {ACCOUNT_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"callback_url":"https://your-source-url"}'
Use the source URL exactly as the CLI prints it, with nothing appended. Hookdeck adds any extra path segment on top of the CLI's --path, so a callback_url ending in /cronofy plus --path /webhooks/cronofy delivers to /webhooks/cronofy/cronofy and misses your handler. For a per-user path, as Cronofy suggests, subtract it from --path so the two compose to the route your server listens on.
A verification webhook arrives almost immediately, which confirms your callback URL is reachable.
Step 1: Read the CLI Event List
Running hookdeck listen replaces your terminal with a live view of Events as they arrive. The rest of this step is a tour of that screen.
If you created a channel in Prerequisites, this is the session you already have running. If you are taking the Console route, Step 2 gives you a slightly different command; the screen it draws is the same one:
hookdeck listen 3000 cronofy --path /webhooks/cronofy
If you created the channel, its verification webhook has already arrived and failed, because there is no handler yet. The row is red either way: [404] if your server is up but has no route on that path, [ERROR] if nothing is listening on port 3000 at all. The status bar shows x Last event failed. That is expected. The row turns green once you write the handler in Step 4.
Restart the CLI to read the screen below on a clean slate, or press i to expand the header, which collapses as soon as the first event lands:
●── HOOKDECK CLI ──●
Listening on 1 source • 1 connection • [i] Collapse
cronofy
│ Requests to → https://console.hookdeck.com/e/<your-source-id>
└─ Forwards to → http://localhost:3000/webhooks/cronofy (cli-cronofy)
● Warning: http://localhost:3000 is unreachable. Check the server is running
💡 Sign up to make your webhook URL permanent: https://api.hookdeck.com/signin/guest?token=...
Events • [↑↓] Navigate ──────────────────────────────────────
● Connected. Waiting for events...
──────────────────────────────────────────────────
[q] Quit
The Requests to line is your source URL, which is what a Cronofy channel's callback_url points at.
The unreachable warning is about the port, not your route. It clears as soon as something is listening on 3000, whether or not the path your handler will use exists yet.
The hint below it depends on how you authenticated. Run as a plain guest, as above, and you get the signup line. Run with a --cli-key, which is what the Console route in Step 2 gives you, or once you are logged in, and it becomes a link to your dashboard instead.
The interface has three areas.
Connections Header (top): your source URL and local server routing, plus the CLI destination name in brackets. It auto-collapses once the first event arrives, and [i] Collapse becomes [i] Expand. Press i when you need to read or copy the source URL.
Event List (middle): a scrollable history of received events, up to 1000.
Status Bar (bottom): keyboard shortcuts and the state of the selected event.
Once webhooks start arriving the header collapses and the list fills:
●── HOOKDECK CLI ──●
Listening on 1 source • 1 connection • [i] Expand
Events • [↑↓] Navigate ──────────────────────────────────────
2026-08-27 17:02:30 [200] POST http://localhost:3000/webhooks/cronofy (1ms) → https://console.ho
2026-08-27 17:02:33 [200] POST http://localhost:3000/webhooks/cronofy (1ms) → https://console.ho
> 2026-08-27 17:02:37 [200] POST http://localhost:3000/webhooks/cronofy (1ms) → https://console.
──────────────────────────────────────────────────
> ✓ Last event succeeded [200] | [r] [o] [d] [q]
Navigate with arrow keys (↑ / ↓) or Vim-style keys (k / j). The selected event is marked with >.
Note what a row does not show: the notification type. Each line is timestamp, response status, method, local URL, duration and source. For most providers you can tell events apart by the path, but every Cronofy webhook arrives at the same URL, so the rows above are identical apart from their timestamps. Working out which is a change and which is a profile_disconnected means opening one, which is Step 3.
Step 2: Trigger One of Each Notification Type
To cover the notification types your integration handles, you need one of each. There are two ways to generate them.
From the Cronofy developer dashboard
Open your application, select Channels in the left navigation, and choose a channel showing as Active. Under the Trigger Push Notifications heading, a row of buttons sends a sample webhook of the chosen type to that channel's URL.
The buttons cover five types:
changeverificationprofile_disconnectedconferencing_profile_disconnectedprofile_initial_sync_completed
A sixth type, gdpr_requested, is sent when an account asks for its details to be removed. There is no dashboard button for it, so if your handler needs to cover that path you'll need to construct the request yourself.
Editing a calendar directly also generates a change webhook. Changes your own application makes through the Cronofy API do not, which is deliberate on Cronofy's part so that you can tell apart the changes that need action from the ones you already know about. That makes the dashboard buttons the practical way to drive a test loop.
From the Hookdeck Console
Cronofy is also available in the Hookdeck Console example webhooks. Opening a Console test URL with ?provider=cronofy gives you a Webhooks Library panel: pick Cronofy from the provider list, pick a notification type from its topic list, and press Send. That delivers a realistic Cronofy webhook to your handler without an application or a channel.
Use it to sketch a handler before you have credentials, or to reproduce a notification type without touching a calendar.
The Prerequisites list has the full set of limits. The one to keep in mind from here: these samples are published with the signature redacted, so Step 6 has nothing valid to verify.
Listen to the Console's source, not the one in Step 1. Opening that link creates its own source in a guest project, with its own name and CLI key. Running hookdeck listen 3000 cronofy points at a different source, so Send succeeds and nothing reaches your machine, with no error to explain why.
The Console shows a command with the source name and key already filled in. Take those two values from it, but set --path to the route your handler actually listens on:
hookdeck listen 3000 <console-source-name> --path /webhooks/cronofy --cli-key <cli-key>
Don't copy the Console's command verbatim. Its --path is a generic default, and if it doesn't match your route you'll get a 404 from your own server for every webhook, which looks like a delivery problem and isn't.
Trigger each type you plan to handle and confirm it appears in your CLI before moving on. The rows all look alike, so press d to check which type each one is.
Step 3: Inspect and Debug Cronofy Webhook Payloads
Select any event in the CLI using the arrow keys, then press d to open the detailed view.
Terminal output and payloads in this guide are real captures with two edits: source IDs and callback_url values are replaced with placeholders, and some headers are trimmed where marked. Signature values are reproduced as captured, so they will not recompute against the payloads as printed.
It opens on the request, headers first:
evt_51ndXeutVdYw30koCt • 2026-08-27 17:02:37 • 1.175959ms
───────────────────────────────────────────────────────────────
Request
POST http://localhost:3000/webhooks/cronofy
Accept: application/json, text/plain, */*
Idempotency-Key: evt_51ndXeutVdYw30koCt
content-length: 218
content-type: application/json; charset=utf-8
cronofy-hmac-sha256: FTi+Ok3Z/Mmxkz9LTdba0n3hbxgrRk7nUsdrgUjGDRY=
user-agent: Cronofy (https://www.cronofy.com/support/)
[trimmed: x-vercel-* hosting headers, and several more X-Hookdeck-* entries]
X-Hookdeck-Signature: 3R+wo1vPmkRU4bIS9Gmhsu7wUGwK4tYPo9s4K1s1Z8Q=
X-Hookdeck-EventID: evt_51ndXeutVdYw30koCt
X-Hookdeck-Source-Name: cronofy
X-Hookdeck-Attempt-Count: 1
X-Hookdeck-Attempt-Trigger: INITIAL
──────────────────────────────────────────────────────────────
[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page • [C] Copy request • [H] Copy headers • [B] Copy body
Of that list, only content-type, content-length, cronofy-hmac-sha256 and user-agent came from Cronofy. Everything else is added in transit, and the X-Hookdeck-* set is useful in its own right: Attempt-Count and Attempt-Trigger tell you whether you are looking at a first delivery (1 / INITIAL) or a retry (2 / MANUAL).
Scroll down and you reach the payload and your server's response:
{
"notification": {
"type": "profile_disconnected"
},
"channel": {
"channel_id": "chn_6a8df5c5944f9501621a3083",
"callback_url": "https://your-source-url",
"filters": {},
"scheduling_conversations": {}
}
}
Response
200
Date: Thu, 27 Aug 2026 17:02:37 GMT
Connection: keep-alive
[trimmed: your framework's other response headers]
Scroll with arrow keys (↑ / ↓), page with PgUp / PgDown, and close with d or ESC. C copies the whole request, H just the headers and B just the body, which saves retyping a payload into a test fixture.
Cronofy webhooks arrive with these headers:
content-type: application/json; charset=utf-8
cronofy-hmac-sha256: aIqKw8ATvX78aDoYBY5pnfYjC4gBbIUHaJU6mz2fbn4=
user-agent: Cronofy (https://www.cronofy.com/support/)
Cronofy-HMAC-SHA256 is on every webhook, and Step 6 covers verifying it.
A verification payload looks like this:
{
"notification": {
"type": "verification"
},
"channel": {
"channel_id": "chn_6a8df5c5944f9501621a3083",
"callback_url": "https://your-source-url",
"filters": {},
"scheduling_conversations": {}
}
}
Treat the payload shape as open-ended. Cronofy asks you to ignore notification types you don't recognize so new ones don't break your integration, and the same applies to fields: read the keys you need and leave the rest alone.
The change notification tells you when, not what
A change payload looks like this:
{
"notification": {
"type": "change",
"changes_since": "2026-08-25T20:06:29Z"
},
"channel": {
"channel_id": "chn_6a8df5c5944f9501621a3083",
"callback_url": "https://your-source-url",
"filters": {},
"scheduling_conversations": {}
}
}
There's no event, no calendar, and no indication of whether an event was added, updated or deleted. You get one timestamp, and everything modified since it is yours to go and fetch.
Your application takes changes_since and passes it to Read Events as the last_modified parameter, then reconciles the result against your own state. This design keeps the webhook small and lets you fetch exactly the window you need, and it means every change webhook implies an outbound API call. A user who reorganizes twenty meetings produces a burst of webhooks, and each one costs you a Read Events request. In Step 4 you build a handler that accounts for that.
The other types follow the same pattern. profile_disconnected and conferencing_profile_disconnected tell you that a profile needs reauthorization without naming which one; UserInfo returns the current state of an account's profiles under ["cronofy.data"]["profiles"] and ["cronofy.data"]["conferencing_profiles"]. profile_initial_sync_completed tells you a first sync finished, so read events again.
The pattern holds across all of them: the webhook tells you which account to ask about, and you call an endpoint to find out what actually changed. Design for that from the start.
Step 4: Build the Handler and the Worker
Cronofy treats a webhook as delivered only if your endpoint returns a 2xx within five seconds. Anything slower counts as a failure, however much work your handler eventually completed.
Five seconds is comfortable for an acknowledgment and tight for real work. Since a change webhook means "call Read Events and reconcile", the question is what doing that work inline does to your response time.
Inline works while a single Read Events page comes back in a few hundred milliseconds, and stops working the first time a busy calendar paginates. In production, where Cronofy talks to your endpoint directly, that means the delivery fails even though your sync completed, and Cronofy retries a webhook you have already handled. You won't see it locally, because your source answers Cronofy long before your handler finishes, which is a trap Step 9 comes back to. Queuing costs you a worker to run and a log to watch. It buys a response time that doesn't depend on how much changed, which is why the handlers below enqueue and return.
Where in the handler you set the status is a matter of framework style rather than timing. Express writes the response when you call res.sendStatus(200), so a later throw cannot change it. Sinatra and Flask build the response from what the handler returns, so in Sinatra an exception after status 200 still produces a 500, and in Flask nothing is committed until the return. Either way the thing that keeps you inside five seconds is queuing rather than working, not the placement of the status line:
app.post('/webhooks/cronofy', express.json(), (req, res) => {
const { notification, channel } = req.body;
// Queue the work and return; don't do it inline.
res.sendStatus(200);
switch (notification.type) {
case 'change':
// changes_since is the only thing telling you what to fetch.
queue.add('cronofy.sync', {
channelId: channel.channel_id,
changesSince: notification.changes_since,
});
break;
case 'profile_disconnected':
case 'conferencing_profile_disconnected':
// Neither says which profile. Ask UserInfo.
queue.add('cronofy.reconcile-profiles', { channelId: channel.channel_id });
break;
case 'profile_initial_sync_completed':
queue.add('cronofy.full-sync', { channelId: channel.channel_id });
break;
case 'verification':
// Nothing to do. Cronofy is checking the URL is real.
break;
default:
// gdpr_requested, and whatever Cronofy adds next.
logger.info({ type: notification.type }, 'unhandled Cronofy notification');
}
});
@app.route('/webhooks/cronofy', methods=['POST'])
def cronofy_webhook():
payload = request.get_json()
notification, channel = payload['notification'], payload['channel']
ntype = notification['type']
if ntype == 'change':
# changes_since is the only thing telling you what to fetch.
queue.enqueue('workers.cronofy_sync.cronofy_sync',
channel['channel_id'], notification['changes_since'])
elif ntype in ('profile_disconnected', 'conferencing_profile_disconnected'):
# Neither says which profile. Ask UserInfo.
queue.enqueue('workers.reconcile_profiles.reconcile_profiles',
channel['channel_id'])
elif ntype == 'profile_initial_sync_completed':
queue.enqueue('workers.full_sync.full_sync', channel['channel_id'])
elif ntype == 'verification':
pass # Cronofy is checking the URL is real.
else:
# gdpr_requested, and whatever Cronofy adds next.
# warning, not info: Flask's default level hides info outside debug mode.
app.logger.warning('unhandled Cronofy notification: %s', ntype)
return '', 200
type Payload struct {
Notification struct {
Type string `json:"type"`
ChangesSince string `json:"changes_since"`
} `json:"notification"`
Channel struct {
ChannelID string `json:"channel_id"`
} `json:"channel"`
}
func cronofyWebhook(w http.ResponseWriter, r *http.Request) {
var p Payload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Queue the work and return; don't do it inline.
w.WriteHeader(http.StatusOK)
switch p.Notification.Type {
case "change":
// changes_since is the only thing telling you what to fetch.
queue.Add(SyncJob{ChannelID: p.Channel.ChannelID, ChangesSince: p.Notification.ChangesSince})
case "profile_disconnected", "conferencing_profile_disconnected":
// Neither says which profile. Ask UserInfo.
queue.Add(ReconcileProfilesJob{ChannelID: p.Channel.ChannelID})
case "profile_initial_sync_completed":
queue.Add(FullSyncJob{ChannelID: p.Channel.ChannelID})
case "verification":
// Nothing to do.
default:
// gdpr_requested, and whatever Cronofy adds next.
log.Printf("unhandled Cronofy notification: %s", p.Notification.Type)
}
}
post '/webhooks/cronofy' do
payload = JSON.parse(request.body.read)
notification, channel = payload['notification'], payload['channel']
case notification['type']
when 'change'
# changes_since is the only thing telling you what to fetch.
SyncJob.perform_async(channel['channel_id'], notification['changes_since'])
when 'profile_disconnected', 'conferencing_profile_disconnected'
# Neither says which profile. Ask UserInfo.
ReconcileProfilesJob.perform_async(channel['channel_id'])
when 'profile_initial_sync_completed'
FullSyncJob.perform_async(channel['channel_id'])
when 'verification'
# Nothing to do.
else
# gdpr_requested, and whatever Cronofy adds next.
logger.warn "unhandled Cronofy notification: #{notification['type']}"
end
status 200
end
Route::post('/webhooks/cronofy', function (Request $request) {
$payload = $request->json()->all();
$notification = $payload['notification'];
$channelId = $payload['channel']['channel_id'];
switch ($notification['type']) {
case 'change':
// changes_since is the only thing telling you what to fetch.
SyncJob::dispatch($channelId, $notification['changes_since']);
break;
case 'profile_disconnected':
case 'conferencing_profile_disconnected':
// Neither says which profile. Ask UserInfo.
ReconcileProfilesJob::dispatch($channelId);
break;
case 'profile_initial_sync_completed':
FullSyncJob::dispatch($channelId);
break;
case 'verification':
break; // Nothing to do.
default:
// gdpr_requested, and whatever Cronofy adds next.
Log::info('unhandled Cronofy notification: ' . $notification['type']);
}
return response('', 200);
});
server.createContext("/webhooks/cronofy", exchange -> {
JsonObject payload = JsonParser.parseReader(
new InputStreamReader(exchange.getRequestBody())
).getAsJsonObject();
JsonObject notification = payload.getAsJsonObject("notification");
String channelId = payload.getAsJsonObject("channel").get("channel_id").getAsString();
// Queue the work and return; don't do it inline.
exchange.sendResponseHeaders(200, -1);
exchange.close();
switch (notification.get("type").getAsString()) {
case "change":
// changes_since is the only thing telling you what to fetch.
queue.add(new SyncJob(channelId, notification.get("changes_since").getAsString()));
break;
case "profile_disconnected":
case "conferencing_profile_disconnected":
// Neither says which profile. Ask UserInfo.
queue.add(new ReconcileProfilesJob(channelId));
break;
case "profile_initial_sync_completed":
queue.add(new FullSyncJob(channelId));
break;
case "verification":
break; // Nothing to do.
default:
// gdpr_requested, and whatever Cronofy adds next.
logger.info("unhandled Cronofy notification: {}", notification.get("type"));
}
});
The default branch is the same tolerance Step 3 asked for, applied to types rather than fields. gdpr_requested lands there too, and since the dashboard can't trigger it, that branch is the only place you will ever see it before production does.
The worker: one Read Events call per webhook, minimum
The handler is the small half. The job it queues is where the webhook is finally acted on, and it is the part the changes_since design forces on you.
Read Events takes changes_since as last_modified, as Step 3 covered. Its incremental synchronization guidance sets the rest of the query, and four points there are easy to miss:
tzidis required even though you are filtering by modification time. Use a fixed value such asEtc/UTC.include_deleted=true, or deletions never reach you. Without it a user who cancels a meeting produces achangewebhook whose Read Events response omits the event entirely, and your cache keeps it forever.include_managed=true, so events your own application created are included.- Omit
fromandtoentirely, so nothing is filtered out by date.
The response paginates, so one webhook can mean several requests. pages.next_page is an absolute URL and is absent on the last page.
// Registered for the 'cronofy.sync' job the handler queues.
async function cronofySync({ channelId, changesSince }) {
const account = await accounts.findByChannelId(channelId);
let url = 'https://api.cronofy.com/v1/events?tzid=Etc/UTC'
+ '&include_deleted=true&include_managed=true'
+ `&last_modified=${encodeURIComponent(changesSince)}`;
while (url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${account.accessToken}` },
});
if (!res.ok) throw new Error(`Read Events failed: ${res.status}`);
const body = await res.json();
for (const event of body.events) {
// Upsert by event_uid: overlapping windows deliver the same event twice.
// event.deleted is why include_deleted matters.
await calendar.upsert(account.id, event);
}
url = body.pages.next_page; // undefined on the last page
}
}
import requests
# Queue this by its import path, e.g. queue.enqueue(
# 'workers.cronofy_sync.cronofy_sync', channel_id, changes_since)
def cronofy_sync(channel_id, changes_since):
account = accounts.find_by_channel_id(channel_id)
url = "https://api.cronofy.com/v1/events"
params = {
"tzid": "Etc/UTC",
"last_modified": changes_since,
"include_deleted": "true",
"include_managed": "true",
}
while url:
res = requests.get(
url, params=params,
headers={"Authorization": f"Bearer {account.access_token}"},
)
res.raise_for_status()
body = res.json()
for event in body["events"]:
# Upsert by event_uid: overlapping windows deliver the same event twice.
calendar.upsert(account.id, event)
# next_page is a complete URL; re-sending params would corrupt it.
url = body["pages"].get("next_page")
params = None
type SyncJob struct {
ChannelID string
ChangesSince string
}
// The json tags are load-bearing: encoding/json ignores case but not
// underscores, so Pages.NextPage stays empty without them and the loop
// silently stops after the first page.
type ReadEventsResponse struct {
Pages struct {
NextPage string `json:"next_page"`
} `json:"pages"`
Events []Event `json:"events"`
}
func (j SyncJob) Run() error {
account, err := accounts.FindByChannelID(j.ChannelID)
if err != nil {
return err
}
// Imported as `neturl "net/url"`, because the loop variable is called url.
url := "https://api.cronofy.com/v1/events?tzid=Etc/UTC" +
"&include_deleted=true&include_managed=true" +
"&last_modified=" + neturl.QueryEscape(j.ChangesSince)
for url != "" {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+account.AccessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
res.Body.Close()
return fmt.Errorf("read events failed: %d", res.StatusCode)
}
var body ReadEventsResponse
err = json.NewDecoder(res.Body).Decode(&body)
res.Body.Close()
if err != nil {
return err
}
for _, event := range body.Events {
// Upsert by event_uid: overlapping windows deliver the same event twice.
if err := calendar.Upsert(account.ID, event); err != nil {
return err
}
}
url = body.Pages.NextPage // "" on the last page
}
return nil
}
class SyncJob
include Sidekiq::Job
def perform(channel_id, changes_since)
account = Accounts.find_by_channel_id(channel_id)
url = "https://api.cronofy.com/v1/events?tzid=Etc/UTC" \
"&include_deleted=true&include_managed=true" \
"&last_modified=#{CGI.escape(changes_since)}"
while url
res = HTTP.auth("Bearer #{account.access_token}").get(url)
raise "Read Events failed: #{res.status}" unless res.status.success?
body = JSON.parse(res.body.to_s)
body['events'].each do |event|
# Upsert by event_uid: overlapping windows deliver the same event twice.
Calendar.upsert(account.id, event)
end
url = body['pages']['next_page']
end
end
end
class SyncJob implements ShouldQueue
{
// Queueable is what gives the class the static dispatch() the handler calls.
use Queueable;
public function __construct(
private string $channelId,
private string $changesSince,
) {}
public function handle(): void
{
$account = Accounts::findByChannelId($this->channelId);
$url = 'https://api.cronofy.com/v1/events?tzid=Etc/UTC'
. '&include_deleted=true&include_managed=true'
. '&last_modified=' . urlencode($this->changesSince);
while ($url) {
$res = Http::withToken($account->access_token)->get($url);
$res->throw();
$body = $res->json();
foreach ($body['events'] as $event) {
// Upsert by event_uid: overlapping windows deliver the same event twice.
Calendar::upsert($account->id, $event);
}
$url = $body['pages']['next_page'] ?? null;
}
}
}
// @SerializedName is load-bearing: Gson's default naming policy is IDENTITY,
// so nextPage stays null without it and the loop stops after one page.
class ReadEventsResponse {
static class Pages { @SerializedName("next_page") String nextPage; }
Pages pages;
List<Event> events;
}
public class SyncJob implements Runnable {
private final String channelId;
private final String changesSince;
public SyncJob(String channelId, String changesSince) {
this.channelId = channelId;
this.changesSince = changesSince;
}
@Override
public void run() {
try {
sync();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void sync() throws Exception {
Account account = accounts.findByChannelId(channelId);
String url = "https://api.cronofy.com/v1/events?tzid=Etc/UTC"
+ "&include_deleted=true&include_managed=true"
+ "&last_modified=" + URLEncoder.encode(changesSince, StandardCharsets.UTF_8);
while (url != null) {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + account.accessToken())
.build();
HttpResponse<String> res = client.send(req, BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new IOException("Read Events failed: " + res.statusCode());
}
ReadEventsResponse body = gson.fromJson(res.body(), ReadEventsResponse.class);
for (Event event : body.events) {
// Upsert by event_uid: overlapping windows deliver the same event twice.
calendar.upsert(account.id(), event);
}
url = body.pages.nextPage;
}
}
}
Two things to carry forward. The upsert has to be keyed on something stable, because overlapping changes_since windows deliver the same event more than once; Cronofy documents event_uid as uniquely identifying an event. And a burst of calendar activity is a burst of these jobs, each with its own pagination, which is the amplification the change design implies.
Step 5: Retry a Captured Webhook Instead of Triggering a New One
Select a captured webhook in the CLI and press r. Hookdeck redelivers that exact request to your local server, with the same body and the same headers Cronofy sent. The X-Hookdeck-Attempt-* headers from Step 3 change, since this is a new attempt. Your code has changed since it arrived; the webhook has not.
This matters more for Cronofy than for providers that send rich payloads. Where those let you do the work from the payload in front of you, a change carries no calendar data, so the substance of your handler is all downstream of it: the Read Events call, its pagination loop, and the upsert against what you already stored. That is a paginating sync and an upsert loop hanging off a payload with one timestamp in it.
A practical sequence:
- Trigger one
changefrom the dashboard or the Console. That's the last time you need to leave your editor. - Press
r. Confirm the webhook reaches thechangebranch and the sync job is queued. - Adapt the worker from Step 4 to your own storage. Press
rto run it again. - Watch it fail on whatever you got wrong, usually the upsert. Fix it. Press
r. - Repeat until the handler and the worker are both right.
Watch the right window, though. Because the handler queues rather than works, the CLI shows 200 whether the queued job then succeeds or throws. The CLI is telling you the webhook was delivered and accepted, not that the sync worked. Keep your worker's log where you can see it, or you'll iterate on a green screen while the actual work fails behind it.
One thing the loop assumes: that your server is running the code you just wrote. Plain node server.js won't reload on edit, so either restart it between changes or run it under a watcher such as node --watch or nodemon. Otherwise r faithfully redelivers the webhook to your old handler.
Step 6: Verify the Cronofy-HMAC-SHA256 Signature
Every webhook carries a Cronofy-HMAC-SHA256 header: a base64-encoded HMAC-SHA256 of the raw request body, keyed with your application's client secret.
Three properties shape the implementation.
It signs the raw body. If your framework parses JSON and you re-serialize it to verify, key ordering or whitespace differences will break the comparison. Capture the raw bytes before parsing.
The header can carry multiple values. During client secret rotation Cronofy generates one HMAC per active secret and joins them with commas:
Cronofy-HMAC-SHA256: {HMAC_FROM_SECRET_1},{HMAC_FROM_SECRET_2}
Verification is therefore a membership test, not an equality test. Comparing the whole header against a single computed value works until someone rotates a secret.
Only the body is signed. There's no timestamp or nonce, so identical bodies produce identical signatures. The signature tells you the request came from someone holding your client secret; it doesn't tell you when, and it can't distinguish a replay from the original. Make your handler idempotent and you have both properties covered.
Cronofy publishes known-answer test vectors. Wire both into your test suite:
Client secret: CRN_NggYusqPGLxwjw5FHOJYOqSrTPNXy8WQf14OID
Request body: {"example":"well-known"}
Expected: 5DxentQi5YSXODEzTVv06sRwJ3pULIz1KrYv20qxEK0=
And for the rotation case, the same body with two active secrets:
Secrets: CRN_NggYusqPGLxwjw5FHOJYOqSrTPNXy8WQf14OID
CRN_nGlYDFXwfSXgB9rvGNBJyfE454GGPtWIbNuPwr
Expected: 5DxentQi5YSXODEzTVv06sRwJ3pULIz1KrYv20qxEK0=,BmQmWVuZ70ILWjr1CAt5oC7YOolgnku4WZtlrKfx/6k=
Verification should return true for either secret against that two-value header.
const crypto = require('crypto');
function verifyCronofySignature(rawBody, header, secret) {
if (!header) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest();
return header.split(',').some((candidate) => {
const given = Buffer.from(candidate.trim(), 'base64');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
// Express: capture the raw body before JSON parsing.
app.post('/webhooks/cronofy',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verifyCronofySignature(req.body, req.get('Cronofy-HMAC-SHA256'), process.env.CRONOFY_CLIENT_SECRET)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200);
// ...dispatch on payload.notification.type
});
import hmac, hashlib, base64, os
def verify_cronofy_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
for candidate in header.split(','):
try:
given = base64.b64decode(candidate.strip())
except Exception:
continue
if hmac.compare_digest(given, expected):
return True
return False
@app.route('/webhooks/cronofy', methods=['POST'])
def cronofy_webhook():
# request.get_data() returns the raw bytes, before any JSON parsing.
if not verify_cronofy_signature(request.get_data(),
request.headers.get('Cronofy-HMAC-SHA256', ''),
os.environ['CRONOFY_CLIENT_SECRET']):
return '', 401
return '', 200
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
)
func verifyCronofySignature(rawBody []byte, header, secret string) bool {
if header == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, candidate := range strings.Split(header, ",") {
given, err := base64.StdEncoding.DecodeString(strings.TrimSpace(candidate))
if err != nil {
continue
}
if hmac.Equal(given, expected) {
return true
}
}
return false
}
require 'openssl'
require 'base64'
def verify_cronofy_signature(raw_body, header, secret)
return false if header.nil? || header.empty?
expected = OpenSSL::HMAC.digest('SHA256', secret, raw_body)
header.split(',').any? do |candidate|
given = Base64.decode64(candidate.strip)
given.bytesize == expected.bytesize && OpenSSL.secure_compare(given, expected)
end
end
post '/webhooks/cronofy' do
# Read the body once. Rack does not rewind it, so a second
# request.body.read returns "" and JSON.parse raises.
raw_body = request.body.read
halt 401 unless verify_cronofy_signature(
raw_body, request.env['HTTP_CRONOFY_HMAC_SHA256'], ENV['CRONOFY_CLIENT_SECRET']
)
payload = JSON.parse(raw_body)
# ...dispatch on payload['notification']['type']
status 200
end
function verifyCronofySignature(string $rawBody, ?string $header, string $secret): bool {
if ($header === null || $header === '') {
return false;
}
$expected = hash_hmac('sha256', $rawBody, $secret, true);
foreach (explode(',', $header) as $candidate) {
$given = base64_decode(trim($candidate), true);
if ($given !== false && hash_equals($expected, $given)) {
return true;
}
}
return false;
}
// Laravel: $request->getContent() is the raw body.
Route::post('/webhooks/cronofy', function (Request $request) {
if (!verifyCronofySignature(
$request->getContent(),
$request->header('Cronofy-HMAC-SHA256'),
env('CRONOFY_CLIENT_SECRET')
)) {
return response('', 401);
}
return response('', 200);
});
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public class CronofySignature {
public static boolean verify(byte[] rawBody, String header, String secret)
throws Exception {
if (header == null || header.isEmpty()) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal(rawBody);
for (String candidate : header.split(",")) {
try {
byte[] given = Base64.getDecoder().decode(candidate.trim());
if (MessageDigest.isEqual(given, expected)) return true;
} catch (IllegalArgumentException ignored) {
// Not valid base64; try the next candidate.
}
}
return false;
}
}
All six implementations use a constant-time comparison and treat the header as a list.
Merging this into the Step 4 handler needs care in every language, because verification wants the raw bytes and Step 4 wanted a parsed object. Read the body once, verify, then parse what you already read.
The specifics differ. In Express, express.json() has to be replaced with express.raw(), since a JSON parser in front leaves req.body a parsed object rather than the bytes Cronofy signed. In Sinatra, Rack does not rewind the body, so a second request.body.read returns an empty string and JSON.parse raises. Go's r.Body and Java's request stream are consumed on first read in the same way. In each of those, the fix is to capture the raw body into a variable, pass that to the verifier, and parse that same variable rather than re-reading.
Flask and Laravel need no special handling: request.get_data() and $request->getContent() both cache the body, so calling get_json() or json() afterwards still works.
To test verification locally, press r on a webhook that arrived from Cronofy. Because the retry is byte-identical, a handler that verifies correctly keeps verifying, and one that re-serializes the body before hashing fails every time. That's a bug worth surfacing early.
The Console route can't exercise this step. The Cronofy examples in the Console are published with their signature redacted, so Cronofy-HMAC-SHA256 arrives as the literal string REDACTED and verification fails no matter which secret you use. That's deliberate, since a real signature published alongside its body would let anyone replay a correctly signed request. Unlike the other Console limits, this one isn't about calling the Cronofy API: verification is entirely local, it just needs a webhook signed by your own channel.
Step 7: Build a Test Event Library with Bookmarks
Once you've captured the notification types you handle, preserve them. Bookmarks turn a captured webhook into a permanent fixture.
This step needs a Hookdeck account, and how you claim one depends on the route you took. On the Console route from Step 2, choose Save Data in the Console: the sources you have made carry over as they are, along with the requests they captured. On the plain guest route from Step 1, follow the Sign up to make your webhook URL permanent link the CLI prints. Either way you keep what you have already captured.
Select an event in the CLI and press o to open it in the dashboard, then click Bookmark and give it a descriptive label.
Bookmarked events are saved to your Hookdeck account and accessible from:
- The Bookmarks page in the dashboard
- The Bookmarks API for automation
- Any connection in your project
A useful Cronofy library is small, because there are only six notification types:
cronofy_changefor the main sync pathcronofy_profile_disconnectedandcronofy_conferencing_profile_disconnected, which exercise the UserInfo round trip yourchangepath doesn'tcronofy_profile_initial_sync_completedcronofy_verification, to confirm your handler acknowledges and does nothing else
gdpr_requested is the one you can't capture from the dashboard. To exercise that branch, send a hand-written request through your source. It won't carry a valid signature, so either test it with verification disabled or sign the body with your own secret first.
Replay a bookmarked event
Bookmarks are not replayed with r. Open the Bookmarks page, find the one you labeled cronofy_change, and click its replay button, or call the API:
curl https://api.hookdeck.com/2025-07-01/bookmarks/{bookmark_id}/trigger \
-X POST \
-H "Authorization: Bearer YOUR_API_KEY"
Trigger it from the CLI View. Bookmarks triggered in the HTTP View go only to HTTP destinations, and ones triggered in the CLI View go only to connected CLIs, so the view you are in decides whether the webhook reaches your machine.
One difference from r matters for the idempotency work in Step 9: triggering a bookmark creates a new event, where a retry creates another attempt on the existing one. So a replayed bookmark arrives with a fresh Idempotency-Key, while a retry reuses the original. If your deduplication is keyed on that header, five bookmark replays look like five distinct webhooks and five retries look like one. Both are worth testing, because production sees both: Cronofy's own retries arrive as new deliveries, not as attempts on a delivery you have already seen.
That makes bookmarks the better tool for burst behavior. Replay change five times and count the sync jobs your worker runs. Five is the honest answer unless you coalesce overlapping windows, and each one costs at least one Read Events call and more if the window paginates. Deciding whether that's acceptable is better done now than when a customer with a busy calendar decides it for you.
Bookmarks are shared across the project, so a labeled set is a regression suite your whole team can replay.
Step 8: Read Your Handler's Response and Duration
Select any event and press d. The delivery duration is in the title bar, as Step 3's capture shows; scroll past the request and you get the status code and your response headers.
One thing to know before you rely on it: the view renders a JSON response body, but not a plain-text or HTML one. res.json({ error: 'Queue unavailable' }) is visible; res.status(500).send('Queue unavailable') shows the status and headers with no body. If you want your handler's error detail to be readable here, return it as JSON.
The duration is the number to watch. It times the leg from your source to your machine, not the one Cronofy's five-second budget applies to, so it will never fail the way production would; Step 9 covers that difference. It is still the fastest signal that your handler is getting slower. Some things to look for:
Durations creeping up. A handler that took 80ms and now takes 900ms has picked up a synchronous call, usually a database write or a Read Events request that belongs in the worker. Better to notice at 900ms than at 5,100ms.
A 200 that hides an error. A handler that catches everything and returns 200 looks healthy in the CLI while silently dropping work. The response body is where that disconnect shows up.
Content-Type on failures. A text/html response is worth a second look. It can be a framework error page, meaning an exception escaped before your handler ran, but most frameworks also return text/html for a bare string, so it may just be your own error path.
Signature rejections. If you're returning 401 from verification, you'll see it here immediately.
After a fix, press r and compare the new duration against the old one.
Step 9: Test How Your Integration Fails
Handlers fail, so make yours fail on purpose and watch the CLI row, the details view, and your worker log. These return JSON so the detail stays readable in the details view, per Step 8.
app.post('/webhooks/cronofy', express.json(), (req, res) => {
res.status(500).json({ error: 'Queue unavailable' });
});
@app.route('/webhooks/cronofy', methods=['POST'])
def cronofy_webhook():
return jsonify(error='Queue unavailable'), 500
func cronofyWebhook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"Queue unavailable"}`))
}
post '/webhooks/cronofy' do
status 500
content_type :json
body({ error: 'Queue unavailable' }.to_json)
end
Route::post('/webhooks/cronofy', function (Request $request) {
return response()->json(['error' => 'Queue unavailable'], 500);
});
server.createContext("/webhooks/cronofy", exchange -> {
byte[] body = "{\"error\":\"Queue unavailable\"}".getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(500, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
Now test a handler that takes longer than five seconds. Add a sleep(6) and watch what happens.
What you'll see is a green [200] with a duration around 6010ms, not a timeout. Cronofy's five-second budget applies between Cronofy and your source, and the source accepted the webhook and answered long before your handler finished. The CLI is reporting the leg from the source to your machine, where no such deadline applies.
So the duration is the only signal a slow handler gives you here, and nothing goes red to prompt you. That's the same store-and-forward property that keeps your channel alive when your laptop sleeps, but it does mean local testing cannot tell you whether you would have blown the budget in production, where Cronofy talks to your endpoint directly. Watch the number rather than waiting for a failure.
Then test idempotency. Every delivery carries an Idempotency-Key header holding the event ID, added by the relay rather than by Cronofy. It stays the same across retries of one event and differs between events, and that distinction decides how you test.
Pressing r five times retries a single event, so a handler keyed on that header collapses all five and the test passes whether or not your sync is safe. Replay a bookmarked change five times instead, as Step 7 describes: each arrives as a distinct event with its own key, which is what Cronofy's own retries look like. changes_since is identical across all five, so your worker fetches the same window each time. If that leaves five copies of anything, fix it now. The header dedupes delivery attempts, not repeated work. The durable protection is in the sync itself: upsert by event_uid, as the worker in Step 4 does. Then processing the same window twice costs a wasted API call rather than duplicate rows.
Keep the channel from closing
Cronofy retries a failed delivery for up to 24 hours. If no delivery succeeds in that window, it treats the channel as no longer valid, closes it, and stops sending. Creating a new channel is the way back.
Pointing the channel at a Hookdeck source is what protects you here. The source accepts and stores each webhook and returns 2xx whether or not your machine is awake, so Cronofy sees a successful delivery and the retry window never opens.
That protects the channel. It does not hold your events. Anything arriving while no CLI is attached is recorded as ignored with the cause CLI_DISCONNECTED and discarded: not queued, and not delivered when you start listening again. The request itself is still stored, so you can replay it from the dashboard, but that's a manual step rather than something that happens on reconnect. Before a planned stop, pause the connection instead, since paused events are held and delivered when you unpause. Quitting with q or Ctrl+C counts as a clean shutdown, so it forfeits even the short grace window an abnormal disconnect would get.
Storing every request is also what makes the retry loop in Step 5 work.
The channel-closing failure mode still matters, because it applies the moment a callback_url points at something that can go offline: a tunnel straight to your laptop, or a staging endpoint that gets torn down. Take that tunnel down on Friday, let a calendar change land over the weekend, and the 24-hour window can elapse before you're back at your desk, with the channel closed by the time it does.
If webhooks go quiet, check the channel rather than your handler. List Notification Channels is authoritative: a channel that has been closed won't be in the list.
Step 10: Debug Beyond the Event List
Correlate the CLI with your worker logs. Because the handler acknowledges and queues, the interesting work happens after the CLI has already shown a 200. Run the CLI in one pane and your queue worker in another. The channel_id is in every payload and makes a reasonable correlation key.
Watch the Read Events window. Log the changes_since you received alongside the number of events Read Events returned for it. Replay the same webhook after modifying the connected calendar and watch the count change. It's a direct way to understand what the last_modified window captures.
Filter noisy types during development. If you only care about change while building the sync path, add --filter-body to the listen command you have been using:
hookdeck listen 3000 <your-source-name> --path /webhooks/cronofy \
--filter-body '{"notification": {"type": "change"}}'
Substitute the source you have actually been listening to, and add --cli-key if you are on the Console route from Step 2. Filtering the wrong source produces the silent failure described there: the CLI connects, and nothing ever arrives.
Stop the running listen before starting the filtered one. Two sessions on the same source split events between them, which looks like the filter dropping things it should have passed.
Compare channel filters side by side. Create Notification Channel accepts filters.calendar_ids and filters.only_managed, which are set at creation time. To try a different filter, create a second channel pointing at a second source and compare the two rather than replacing your original.
Replay through the API. For automated regression runs, replay a stored request from CI:
curl https://api.hookdeck.com/2025-07-01/requests/{request_id}/replay \
-X POST \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
Use replay rather than retry here. Retry only accepts a request that was rejected or that produced ignored events, so a webhook your handler already accepted comes back 400. Replay re-ingests it as a brand-new request with no eligibility check.
Troubleshooting
Issue 1: 401 Unauthorized When Creating a Channel
Symptoms: POST /v1/channels returns 401 with an empty body.
Cause: Three things produce the same response, so work through them in order.
Solution:
- The token isn't an account access token. It has to come from authorizing a calendar against your application, as covered in the prerequisites. A token that isn't tied to your application won't work. This is the most common cause.
- The host doesn't match your data center. A token issued in the US data center isn't valid against
api-uk.cronofy.com, or vice versa. - The token expired. Access tokens last an hour. Use your refresh token to get a new one.
To tell the first apart from the other two, call UserInfo with the same token and host:
curl -i https://api.cronofy.com/v1/userinfo \
-H "Authorization: Bearer {YOUR_TOKEN}"
A 200 means the token and host are fine, so it's the token type. A 401 means the host is wrong or the token has expired.
Issue 2: Channel Is Active but No Webhooks Arrive
Symptoms: The channel was created and appears in the dashboard, but nothing reaches your endpoint, not even the verification webhook.
Cause: Cronofy verifies the TLS certificate on your callback URL before sending, and a certificate it can't validate stops delivery before your code is reached.
Solution: Cronofy requires a grade of "A" on the SSL Labs test for your callback URL. Run your callback URL through that test before you start debugging your handler. This is the usual reason a self-signed certificate or a hand-rolled tunnel never receives anything.
Issue 3: Webhooks Stopped and Won't Resume
Symptoms: An integration that worked has gone quiet, and redeploying doesn't help.
Cause: The channel was closed after 24 hours of failed deliveries.
Solution: Call List Notification Channels. If your channel isn't listed, create a new one, then see Step 9 for keeping the next one healthy.
Issue 4: Signature Verification Fails on Every Webhook
Symptoms: The Cronofy-HMAC-SHA256 header is present, your secret is correct, and verification never passes.
Cause: Usually the body. Frameworks that parse JSON before your handler runs hand you a re-serialized version whose bytes differ from what Cronofy signed.
Solution: Capture the raw bytes before parsing, as shown in Step 6. Then check the multi-value case: comparing the entire header against one computed digest breaks as soon as a second client secret is active. Run Cronofy's published vectors through your implementation, including the two-secret one.
Issue 5: A Calendar Change Produced No Webhook
Symptoms: You create or update an event through the Cronofy API to generate a test webhook, and nothing arrives.
Cause: Cronofy doesn't notify you about changes your own application made, so that you have a clear signal about which changes need action.
Solution: Use the dashboard trigger buttons or the Hookdeck Console (Step 2), or make the change directly in the underlying calendar.
Frequently Asked Questions
What does a Cronofy change webhook contain?
A type and a changes_since timestamp. It doesn't say what changed or which calendar changed. Your application passes changes_since to Read Events as the last_modified parameter and reconciles the result against its own state, which means every change webhook implies an outbound API call.
How do I trigger Cronofy webhooks for testing?
Two ways. In the Cronofy developer dashboard, open Channels, select an Active channel, and use the buttons under Trigger Push Notifications, which cover five of the six types. Or open a Hookdeck Console test URL with ?provider=cronofy and send example Cronofy webhooks without setting up an application at all.
How do I test my handler without triggering new webhooks?
Press r on a captured event in the Hookdeck CLI. It redelivers the request to your local server with the same body and the same headers Cronofy sent, though the X-Hookdeck-Attempt-* headers change, since it is a new attempt. Change your code and press r again. You only need to trigger the webhook once.
How do I verify the Cronofy-HMAC-SHA256 header?
Compute a base64-encoded HMAC-SHA256 of the raw request body using your client secret, then check whether your value appears in the comma-separated list in the header. The list matters: during secret rotation Cronofy sends one HMAC per active secret. Use a constant-time comparison and hash the raw bytes rather than a re-serialized body.
Can I rely on the signature to prevent replay?
No. Only the body is signed, with no timestamp or nonce, so identical bodies produce identical signatures. The signature authenticates the sender. Handle replay with idempotency in your handler.
Why did my notification channel stop working?
Cronofy retries a failed delivery for 24 hours and closes the channel if nothing succeeds. A local tunnel that goes down over a weekend is enough. Check with List Notification Channels and create a new channel if it's gone.
Best Practices for Local Development
Queue the work, don't do it inline. Enqueue and return as soon as the webhook is valid. Five seconds sounds generous until a Read Events call is inside it.
Make the handler idempotent. Replays are both a development tool and a production reality, and the same changes_since will be processed more than once.
Point the channel at a source, never straight at a tunnel. Cronofy always sees a 2xx, so the channel survives a laptop that sleeps. Pause the connection before a planned stop, though, because a source protects the channel and not the events (Step 9).
Verify against the published vectors in CI. Both of them, including the two-secret case. It's the cheapest guard against a rotation outage.
Handle unknown types by ignoring them. Cronofy asks for this directly, and gdpr_requested is the type you can't rehearse from the dashboard.
Trigger from the dashboard or Console, not the API. Changes your application makes through the API don't produce webhooks.
Watch delivery duration, not just status codes. Nothing local enforces the five-second budget, so a handler drifting toward it stays green until production disagrees.
Label bookmarks by notification type. There are only six, so a complete set is minutes of work.
Next Steps
Implement the recommended sync flow. Cronofy's recommended sync guide sets out the date window they suggest keeping in sync, and a periodic full sync to catch anything the incremental path missed.
Add channel lifecycle monitoring. Poll List Notification Channels, or track time since the last webhook per channel, so a closed channel surfaces as an alert rather than a support ticket.
Decide what happens when your handler is down. Nothing in the local loop tells you what a failed production delivery costs you, because the source absorbs it. Configure retry policies and issue tracking so a failure is visible and recoverable rather than silent.
Filter at the source. If you only care about one calendar, filters.calendar_ids at channel creation reduces both webhook volume and the Read Events calls they imply.
Explore the other notification families. Beyond calendar channels, Cronofy offers Event Triggers with event_start and event_end transitions, Smart Invites callbacks, and conferencing subscription notifications, each with its own payload shape.