Calendly Integration
Receive and route Calendly webhooks for bookings, cancellations, no-shows and routing form submissions.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Calendly Production",
"slug": "calendly",
"provider": "calendly"
}'Leave signingSecret out and Hookbase generates the webhook signing key for you. The create
response is the one place the full value is returned, so copy signingSecret from it now (you can
reveal it again later from the source in the dashboard). You send this same value to Calendly in the
next step.
Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/calendly2. Create the Calendly Webhook Subscription
Calendly webhook subscriptions are created through its API.
POST https://api.calendly.com/webhook_subscriptions- Set
urlto your Hookbase ingest URL - Set
scopetoorganizationoruser - List the events you want in the
eventsarray — for exampleinvitee.created,invitee.canceled,routing_form_submission.created - Set
signing_keyin the request body to the webhook signing key Hookbase generated (the source'ssigningSecret)
curl -X POST https://api.calendly.com/webhook_subscriptions \
-H "Authorization: Bearer your-calendly-personal-access-token" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.hookbase.app/ingest/{orgSlug}/calendly",
"events": ["invitee.created", "invitee.canceled"],
"organization": "https://api.calendly.com/organizations/AAAAAAAAAAAAAAAA",
"scope": "organization",
"signing_key": "the-signingSecret-hookbase-generated"
}'Creating a subscription requires a Calendly Standard, Teams or Enterprise plan and either a personal access token or an OAuth token. See Calendly's webhook documentation for the request shape.
Info
signing_key is a request field you choose, not something Calendly returns. The create response
does not include it, so the value Hookbase generated is the one to keep.
OAuth apps work the other way round. Calendly generates one webhook signing key for each OAuth
application and signs every webhook for that app with it. If you create subscriptions with an OAuth
token, paste your app's webhook signing key into the source's signingSecret (replacing the generated
one) instead. If you have lost it, Calendly recovers it through support+developer@calendly.com.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "CRM Sync", "slug": "calendly-crm", "url": "https://api.myapp.com/webhooks/calendly"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Calendly to CRM", "sourceId": "src_...", "destinationId": "dst_..."}'Signature Verification
Calendly signs a timestamp and the body together, not the body alone. The
Calendly-Webhook-Signature header carries both parts as comma-separated key=value pairs:
Calendly-Webhook-Signature: t=1700000000,v1=6f3d2c1b0a9988776655443322110ffeeddccbbaa99887766554433221100ffev1 is the hex HMAC-SHA256 of the string {t}.{body} — the timestamp, a literal dot, then the raw
request body — keyed with your webhook signing key.
Hookbase verifies this automatically once the source has both fields set:
{
"provider": "calendly",
"signingSecret": "your-calendly-webhook-signing-key"
}Info
The timestamp is checked, not just signed. Hookbase rejects a Calendly signature whose t is more
than 180 seconds away from the receiving clock — the three-minute tolerance Calendly's own
documentation names. A correctly signed request that arrives late, or is replayed later, does not
verify.
Once events are arriving with signature_valid: true, set rejectInvalidSignatures: true on the
source to have unverified requests refused with 401 instead of stored.
Common Events
| Event | Description |
|---|---|
invitee.created | A new invitee scheduled an event |
invitee.canceled | An invitee or host canceled a scheduled event |
invitee_no_show.created | A host marked an invitee as a no-show |
invitee_no_show.deleted | A no-show marking was removed from an invitee |
routing_form_submission.created | Someone submitted a routing form, whether or not they went on to book |
Invitee Created
{
"created_at": "2024-01-15T10:30:00.000000Z",
"created_by": "https://api.calendly.com/users/AAAAAAAAAAAAAAAA",
"event": "invitee.created",
"payload": {
"cancel_url": "https://calendly.com/cancellations/BBBBBBBBBBBBBBBB",
"created_at": "2024-01-15T10:30:00.000000Z",
"email": "jane@example.com",
"event": "https://api.calendly.com/scheduled_events/CCCCCCCCCCCCCCCC",
"name": "Jane Doe",
"new_invitee": null,
"old_invitee": null,
"questions_and_answers": [
{
"question": "What would you like to discuss?",
"answer": "Onboarding walkthrough",
"position": 0
}
],
"reschedule_url": "https://calendly.com/reschedulings/BBBBBBBBBBBBBBBB",
"rescheduled": false,
"status": "active",
"text_reminder_number": null,
"timezone": "America/New_York",
"tracking": {
"utm_source": null,
"utm_campaign": null,
"salesforce_uuid": null
},
"updated_at": "2024-01-15T10:30:00.000000Z",
"uri": "https://api.calendly.com/scheduled_events/CCCCCCCCCCCCCCCC/invitees/BBBBBBBBBBBBBBBB"
}
}Invitee Canceled
{
"created_at": "2024-01-16T09:00:00.000000Z",
"created_by": "https://api.calendly.com/users/AAAAAAAAAAAAAAAA",
"event": "invitee.canceled",
"payload": {
"cancel_url": "https://calendly.com/cancellations/BBBBBBBBBBBBBBBB",
"cancellation": {
"canceled_by": "Jane Doe",
"canceler_type": "invitee",
"reason": "Schedule conflict"
},
"created_at": "2024-01-15T10:30:00.000000Z",
"email": "jane@example.com",
"event": "https://api.calendly.com/scheduled_events/CCCCCCCCCCCCCCCC",
"name": "Jane Doe",
"reschedule_url": "https://calendly.com/reschedulings/BBBBBBBBBBBBBBBB",
"status": "canceled",
"timezone": "America/New_York",
"updated_at": "2024-01-16T09:00:00.000000Z",
"uri": "https://api.calendly.com/scheduled_events/CCCCCCCCCCCCCCCC/invitees/BBBBBBBBBBBBBBBB"
}
}Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
CRM Contact Shape
function transform(payload) {
const p = payload.payload;
return {
event: payload.event,
email: p.email,
name: p.name,
timezone: p.timezone,
status: p.status,
scheduled_event_url: p.event,
invitee_uri: p.uri,
answers: (p.questions_and_answers || []).map(qa => ({
question: qa.question,
answer: qa.answer
})),
utm_source: p.tracking ? p.tracking.utm_source : null,
occurred_at: payload.created_at
};
}Slack Alert on Cancellation
function transform(payload) {
const p = payload.payload;
const c = p.cancellation || {};
return {
text: `Booking canceled: ${p.name}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Invitee:*\n${p.name} (${p.email})` },
{ type: "mrkdwn", text: `*Canceled by:*\n${c.canceled_by || "unknown"}` },
{ type: "mrkdwn", text: `*Reason:*\n${c.reason || "none given"}` }
]
}
]
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
New Bookings Only
{
"name": "New Bookings",
"logic": "AND",
"conditions": [
{
"field": "event",
"operator": "equals",
"value": "invitee.created"
}
]
}Bookings or Cancellations, Ignoring Routing Forms
{
"name": "Booking Lifecycle",
"logic": "OR",
"conditions": [
{
"field": "event",
"operator": "equals",
"value": "invitee.created"
},
{
"field": "event",
"operator": "equals",
"value": "invitee.canceled"
}
]
}Only Invitees From a Campaign
{
"name": "Campaign Bookings",
"logic": "AND",
"conditions": [
{
"field": "payload.tracking.utm_campaign",
"operator": "exists",
"value": ""
}
]
}Headers
| Header | Description |
|---|---|
Calendly-Webhook-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "{t}.{body}"> |
Hookbase does not forward these headers to your destination. A delivery is a fresh request:
Hookbase sets Content-Type, User-Agent: Hookbase/1.0, X-Delivery-ID and X-Event-ID, then
adds the headers and auth you configured on the destination. Nothing Calendly sent reaches your
handler as a header — read what you need out of the body, or set it on the destination yourself.
Of the incoming headers, only content-type, user-agent, x-github-event, x-gitlab-event and
stripe-signature are stored on the event, so those are the only ones visible later in the
dashboard or the API.
Troubleshooting
Signature Verification Failed
- Confirm the source's
provideriscalendly— a source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256, and Calendly sends its own header - Confirm
signingSecretis the same value you sent assigning_keywhen creating the subscription (or, for an OAuth app, the app's webhook signing key), not a personal access token - Check the clock: the signed timestamp must be within 180 seconds. A delivery that sat in a retry queue for longer will not verify even though it is genuine
- Confirm you are not replaying a captured request — outside the tolerance, a real signature is still refused, which is the point of the timestamp
Missing Events
- Check the subscription's
eventsarray actually lists the event you expect - Check the subscription
scope— auser-scoped subscription sees only that user's bookings - Calendly subscriptions are per-organization or per-user; a second team needs its own
Duplicate Events
Hookbase has no provider event-id extractor for Calendly, so the default auto dedup strategy falls
back to hashing the payload. Calendly sends no per-delivery id. payload.uri identifies the invitee,
and the same URI recurs across that invitee's created and canceled events. If your handler needs an
idempotency key, combine event with payload.uri.