> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Calendly integration: booking events, SMS confirmations, and the HMAC receiver

> Dedicated walkthrough for the Calendly integration: the four webhook event types and their payload shapes, the OAuth connect flow that registers the subscription, signature verification, mapping invitee events to SMS confirmation and cancellation flows, idempotency, troubleshooting, and a production checklist.

# Calendly integration

Orbit's Calendly integration turns bookings into messaging. When an invitee books, cancels, or no-shows, Calendly sends the event to a webhook receiver Orbit registers on your account — and Orbit upserts the invitee as a contact, persists the event for audit, and fans a tenant webhook out to your booking flows: SMS confirmations, reschedule nudges, no-show recovery.

This page is the operator walkthrough for the whole loop. The composite runbook that covers Calendly alongside Zendesk, Jira, DocuSign, and the connectors lives at [Connect Zendesk, Jira, Calendly, DocuSign, Slack, Zapier, and n8n](/guides/integrations-zendesk-jira-calendly-docusign-slack-zapier-n8n) — this page is the deeper, single-provider version the booking-flow operator actually works from.

<Note>
  The connect step is **owner/admin only** — it's the only step that touches provider credentials. After OAuth, the subscription registration, signing-key storage, and idempotent event handling are automatic.
</Note>

## What you'll set up

1. An OAuth connection from Orbit to your Calendly account, with the API key Orbit uses to call Calendly stored on the tenant connection record.
2. A Calendly webhook subscription Orbit registers at connect-complete, pointing at `POST /api/v1/integrations/webhooks/calendly?org=<orgId>` with a per-tenant signing key.
3. The HMAC-verified receiver itself — contact upserts, event persistence, and the tenant webhook fan-out (`calendly.invitee.created` and siblings).
4. A worked mapping: `invitee.created` → SMS confirmation, `invitee.canceled` → SMS apology with a reschedule link.

***

## 1. What Calendly sends to Orbit

Calendly's v2 webhook envelope carries a top-level `event` discriminator plus a `payload` object. Orbit recognises four event types:

| Event | When it fires | What Orbit does on it |
| - | - | - |
| `invitee.created` | An invitee books a meeting | Upserts an Orbit contact, persists the event, fans `calendly.invitee.created` to your tenant webhooks |
| `invitee.canceled` | An invitee cancels a booked meeting | Upserts the contact (with cancellation attributes), persists the event, fans `calendly.invitee.canceled` |
| `invitee_no_show.created` | An invitee is marked as a no-show | Persisted against the same contact linkage and fanned out — the trigger for no-show recovery |
| `routing_form_submission.created` | A routing form is submitted | Persisted for audit and future campaign triggers (no structured contact join today) |

The receiver's discriminator is strict — an event type outside this catalog is persisted for audit but never dispatched to your webhook subscriptions, so the wire can only carry the four catalog verbs.

### Payload shape — `invitee.created` (the one you build on)

```json theme={null}
{
  "event": "invitee.created",
  "created_at": "2026-10-03T14:21:09.000Z",
  "created_by": "https://api.calendly.com/users/AAAA-BBBB",
  "payload": {
    "uri": "https://api.calendly.com/scheduled_events/UUID/invitees/UUID",
    "email": "buyer@example.com",
    "first_name": "Ada",
    "last_name": "Lovelace",
    "name": "Ada Lovelace",
    "text_reminder_number": "+14155550134",
    "event": "https://api.calendly.com/scheduled_events/UUID",
    "questions_and_answers": [
      { "question": "What are you trying to solve?", "answer": "Renewal pricing" }
    ]
  }
}
```

Field-by-field on the invitee payload:

* `payload.uri` — the invitee URI, Orbit's stable id for the booking. Used for dedupe and as the contact's Calendly linkage.
* `payload.email` — join key #1 onto an Orbit contact.
* `payload.text_reminder_number` — join key #2 (the phone the booker filled on Calendly's SMS-reminder field). E.164.
* `payload.first_name` / `payload.last_name` / `payload.name` — contact display fields.
* `payload.event` — the scheduled-event URI (the meeting itself); Orbit stores it on the contact's Calendly attributes.
* `payload.cancellation.reason` / `.canceler_type` — present on `invitee.canceled`; stamped onto the contact's attributes for no-show recovery gating.
* `payload.questions_and_answers` — routing-form / booking-form answers; carried in the audit row.

***

## 2. Connect — OAuth and the webhook subscription

**Step order.** In Orbit, go to **Settings → Integrations**, find Calendly, and click **Connect**. Calendly's consent screen opens; authorize the scopes. On the OAuth callback, Orbit's connect-complete step runs automatically — the operator never registers the subscription by hand.

Screen annotations, as text:

