Every webhook request has the same outer envelope, with the changed booking or enrollment inside data. The examples below show a realistic payload for each event type. Identifiers are 16-character strings and all times are UTC in ISO 8601 format.
The envelope
{
"id": "evt_8HgT2kQm9vLxRp4c",
"type": "booking.created",
"created": "2026-08-29T09:12:04.512Z",
"data": { }
}- id
- Unique per event, prefixed
evt_. Use it to make your handler idempotent: the same event can arrive more than once. - type
- One of the seven event names, or
pingfor a test. - created
- When Booklink generated the event. Use it to discard payloads that arrive out of order after a retry.
- data
- The full booking for
booking.*events, the full enrollment forenrollment.*events, andnullfor a ping.
Empty fields are omitted
Optional fields are left out of the JSON when they have no value, so do not assume every key below is always present. Always read fields defensively.Referral attribution
Every booking.* and enrollment.* payload can carry an attribution object. It holds the ref, utm_source, utm_medium, utm_campaign, utm_term and utm_content query parameters that were on your booking link when the customer landed on it, so a link such as https://app.booklink.co.za/to/your-business?ref=partner-a&utm_source=newsletter lets you credit the booking to that partner or campaign in your own tools. Only those six keys are kept, each value is trimmed to 200 characters, and the object is set once at creation and never changes afterwards. It is omitted entirely when the link carried none of them.
booking.created
Sent as soon as a booking exists. On a service that requires payment, the booking arrives pending and unpaid. A booking.confirmed event follows later once payment lands or you confirm it manually.
{
"id": "evt_8HgT2kQm9vLxRp4c",
"type": "booking.created",
"created": "2026-08-29T09:12:04.512Z",
"data": {
"id": "9mKr4Tq7XbNc2VdW",
"client_id": "5pQz8LmT3nRk9WxB",
"service_id": "2VnJ7kQx4LmT8pRc",
"team_member_id": "7XcT2mQk9nLp4VrB",
"room_id": "3JbN8vQt5mXd2LcR",
"customer_name": "Naledi Mokoena",
"customer_email": "[email protected]",
"customer_phone": "+27821234567",
"notes": "First visit. Allergic to almond oil.",
"start_time": "2026-09-03T09:00:00Z",
"end_time": "2026-09-03T10:00:00Z",
"status": "pending",
"payment_status": "unpaid",
"reschedule_count": 0,
"variant_id": "6TbK9mQx2nLp7VrD",
"price_cents": 45000,
"payment_expiry_minutes": 30,
"conflict_status": "none",
"custom_answers": [
{
"field_id": "4KcR7pQm2nTx9VbL",
"label": "How did you hear about us?",
"type": "text",
"value": "Instagram"
}
],
"attribution": {
"ref": "partner-a",
"utm_source": "newsletter",
"utm_campaign": "spring"
},
"items": [
{
"service_id": "2VnJ7kQx4LmT8pRc",
"variant_id": "6TbK9mQx2nLp7VrD",
"service_name": "Deep Tissue Massage",
"variant_name": "60 minutes",
"duration_minutes": 60,
"price_cents": 45000,
"payment_required": true,
"deposit_cents": 15000
}
],
"policy_snapshot": {
"auto_confirm": false,
"payment_required": true,
"deposit_cents": 15000,
"payment_expiry_minutes": 30,
"allow_cancel": true,
"cancel_deadline_hours": 24,
"allow_reschedule": true,
"reschedule_deadline_hours": 24,
"max_reschedules": 2,
"booking_cutoff_minutes": 120,
"require_phone": true
},
"created_at": "2026-08-29T09:12:04.487Z"
}
}A few fields are worth calling out. items is always present and lists every service on the booking, so a multi-service booking has more than one entry. policy_snapshot is the booking policy frozen at the time of booking, which is what your cancellation and reschedule logic should honour. All money is in cents, so 45000 means R450.00.
booking.confirmed
Sent when the booking becomes confirmed. On an auto-confirm service this arrives immediately after booking.created. After an online payment it arrives once the gateway confirms, with the payment fields filled in.
{
"id": "evt_2QxL9mTk7RpN4VcB",
"type": "booking.confirmed",
"created": "2026-08-29T09:14:51.203Z",
"data": {
"id": "9mKr4Tq7XbNc2VdW",
"client_id": "5pQz8LmT3nRk9WxB",
"service_id": "2VnJ7kQx4LmT8pRc",
"team_member_id": "7XcT2mQk9nLp4VrB",
"customer_name": "Naledi Mokoena",
"customer_email": "[email protected]",
"customer_phone": "+27821234567",
"start_time": "2026-09-03T09:00:00Z",
"end_time": "2026-09-03T10:00:00Z",
"status": "confirmed",
"payment_status": "paid",
"meet_link": "https://meet.google.com/abc-defg-hij",
"transaction_id": "8LpQ2mXt4RvN7KcB",
"amount_paid_cents": 45000,
"payment_method": "yoco",
"reschedule_count": 0,
"variant_id": "6TbK9mQx2nLp7VrD",
"price_cents": 45000,
"conflict_status": "none",
"items": [
{
"service_id": "2VnJ7kQx4LmT8pRc",
"variant_id": "6TbK9mQx2nLp7VrD",
"service_name": "Deep Tissue Massage",
"variant_name": "60 minutes",
"duration_minutes": 60,
"price_cents": 45000,
"payment_required": true,
"deposit_cents": 15000
}
],
"policy_snapshot": {
"auto_confirm": false,
"payment_required": true,
"deposit_cents": 15000,
"payment_expiry_minutes": 30,
"allow_cancel": true,
"cancel_deadline_hours": 24,
"allow_reschedule": true,
"reschedule_deadline_hours": 24,
"max_reschedules": 2,
"booking_cutoff_minutes": 120,
"require_phone": true
},
"created_at": "2026-08-29T09:12:04.487Z"
}
}booking.rescheduled
Sent when a booking moves. The payload carries the new times only, so store the previous times on your side if you need a before and after. reschedule_count tells you how many times the booking has moved.
{
"id": "evt_5RpT8mQx2LcN9VbK",
"type": "booking.rescheduled",
"created": "2026-08-30T11:02:17.884Z",
"data": {
"id": "9mKr4Tq7XbNc2VdW",
"client_id": "5pQz8LmT3nRk9WxB",
"service_id": "2VnJ7kQx4LmT8pRc",
"team_member_id": "7XcT2mQk9nLp4VrB",
"customer_name": "Naledi Mokoena",
"customer_email": "[email protected]",
"customer_phone": "+27821234567",
"start_time": "2026-09-05T14:00:00Z",
"end_time": "2026-09-05T15:00:00Z",
"status": "confirmed",
"payment_status": "paid",
"external_event_id": "6f2n8kqm4tvc9xbr",
"transaction_id": "8LpQ2mXt4RvN7KcB",
"amount_paid_cents": 45000,
"payment_method": "yoco",
"reschedule_count": 1,
"calendar_sequence": 1,
"variant_id": "6TbK9mQx2nLp7VrD",
"price_cents": 45000,
"conflict_status": "none",
"items": [
{
"service_id": "2VnJ7kQx4LmT8pRc",
"variant_id": "6TbK9mQx2nLp7VrD",
"service_name": "Deep Tissue Massage",
"variant_name": "60 minutes",
"duration_minutes": 60,
"price_cents": 45000,
"payment_required": true,
"deposit_cents": 15000
}
],
"policy_snapshot": {
"auto_confirm": false,
"payment_required": true,
"deposit_cents": 15000,
"payment_expiry_minutes": 30,
"allow_cancel": true,
"cancel_deadline_hours": 24,
"allow_reschedule": true,
"reschedule_deadline_hours": 24,
"max_reschedules": 2,
"booking_cutoff_minutes": 120,
"require_phone": true
},
"created_at": "2026-08-29T09:12:04.487Z"
}
}booking.cancelled
Sent whenever a booking is cancelled. cancelled_by tells you who did it: operator for someone in your team, client for a self-service cancellation from the manage link, and system for automatic cancellations such as an unpaid booking expiring or a session being cancelled.
{
"id": "evt_7VcN4mQt9LpR2KxB",
"type": "booking.cancelled",
"created": "2026-09-01T06:45:09.331Z",
"data": {
"id": "9mKr4Tq7XbNc2VdW",
"client_id": "5pQz8LmT3nRk9WxB",
"service_id": "2VnJ7kQx4LmT8pRc",
"team_member_id": "7XcT2mQk9nLp4VrB",
"customer_name": "Naledi Mokoena",
"customer_email": "[email protected]",
"customer_phone": "+27821234567",
"start_time": "2026-09-05T14:00:00Z",
"end_time": "2026-09-05T15:00:00Z",
"status": "cancelled",
"payment_status": "paid",
"transaction_id": "8LpQ2mXt4RvN7KcB",
"amount_paid_cents": 45000,
"payment_method": "yoco",
"reschedule_count": 1,
"variant_id": "6TbK9mQx2nLp7VrD",
"price_cents": 45000,
"conflict_status": "none",
"cancellation_reason": "Client is unwell.",
"cancelled_by": "client",
"cancelled_time": "2026-09-01T06:45:09.298Z",
"items": [
{
"service_id": "2VnJ7kQx4LmT8pRc",
"variant_id": "6TbK9mQx2nLp7VrD",
"service_name": "Deep Tissue Massage",
"variant_name": "60 minutes",
"duration_minutes": 60,
"price_cents": 45000,
"payment_required": true,
"deposit_cents": 15000
}
],
"policy_snapshot": {
"auto_confirm": false,
"payment_required": true,
"deposit_cents": 15000,
"payment_expiry_minutes": 30,
"allow_cancel": true,
"cancel_deadline_hours": 24,
"allow_reschedule": true,
"reschedule_deadline_hours": 24,
"max_reschedules": 2,
"booking_cutoff_minutes": 120,
"require_phone": true
},
"created_at": "2026-08-29T09:12:04.487Z"
}
}booking.completed
Sent after a confirmed booking’s end time passes. A background sweep looks for newly ended bookings, so expect this shortly after the end time rather than on the exact second. The booking status stays confirmed; the event itself is what tells you the appointment is done.
{
"id": "evt_3KxQ7mTv2NpL9RcB",
"type": "booking.completed",
"created": "2026-09-05T15:05:00.642Z",
"data": {
"id": "9mKr4Tq7XbNc2VdW",
"client_id": "5pQz8LmT3nRk9WxB",
"service_id": "2VnJ7kQx4LmT8pRc",
"team_member_id": "7XcT2mQk9nLp4VrB",
"customer_name": "Naledi Mokoena",
"customer_email": "[email protected]",
"customer_phone": "+27821234567",
"start_time": "2026-09-05T14:00:00Z",
"end_time": "2026-09-05T15:00:00Z",
"status": "confirmed",
"payment_status": "paid",
"transaction_id": "8LpQ2mXt4RvN7KcB",
"amount_paid_cents": 45000,
"payment_method": "yoco",
"reschedule_count": 1,
"variant_id": "6TbK9mQx2nLp7VrD",
"price_cents": 45000,
"conflict_status": "none",
"items": [
{
"service_id": "2VnJ7kQx4LmT8pRc",
"variant_id": "6TbK9mQx2nLp7VrD",
"service_name": "Deep Tissue Massage",
"variant_name": "60 minutes",
"duration_minutes": 60,
"price_cents": 45000,
"payment_required": true,
"deposit_cents": 15000
}
],
"policy_snapshot": {
"auto_confirm": false,
"payment_required": true,
"deposit_cents": 15000,
"payment_expiry_minutes": 30,
"allow_cancel": true,
"cancel_deadline_hours": 24,
"allow_reschedule": true,
"reschedule_deadline_hours": 24,
"max_reschedules": 2,
"booking_cutoff_minutes": 120,
"require_phone": true
},
"created_at": "2026-08-29T09:12:04.487Z"
}
}enrollment.created
Sent when a client is enrolled into a group or cohort, whether you added them or they signed up from the booking page. The data is the enrollment, not a booking, so the shape is different.
{
"id": "evt_6NpL2mQx8TvR4KcB",
"type": "enrollment.created",
"created": "2026-08-29T13:22:41.907Z",
"data": {
"id": "4TbN9mQx7LpR2VcK",
"cohort_id": "8VcR3mQt5LpN7KxB",
"client_id": "5pQz8LmT3nRk9WxB",
"customer_name": "Sipho Dlamini",
"customer_email": "[email protected]",
"customer_phone": "+27835557788",
"variant_id": "2LmQ9xTv4RpN8KcB",
"payment_model": "upfront",
"total_amount_cents": 240000,
"paid_amount_cents": 240000,
"refunded_amount_cents": 0,
"payment_status": "paid",
"transaction_id": "9KxT4mQp2LvN7RcB",
"checkout_id": "7RcB2mQx9LpT4VkN",
"accepted_terms_at": "2026-08-29T13:22:38.114Z",
"status": "active",
"created_at": "2026-08-29T13:22:41.882Z"
}
}payment_model is one of upfront, deposit, or per_session. status is active, cancelled, or completed.
enrollment.cancelled
Sent when an enrollment is cancelled. Booklink first sends a booking.cancelled event for each of the enrollment’s remaining session bookings, then this event. Handle both, or you will see session cancellations without knowing the enrollment behind them ended.
{
"id": "evt_9LcT4mQx7RpN2VbK",
"type": "enrollment.cancelled",
"created": "2026-09-02T08:15:33.441Z",
"data": {
"id": "4TbN9mQx7LpR2VcK",
"cohort_id": "8VcR3mQt5LpN7KxB",
"client_id": "5pQz8LmT3nRk9WxB",
"customer_name": "Sipho Dlamini",
"customer_email": "[email protected]",
"customer_phone": "+27835557788",
"variant_id": "2LmQ9xTv4RpN8KcB",
"payment_model": "upfront",
"total_amount_cents": 240000,
"paid_amount_cents": 240000,
"refunded_amount_cents": 120000,
"payment_status": "paid",
"transaction_id": "9KxT4mQp2LvN7RcB",
"accepted_terms_at": "2026-08-29T13:22:38.114Z",
"status": "cancelled",
"created_at": "2026-08-29T13:22:41.882Z"
}
}ping
Sent only when you click Test on an endpoint. It is signed exactly like a real event and carries no data.
{
"id": "evt_4Lm7QxT2kR9pVcNb",
"type": "ping",
"created": "2026-08-29T09:10:00.118Z",
"data": null
}Field values you can rely on
| Field | Possible values |
|---|---|
status (booking) | pending, confirmed, cancelled |
payment_status | unpaid, pending, partial, manual, paid, failed, package |
cancelled_by | operator, client, system |
conflict_status | none, flagged, resolved |
status (enrollment) | active, cancelled, completed |
payment_model | upfront, deposit, per_session |
New values may be added
Treat these lists as the current set, not a permanent one. Write your handler so an unrecognised status or event type is logged and ignored rather than throwing an error, otherwise a future addition will start failing your deliveries.