Build spec · Customer.io
How appointment reminders and cancellations flow from Calendly into Customer.io, with the real webhook payloads to build against. This is the system of record.
Every Calendly booking across the org creates a throwaway meeting profile in Customer.io. That profile owns its own branded emails (a confirmation, a 24 hour reminder, a 1 hour reminder, and a cancellation acknowledgement) and its own cancellation and reschedule state. It runs as a direct webhook with no middleware.
| Component | ID | What it is |
|---|---|---|
| Calendly webhook subscription | 52e14b78… | Fires invitee.created and invitee.canceled to the receiver |
| Receiver campaign | Campaign 69 | Webhook triggered. Builds the meeting profile and fires the normalised events |
| Reminder sequence | Campaign 71 | Triggered by calendly_call_booked, filtered to Segment 139 |
| Cancellation handler | Campaign 70 | Triggered by calendly_call_canceled, filtered to Segment 140 |
| Segment "Meeting — Scheduled" | 139 | Meeting profiles in scheduled state. Anchors Campaign 71 |
| Segment "Meeting — Hard Canceled" | 140 | Meeting profiles in canceled state. Anchors Campaign 70 |
On invitee.created, the receiver creates the meeting profile (its Customer.io id is the Calendly invitee URI) and fires calendly_call_booked. The profile enters Segment 139, so Campaign 71 runs on it:
new_invitee is present, this is the cancel half of a reschedule. The profile is marked rescheduled and nothing is sent. The new booking builds a fresh profile and a fresh sequence.calendly_call_canceled fires, and Campaign 70 sends the cancellation acknowledgement from the meeting host.On the new booking Calendly sends rescheduled: false, so the reliable signal is the old_invitee and new_invitee pointers, not the rescheduled flag.
These are real captured Calendly v2 webhook bodies. Personal values (names, emails, ids) have been replaced with dummy data. Every field name, nesting, value format (ISO 8601 timestamps in UTC with microseconds, URI shapes, UUIDs, and nulls) is exactly as Calendly sends it. Build against these.
Two structural notes worth catching early: the top level event is the discriminator, while payload.event is a URI to the scheduled event. The full scheduled event is also expanded inline at payload.scheduled_event, so the receiver never has to make a second call.
1 · invitee.created (a new booking)
{
"event": "invitee.created",
"payload": {
"cancel_url": "https://calendly.com/cancellations/c4e1d8a6-2b73-4f19-9a0c-6d8e2f4b1c33",
"created_at": "2026-06-02T14:22:27.922032Z",
"email": "[email protected]",
"event": "https://api.calendly.com/scheduled_events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22",
"first_name": null,
"invitee_scheduled_by": null,
"last_name": null,
"name": "Jordan Lee",
"new_invitee": null,
"no_show": null,
"old_invitee": null,
"payment": null,
"questions_and_answers": [],
"reconfirmation": null,
"reschedule_url": "https://calendly.com/reschedulings/c4e1d8a6-2b73-4f19-9a0c-6d8e2f4b1c33",
"rescheduled": false,
"routing_form_submission": null,
"scheduled_event": {
"calendar_event": {
"external_id": "m7k2p9q4r8s1t6u3v0w5x2y8za",
"kind": "google"
},
"created_at": "2026-06-02T14:22:27.898497Z",
"end_time": "2026-06-10T19:45:00.000000Z",
"event_guests": [],
"event_memberships": [
{
"buffered_end_time": "2026-06-10T20:00:00.000000Z",
"buffered_start_time": "2026-06-10T19:00:00.000000Z",
"user": "https://api.calendly.com/users/9c2e7b14-8a36-4d51-b2e9-5f1a0c8d3e88",
"user_email": "[email protected]",
"user_name": "Taylor Brooks"
}
],
"event_type": "https://api.calendly.com/event_types/f1d3b5a7-2c89-4e64-9a1b-7d0e3c5f2a99",
"invitees_counter": { "active": 1, "limit": 1, "total": 1 },
"location": {
"join_url": "https://calendly.com/events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22/google_meet",
"status": "pushed",
"type": "google_conference"
},
"meeting_notes_html": null,
"meeting_notes_plain": null,
"name": "Sales Demonstration",
"start_time": "2026-06-10T19:00:00.000000Z",
"status": "active",
"updated_at": "2026-06-02T14:22:30.226091Z",
"uri": "https://api.calendly.com/scheduled_events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22"
},
"scheduling_method": null,
"status": "active",
"text_reminder_number": null,
"timezone": "America/New_York",
"tracking": {
"salesforce_uuid": null,
"utm_campaign": null,
"utm_content": null,
"utm_medium": null,
"utm_source": null,
"utm_term": null
},
"updated_at": "2026-06-02T14:22:27.922032Z",
"uri": "https://api.calendly.com/scheduled_events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22/invitees/c4e1d8a6-2b73-4f19-9a0c-6d8e2f4b1c33"
}
}
2 · invitee.canceled (a hard cancel)
Same shape as the booking above, with a cancellation object added and the status fields flipped to canceled. rescheduled stays false.
{
"event": "invitee.canceled",
"payload": {
"cancel_url": "https://calendly.com/cancellations/c4e1d8a6-2b73-4f19-9a0c-6d8e2f4b1c33",
"cancellation": {
"canceled_by": "Taylor Brooks",
"canceler_type": "host",
"created_at": "2026-06-02T14:28:43.443288Z",
"reason": "Something came up\n"
},
"created_at": "2026-06-02T14:22:27.922032Z",
"email": "[email protected]",
"event": "https://api.calendly.com/scheduled_events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22",
"first_name": null,
"invitee_scheduled_by": null,
"last_name": null,
"name": "Jordan Lee",
"new_invitee": null,
"no_show": null,
"old_invitee": null,
"payment": null,
"questions_and_answers": [],
"reconfirmation": null,
"reschedule_url": "https://calendly.com/reschedulings/c4e1d8a6-2b73-4f19-9a0c-6d8e2f4b1c33",
"rescheduled": false,
"routing_form_submission": null,
"scheduled_event": {
"...": "identical to invitee.created above, with status now \"canceled\"",
"name": "Sales Demonstration",
"start_time": "2026-06-10T19:00:00.000000Z",
"status": "canceled",
"uri": "https://api.calendly.com/scheduled_events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22"
},
"scheduling_method": null,
"status": "canceled",
"text_reminder_number": null,
"timezone": "America/New_York",
"tracking": { "salesforce_uuid": null, "utm_campaign": null, "utm_content": null, "utm_medium": null, "utm_source": null, "utm_term": null },
"updated_at": "2026-06-02T14:28:43.446299Z",
"uri": "https://api.calendly.com/scheduled_events/7f3a9c20-1e84-4d6b-b9a2-3c5e0f1a7b22/invitees/c4e1d8a6-2b73-4f19-9a0c-6d8e2f4b1c33"
}
}
3 · invitee.canceled (the cancel half of a reschedule)
Same body as the hard cancel above, except for the fields below. The discriminator is rescheduled: true with new_invitee pointing at the replacement booking. The cancellation object is still present here, so do not use its presence to tell a reschedule apart from a hard cancel.
{
"event": "invitee.canceled",
"payload": {
"rescheduled": true,
"status": "canceled",
"new_invitee": "https://api.calendly.com/scheduled_events/e6f2a1b8-3c47-4d2a-9b85-0a7c1e3f5d66/invitees/4a8d2c91-6e15-4b73-8f0a-2c9d5e1b7a77",
"old_invitee": null,
"cancellation": {
"canceled_by": "Jordan Lee",
"canceler_type": "invitee",
"created_at": "2026-06-02T14:33:53.302916Z",
"reason": "Something came up\n"
},
"uri": "https://api.calendly.com/scheduled_events/2d9b4e71-5c3a-4a8e-b1f7-9e0c3a5d2b44/invitees/8b1c6f30-7d24-4e95-a3c2-1f6b8d0e4a55"
}
}
4 · invitee.created (the new half of a reschedule)
Same body as a normal booking, except for the fields below. Note rescheduled is false here. The signal is old_invitee being set. It cross references the cancel half above: this old_invitee equals the cancel half's uri, and the cancel half's new_invitee equals this uri.
{
"event": "invitee.created",
"payload": {
"rescheduled": false,
"status": "active",
"new_invitee": null,
"old_invitee": "https://api.calendly.com/scheduled_events/2d9b4e71-5c3a-4a8e-b1f7-9e0c3a5d2b44/invitees/8b1c6f30-7d24-4e95-a3c2-1f6b8d0e4a55",
"uri": "https://api.calendly.com/scheduled_events/e6f2a1b8-3c47-4d2a-9b85-0a7c1e3f5d66/invitees/4a8d2c91-6e15-4b73-8f0a-2c9d5e1b7a77"
}
}
The fields the receiver reads, with exact names and formats. The scheduler must populate these.
| Field path | Format and example | Notes |
|---|---|---|
event | String: invitee.created or invitee.canceled | Top level. The router branches on this. |
payload.uri | …/scheduled_events/{uuid}/invitees/{uuid} | Unique and stable per booking. Becomes the meeting profile id. The same value must return on that booking's cancel or reschedule. |
payload.email | [email protected] | Used to match the person who gets messaged. |
payload.name | Jordan Lee | One field. first_name and last_name are always null. |
payload.timezone | America/New_York | IANA timezone string. |
payload.text_reminder_number | E.164 string or null | Maps to the profile phone. |
payload.rescheduled | Boolean | True only on the cancel half of a reschedule. False on the new booking. |
payload.new_invitee / payload.old_invitee | Invitee URI or null | The real reschedule signal. new_invitee set on the cancel half, old_invitee set on the new half. |
payload.reschedule_url / payload.cancel_url | https://calendly.com/reschedulings/{uuid} | Used in email CTAs. |
payload.cancellation.reason | String, may end with \n | Also canceled_by and canceler_type (host or invitee). |
payload.scheduled_event.start_time / end_time | 2026-06-10T19:00:00.000000Z | ISO 8601, UTC, microsecond precision. Converted to unix for the reminder timers. |
payload.scheduled_event.name | Sales Demonstration | The meeting type name shown in emails. |
payload.scheduled_event.event_type | …/event_types/{uuid} | Stable id per meeting type. Use this if a demo filter is added later. |
…event_memberships[0].user_name / user_email | Taylor Brooks · [email protected] | The host. Emails send from this address. |
…location.join_url / type | https://calendly.com/events/{uuid}/google_meet | type example google_conference. location.location holds in person addresses. |
payload.questions_and_answers | Array, empty [] when none | When intake questions exist, each item is { question, answer, position }. |
payload.tracking.* | All null in the sample | utm_source/medium/campaign/content/term and salesforce_uuid. Carry a real Carepatron id here, or as a first class field. |
The receiver normalises every booking into two Customer.io events on the matching profiles.
meeting_type_name · meeting_start_time (unix) · meeting_host_name · meeting_id
meeting_type_name · meeting_id · cancellation_reason
Richer personalisation in the emails comes from the meeting profile attributes below, not from these event fields.
Set on the throwaway profile when the booking comes in. Its id is the Calendly invitee URI.
profile_type email name timezone phone
meeting_id meeting_start_time meeting_end_time meeting_type_name
meeting_host_name meeting_host_email meeting_join_url
meeting_location_type meeting_location meeting_reschedule_url
meeting_cancel_url meeting_status created_at
meeting_status moves through scheduled, then rescheduled, canceled, or completed.
1be3a96f… feeds an older Customer.io webhook (Campaign 18, February build, invitee.created only).To reuse this pipeline with no Customer.io or template changes, send the payload shapes above to the receiver webhook URL. Minimum the receiver depends on:
event of invitee.created or invitee.canceled.payload.uri, unique and stable, returned again on cancel or reschedule.payload.email, payload.name, payload.timezone, payload.text_reminder_number.payload.new_invitee on a reschedule cancel half, payload.old_invitee on the new half.payload.reschedule_url, payload.cancel_url, payload.cancellation.reason.payload.scheduled_event expanded inline with name, start_time, end_time, event_memberships[0].user_name and user_email, and location.join_url, type, location.
Carepatron
Verified live from the Customer.io API on 2 June 2026. Campaigns 69, 70, and 71. Segments 139 and 140. Payloads are real captured Calendly v2 webhook bodies with personal data replaced by dummy values, format preserved exactly.