* **Settings → Integrations, Calendly card.** Button reads **Connect**. After OAuth completes the card flips to **Connected**.
* **Calendly consent screen** (provider-side). Lists the requested scopes (read user + organization, register webhooks). Confirm with your Calendly account.
* **Redirect back to Orbit.** The `integrations/calendly/connect-complete` hook fires; it fetches your Calendly user + organization URIs and registers a webhook subscription.

What connect-complete registers, exactly:

1. The webhook URL — `POST https://api.orbit.devotel.io/api/v1/integrations/webhooks/calendly?org=<orgId>`. The `?org=<orgId>` query string routes inbound deliveries to your tenant. Calendly preserves it on every delivery.
2. A per-tenant `signing_key`, minted at registration and stored on your connection record — inbound events verify against it.
3. The event set — the four verbs in the table above.

<Check>
  After clicking **Connect** and authorizing, open **Settings → Integrations** again — Calendly shows **Connected**. Then book a real test meeting on an event type your Calendly account owns with a test email; the invitee appears as an Orbit contact (provider: calendly) and a `calendly.invitee.created` tenant webhook fires on your configured outbound endpoint.
</Check>

***

## 3. Signature verification — the HMAC receiver

Every inbound Calendly event hits `POST /api/v1/integrations/webhooks/calendly?org=<orgId>`, mounted ahead of Orbit's usual auth because the HMAC **is** the auth. The receiver, in order:

1. **Tenant resolution.** Looks up the org from `?org=<orgId>` and reads the stored `calendly_signing_key` off that tenant's connection record.
2. **Signature parse.** `Calendly-Webhook-Signature` carries `t=<unix-seconds>,v1=<hex>` — the timestamp and the expected digest.
3. **Replay window.** `t` must sit within 3 minutes of now (Calendly's documented replay-protection threshold). A stale timestamp is rejected.
4. **HMAC.** Computes `HMAC-SHA256("<t>.<raw-body>", signing_key)` and compares it to `v1` with a constant-time compare.
5. **Fail closed.** Missing signing key → `503 WEBHOOK_NOT_PROVISIONED` (the subscription was never registered, e.g. connect-complete never ran). Bad signature, stale timestamp, or malformed header → `401 INVALID_SIGNATURE`. The two failure classes are deliberately different statuses so your monitoring can distinguish "we never wired the subscription" from "sender sent a bad sig."

The **worked HMAC**, exactly the way the receiver computes it — useful for reproducing a delivery in a sandbox:

```javascript theme={null}
import crypto from "node:crypto";

const signingKey = "<stored calendly signing key>";
const rawBody = JSON.stringify({ event: "invitee.created", payload: { uri: "https://api.calendly.com/scheduled_events/UUID/invitees/UUID" } /* … */ });
const ts = Math.floor(Date.now() / 1000);

const v1 = crypto
  .createHmac("sha256", signingKey)
  .update(`${ts}.${rawBody}`, "utf8")
  .digest("hex");

// header value the receiver parses:
// Calendly-Webhook-Signature: t=${ts},v1=${v1}
```

Note the hex digest — not base64, which is the common mistake when reproducing this against the Shopify sibling receiver.

***

## 4. Map events to Orbit flows — the booking-confirmation worked example

Two patterns that ship out of the tenant webhook fan-out. Both trigger off the contact row the invitee upsert created, so they address the person who actually booked rather than a one-off recipient.

**Pattern A: `invitee.created` → SMS booking confirmation.** Trigger a flow on the `calendly.invitee.created` event. The fan-out payload carries `invitee_email`, the scheduled-event URI, and the upserted `contact_id`. Read the meeting start time + location off the contact's Calendly attributes, then send the confirmation SMS to the phone the booker gave on Calendly's `text_reminder_number` field. End with a CDR log point so the confirmation send joins the message history. This is the bookings→SMS confirmation loop operators actually wire up — run it through the standard [flow recipes](/guides/flows-recipes) shape with the Calendly event as the trigger.

**Pattern B: `invitee.canceled` → SMS apology + reschedule link.** Trigger a second flow on `calendly.invitee.canceled`. The message body includes a reschedule link — typically a CTA back to your Calendly booking page. Because the cancellation payload's `canceler_type` sits on the contact's Calendly attributes, gate the apology: a canceler-type of `host` (your side canceled) should get a different copy than an invitee-initiated cancel. End with the survey/callback alternative if the cancellation reason says the meeting was no longer needed.

```json theme={null}
// Example triggercondition your flow uses
{ "trigger": { "type": "event", "event": "calendly.invitee.created" },
  "steps": [
    { "type": "send_sms", "to": "{{contact.phone}}",
      "body": "Confirmed for {{contact.attributes.calendly_last_scheduled_event_uri}} — see you then." },
    { "type": "log_cdr", "note": "calendly confirmation sent" }
  ] }
```

That flow definition is illustrative — the production flow form is in [Five flow recipes](/guides/flows-recipes) with the Calendly verbs substituted.

***

## 5. Idempotency and dedupe

Calendly retries events up to 24 hours after a 5xx, and re-sends after network blips. The receiver turns those retries into no-ops at three layers:

* **Event uniqueness.** Event rows persisted on `(event_type, event_uri)` with on-conflict-do-nothing. Retried deliveries of the same invitee URI insert no duplicate row.
* **Dedupe on dispatch.** Only a fresh (not duplicate) event fans out to your tenant webhooks, so retry bursts don't re-trigger the SMS confirmation.
* **Response `duplicate: true`.** On a retry the receiver still returns 200, with `duplicate: true` — Calendly halts its retry chain and your webhook subscriptions skip a second fire.

A missing `payload.uri` (vanishingly rare) is accepted with a synthetic non-dedupable id, so the row always lands for audit — the alternative would be dropping an event that was genuinely new.

***

## 6. Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `503 WEBHOOK_NOT_PROVISIONED` | Connect-complete never registered the subscription, or the signing key was never stored | Re-run **Connect** from Settings → Integrations — the registration step idempotently re-registers |
| `401 INVALID_SIGNATURE` on inbound | Stored signing key drifted from Calendly's current one (key rotation, re-connect from a different Calendly account) | Re-run **Connect** — reconnect re-registers and refreshes the stored key in place. If the spike is sustained but event volume is zero, treat it as scanner noise: check the `calendly_webhook.hmac_invalid` counter |
| `400 MISSING_ORG` | The `?org=<orgId>` query string is absent — usually a hand-rolled subscription pointing at a wrong URL | Re-register via Connect; never hand-edit Calendly's subscription URL |
| Calendly subscription shows **pending** in the Calendly admin | Calendly is re-validating after its own blip | Leave it an hour. If still pending, disconnect + reconnect in Orbit — the re-registration reconciles |
| Bookings arrive, no contact appears | Invitee carried no email and no `text_reminder_number` — no join key | The event still persists for audit; enable SMS-reminder capture on the Calendly booking form so the phone lands as join key #2 |
| Tenant webhook never fires on your endpoint | Your webhook subscription's `events[]` doesn't include the `calendly.*` verbs | Add the verbs under **Settings → Webhooks** |
| Second SMS confirmation on a Calendly retry | The `duplicate` flag was cleared before the flow ran, or the flow isn't event-triggered | Confirm the flow trigger is on the `calendly.invitee.*` event, not a raw inbound poll — the receiver's duplicate flag gates the fan-out upstream of any flow |

***

## 7. Production checklist

Run this before flipping traffic to a live Calendly account:

* [ ] **Connected as owner/admin** — the Calendly card on Settings → Integrations reads Connected.
* [ ] **Test event verified** — a real test booking on a Calendly event type you own produced a contact (provider: calendly) and the tenant webhook fired.
* [ ] **Signature path confirmed** — a forged POST with a wrong HMAC returns `401 INVALID_SIGNATURE`; an un-provisioned tenant's `?org=` returns `503 WEBHOOK_NOT_PROVISIONED`. Both are expected fail-closed behavior.
* [ ] **Webhook subscription includes the Calendly verbs** — Settings → Webhooks carries `calendly.invitee.created` and `calendly.invitee.canceled` at minimum.
* [ ] **Dedupe path observed** — re-deliver the same event (Calendly's resend control) and observe `duplicate: true` with no second SMS.
* [ ] **Cancellation flow gated on canceler-type** — a host-canceled booking doesn't send the same copy as an invitee-cancel.
* [ ] **Phone capture on the booking form** — Calendly's SMS-reminder field is the join key #2; if your booking form doesn't capture it, contact linkage falls back to email-only.

***

## Related reading

* [Integrations API reference](/api-reference/integrations) — endpoint map for connect, status, disconnect, and the Calendly webhook receiver.
* [Connect Zendesk, Jira, Calendly, DocuSign, Slack, Zapier, and n8n](/guides/integrations-zendesk-jira-calendly-docusign-slack-zapier-n8n) — the composite runbook that covers Calendly alongside the other six.
* [Five flow recipes](/guides/flows-recipes) — the flow-definition shapes the booking-confirmation pattern plugs into.
* [Webhook event catalog](/guides/webhook-event-catalog) — the full tenant webhook verb catalog, `calendly.*` included.
* [Inbound webhook debugging](/guides/inbound-webhook-debugging) — general route for tracing inbound receivers.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.