Webhook events
Every event a Hapana webhook can subscribe to, with the fields each one carries and an example delivery body.
Event catalogue
| Event | Sent when |
|---|---|
member.created | A member is created at a location. |
member.updated | A member's profile is edited. |
member.suspended | A member is blocked at a location. Unblocking sends member.updated. |
member.deleted | A member is archived. |
membership.created | A membership or package is purchased or moved to. |
membership.activated | A membership becomes usable for the first time: sold and paid for today, or a pre-sale reaching its start date. Once per membership per location; never for a resume, restore, extension or credit reactivation. |
membership.updated | A membership's status or details change, other than its first activation. |
membership.cancelled | A membership is cancelled. |
membership.expired | A membership expires. |
membership.renewed | A membership renews. |
booking.created | A member is booked into a session. |
booking.cancelled | A booking is cancelled or marked late-cancelled. |
booking.completed | A booking is marked attended. |
booking.no_show | A booking is marked as a no-show. |
checkin.created | A member checks in successfully. |
door.access | A member scans at a door, whether admitted or refused. |
session.created | A session (class or appointment) is scheduled. |
session.updated | A session is edited. |
session.cancelled | A session is cancelled. |
session.instructor_changed | Who teaches a session changes, including substitutions and cover fills. |
staff.created | A staff member is added. |
staff.updated | A staff member's profile is edited or they are reactivated. |
staff.deactivated | A staff member is deactivated. |
checkIn | Legacy v1 check-in body, for partners migrating from Hapana v1. |
Every event except checkIn is wrapped in the standard event envelope (id, object, type, apiVersion, createdAt, livemode, data). The tables below describe data. All ids are Hapana ids — the same ids the Business API uses, so you can read the full record back. Timestamps are ISO 8601 in UTC.
The membership.* events, member.suspended and member.deleted are being enabled location by location; until a location has them, a periodic reconcile keeps you current. Their payloads are deliberately thin — ids rather than the full record — so re-read the resource an event names rather than treating the payload as its new state. A member re-scanning into a class they have already attended deliberately emits nothing.
data without notice, so ignore fields you don't recognise rather than rejecting the event.Member events
member.created
A new member was created at a location.
| Field | Type | Description |
|---|---|---|
clientId | string | The new member's id. |
siteId | string | The location the member joined. |
firstName | string | null | First name. |
lastName | string | null | Last name. |
email | string | null | Email address. |
{
"id": "evt_2b9c6f0e-41d7-4a55-8f0c-6a1e9b3d7c21",
"object": "event",
"type": "member.created",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T21:14:05.112Z",
"livemode": true,
"data": {
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com"
}
}member.updated
A member's profile was edited. The event names the fields that changed but not their values — read the member back through the API (under your own key's scopes) to get the new values.
| Field | Type | Description |
|---|---|---|
clientId | string | The member's id. |
siteId | string | The location the edit was made at. |
changedFields | string[] | Names of the profile fields that were submitted in the edit, sorted. |
{
"id": "evt_6d0e3b8a-9c5f-4b27-a1e4-0f8d2c6b9a13",
"object": "event",
"type": "member.updated",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T21:20:41.903Z",
"livemode": true,
"data": {
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"changedFields": [
"email",
"mobilePhoneNumber"
]
}
}Membership events
membership.activated
Sent once per membership per location the moment the membership becomes usable: a purchase paid for today, or a pre-sale reaching its start date (including a pre-sale that was migrated in). A door system can admit the member from this event alone. Memberships inserted by a bulk import or migration as already active send nothing. It is never sent for a resume from hold, a restore after cancellation, an extension or a credit reactivation — those arrive as membership.updated. Locations the member can use the membership at but does not belong to receive nothing. The event id is derived from the membership and location, so a redelivery carries the same id; treat a repeated id as already handled.
| Field | Type | Description |
|---|---|---|
clientId | string | The member's id. |
siteId | string | The location this delivery is for. |
serviceId | string | The membership id. |
serviceSiteId | string | The location that sold the membership. |
serviceTemplateId | string | The membership plan. |
parentServiceId | string | null | The parent membership when this is an add-on. |
barcode | string | null | The member's barcode at delivery time. |
activatedAt | string | When the membership became usable. |
trigger | string | purchase, payment_activation, scheduled_activation, renewal or movement. |
{
"id": "evt_1b4f0c3d9a2e4c7f8e6d5a4b3c2d1e0f",
"object": "event",
"type": "membership.activated",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-22T03:12:09.117Z",
"livemode": true,
"data": {
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"serviceId": "9c7b6a5d-4e3f-4a2b-8c1d-0e9f8a7b6c5d",
"serviceSiteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"serviceTemplateId": "3e2d1c0b-9a8f-4e7d-6c5b-4a3f2e1d0c9b",
"parentServiceId": null,
"barcode": "8675309",
"activatedAt": "2026-10-22T03:12:08.902Z",
"trigger": "purchase"
}
}Booking events
All booking events share the same core fields:
| Field | Type | Description |
|---|---|---|
bookingId | string | The booking id. |
clientId | string | The booked member. |
sessionId | string | The session booked into. |
siteId | string | The location of the session. |
status | string | The booking's new status: PENDING, CONFIRMED, CANCELLED, LATE_CANCELLED, ATTENDED, NO_SHOW, and so on. |
booking.created
A member was booked into a session. Adds:
| Field | Type | Description |
|---|---|---|
bookingSource | string | How the booking was paid for or made: CREDIT, FREE, DROP_IN, BILLING, ADMIN, AGGREGATOR, IMPORT, or GUEST_PASS. |
{
"id": "evt_7c1e1c52-3f0a-4a8e-9a43-2f5b1d0e6c11",
"object": "event",
"type": "booking.created",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T23:08:22.481Z",
"livemode": true,
"data": {
"bookingId": "b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60",
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"status": "CONFIRMED",
"bookingSource": "CREDIT"
}
}booking.cancelled
A booking was cancelled by the member or staff (status is CANCELLED or LATE_CANCELLED), or staff marked attendance as late-cancelled (status is LATE_CANCELLED). When the member or staff cancels, the event adds:
| Field | Type | Description |
|---|---|---|
lateCancel | boolean | Whether the cancellation fell inside the location's late-cancellation window. Absent when the event comes from marking attendance. |
{
"id": "evt_a4f2d9c1-7e3b-4c80-b5a6-1d9e0f2c3b47",
"object": "event",
"type": "booking.cancelled",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-03T06:42:10.027Z",
"livemode": true,
"data": {
"bookingId": "b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60",
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"status": "LATE_CANCELLED",
"lateCancel": true
}
}booking.completed
Staff marked the booking attended (status is ATTENDED). Use it to count visits.
{
"id": "evt_e8b7c6d5-4a3f-4e21-9d0c-8b7a6f5e4d32",
"object": "event",
"type": "booking.completed",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-03T08:05:51.660Z",
"livemode": true,
"data": {
"bookingId": "b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60",
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"status": "ATTENDED"
}
}booking.no_show
Staff marked the booking as a no-show (status is NO_SHOW). Same fields as booking.completed.
{
"id": "evt_f1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"object": "event",
"type": "booking.no_show",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-03T08:06:12.318Z",
"livemode": true,
"data": {
"bookingId": "b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60",
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"status": "NO_SHOW"
}
}If staff undo an attendance mark (back to CONFIRMED), no event is sent.
Check-in events
checkin.created
A member checked in successfully — from a barcode or fob scan, the kiosk, the app, contactless, or staff in Core. Refused check-ins are not currently sent as checkin.created; status and failReason are included so your handler is ready if they are added. A re-scan inside the location's re-entry grace window returns the earlier check-in and sends nothing.
| Field | Type | Description |
|---|---|---|
checkInId | string | The check-in id. |
clientId | string | The member who checked in. |
siteId | string | The location checked in to. |
type | string | GENERAL (gym floor / door), SESSION (a booked class), TANNING, or ZONE. |
status | string | SUCCESS, FAIL, or UNKNOWN (member not found). |
failReason | string | null | Why a check-in was refused, e.g. NO_VALID_MEMBERSHIP_OR_BOOKING, SUSPENDED, MEMBERSHIP_ON_HOLD, FAILED_PAYMENTS, NO_CREDITS. null on success. |
sessionId | string | null | The session the check-in was for, or null for a general check-in. |
{
"id": "evt_0c9b8a7f-6e5d-4c3b-a2f1-e0d9c8b7a6f5",
"object": "event",
"type": "checkin.created",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T23:08:22.517Z",
"livemode": true,
"data": {
"checkInId": "d2c1b0a9-f8e7-4d6c-b5a4-93f2e1d0c9b8",
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"type": "GENERAL",
"status": "SUCCESS",
"failReason": null,
"sessionId": null
}
}Session events
session.created, session.updated, and session.cancelled all carry the same fields — the session as it is after the change:
| Field | Type | Description |
|---|---|---|
sessionId | string | The session id. |
siteId | string | The location running the session. |
name | string | Session name. |
startTime | string | Start time, ISO 8601 UTC. |
endTime | string | End time, ISO 8601 UTC. |
status | string | SCHEDULED, IN_PROGRESS, COMPLETED, or CANCELLED. |
scheduleTemplateId | string | null | The schedule template the class was generated from (see GET /sessions/templates), or null for a class created on its own. |
session.created
A session was scheduled.
{
"id": "evt_3e4f5a6b-7c8d-4e9f-a0b1-c2d3e4f5a6b7",
"object": "event",
"type": "session.created",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-01T02:11:37.204Z",
"livemode": true,
"data": {
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"name": "Morning Yoga",
"startTime": "2026-10-03T10:00:00.000Z",
"endTime": "2026-10-03T11:00:00.000Z",
"status": "SCHEDULED",
"scheduleTemplateId": "5e550100-0001-4000-8000-000000000000"
}
}session.updated
A session was edited — time, name, capacity, instructors, and so on. The event carries the session's current fields; read it back through the API for anything else.
{
"id": "evt_8a9b0c1d-2e3f-4a5b-9c6d-7e8f9a0b1c2d",
"object": "event",
"type": "session.updated",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T04:55:09.871Z",
"livemode": true,
"data": {
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"name": "Morning Yoga",
"startTime": "2026-10-03T10:30:00.000Z",
"endTime": "2026-10-03T11:30:00.000Z",
"status": "SCHEDULED",
"scheduleTemplateId": "5e550100-0001-4000-8000-000000000000"
}
}session.cancelled
A session was cancelled. status is CANCELLED.
{
"id": "evt_1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"object": "event",
"type": "session.cancelled",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T19:30:44.390Z",
"livemode": true,
"data": {
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"name": "Morning Yoga",
"startTime": "2026-10-03T10:30:00.000Z",
"endTime": "2026-10-03T11:30:00.000Z",
"status": "CANCELLED",
"scheduleTemplateId": "5e550100-0001-4000-8000-000000000000"
}
}door.access
Fires for each scan at a door, including a re-entry and a door that admits without a check-in, neither of which sends a check-in event. Use it when you need to match door activity to something else in real time, such as camera footage for tailgating detection. It is being enabled location by location. Filter on outcome: a DENIED scan means the door stayed shut. Scans a door panel refuses or handles offline itself, doors opened remotely by staff, and an unrecognised fob at a location without multi-door access rules do not send this event.
{
"doorAccessEventId": "b41e…",
"occurredAt": "2026-11-06T01:02:03.000Z",
"outcome": "ALLOWED",
"siteId": "9dc9…",
"door": { "id": "7ac2…", "name": "Front Door", "provider": "VIVA_MAC" },
"zone": { "id": "51fd…", "name": "Main Entrance" },
"member": {
"clientId": "c1a7…",
"firstName": "Sam",
"lastName": "Lee",
"email": "sam@example.com"
},
"credentialType": "FOB",
"source": "FOB_SCAN",
"deniedBy": null,
"failReason": null
}door.idis stable, so use it as your device id.door.nameis the name shown in Core and can change.outcomeis what the door did, which can differ from the Core access log. For example, a Fitness Passport grant opens the door and sendsALLOWEDwhile the log shows the refusal it overrode.memberis null when a scanned fob matched nobody.occurredAtis when Hapana recorded the scan, normally within a couple of seconds of the door decision. A partner door that replays taps after being offline sends them late, stamped with the time Hapana received them rather than when the door opened.
session.instructor_changed
Fires after any change to who teaches a class: a substitution or instructor write through the API, a substitution or instructor edit in Core, a this-and-future recurring edit (once per affected occurrence), a class-cover fill, and a staff member being deactivated or no longer teaching (once per future class they leave). Your own API writes arrive here too, so you can confirm them.
{
"sessionId": "3f0b…",
"siteId": "9dc9…",
"name": "Reformer 45",
"startTime": "2026-10-09T19:00:00.000Z",
"endTime": "2026-10-09T19:45:00.000Z",
"instructors": [
{ "id": "619e…", "name": "Sarah Johnson", "role": "SUBSTITUTE", "substitutingFor": "f9c5…" }
],
"changedBy": "c1a7…",
"source": "PARTNER_API"
}instructorsis the roster as it now stands, not a diff. An empty array means nobody is teaching.substitutingForis set on aSUBSTITUTEentry and names the instructor whose class it is. The original instructor's assignment is removed when a substitute is made, so this field is how you see who is covering for whom.sourceisPARTNER_API(an API key),STAFF(a person in Core),SYSTEM(a job) orNATIVE_COVER(a Hapana class-cover fill).changedByis the staff id that made the change, or null.
On a site that sends instructor events, session.updated also carries the current instructors and a sorted changedFields list of what the edit changed: startTime and endTime when the class moved, plus date when the day changed, and instructors when who teaches changed. Instructor events are being enabled brand by brand. An occurrence of a recurring class that has not been created yet has no row of its own, so a pattern-level change to it shows up in your next GET /sessions pull with updatedSince, not as an event.
Staff events
Staff can work across several locations, so one staff change produces one event per location the staff member can access (each with its own event id and siteId). Only the locations your webhook covers are delivered. Deduplicate on staffUserId if you only need one notification per change.
staff.created
A staff member was added.
| Field | Type | Description |
|---|---|---|
staffUserId | string | The staff member id. |
siteId | string | A location the staff member can access. |
firstName | string | First name. |
lastName | string | Last name. |
email | string | Email address. |
status | string | active or inactive. |
{
"id": "evt_4c5d6e7f-8a9b-4c0d-a1e2-f3a4b5c6d7e8",
"object": "event",
"type": "staff.created",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-01T22:03:18.554Z",
"livemode": true,
"data": {
"staffUserId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane.smith@example.com",
"status": "active",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b"
}
}staff.updated
A staff member's profile was edited (staffUserId and siteId only — read the profile back for details), or they were reactivated (status is also included, set to active).
{
"id": "evt_9f8e7d6c-5b4a-4392-8a1b-0c9d8e7f6a5b",
"object": "event",
"type": "staff.updated",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T03:47:26.119Z",
"livemode": true,
"data": {
"staffUserId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b"
}
}staff.deactivated
A staff member was deactivated. status is inactive.
{
"id": "evt_5e6f7a8b-9c0d-4e1f-b2a3-c4d5e6f7a8b9",
"object": "event",
"type": "staff.deactivated",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T05:12:00.736Z",
"livemode": true,
"data": {
"staffUserId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
"status": "inactive",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b"
}
}Legacy v1 check-in
The checkIn event exists for partners that integrated with the Hapana v1 platform's check-in webhook — for example, Realtime Feedback — and need the same body unchanged. It reproduces the v1 payload field for field, in the same order.
- No envelope. The body is the bare v1 JSON object. There is no
id,typeenvelope, ordatawrapper — the top-leveltypefield is always"checkIn". - Same headers. It carries the same
X-Hapana-Event-ID,X-Hapana-Event-Type: checkIn,X-Hapana-Timestamp, andX-Hapana-Signatureheaders as every other event, plus your custom header if set. A receiver built for v1 can ignore them; new code should verify the signature and use the event id for idempotency. - Opt-in. It appears in its own Legacy group in Core and is not included by Select all events. For a new integration, use
checkin.createdinstead. - Fires for refused check-ins too, like v1.
Example body
{
"uid": "17909825160599fd1f-fe1a-4ce6-9b41-3d7e00530401",
"type": "checkIn",
"siteID": "<encrypted>",
"clientID": "<encrypted>",
"checkInTime": "2026-10-02T23:08:22",
"checkin_dt": "2026-10-02",
"checkin_id": "<encrypted>",
"firstName": "Brad",
"lastName": "Shipman",
"email": "member@example.com",
"barcode": "0707",
"fobNumber": "",
"joinDate": "2026-07-23",
"packageName": "Employee Membership",
"packageID": "<encrypted>",
"checkInStatus": "Valid",
"checkInMessage": "Valid check-in - with alerts",
"sessionType": "",
"sessionName": "",
"sessionID": "",
"sessionStartTime": "",
"sessionInstructor": "",
"sessionInstructorID": "",
"phone": "+15555550123",
"customProperties": []
}Fields
| Field | Type | Description |
|---|---|---|
uid | string | Unique id for this body: Unix seconds followed by the member's id. Extra bodies for additional sessions (below) get a suffix. |
type | string | Always checkIn. |
siteID | string | Encrypted location id. For a location migrated from v1, the exact value v1 sent. |
clientID | string | Encrypted member id. For a member migrated from v1, the exact value v1 sent. |
checkInTime | string | Check-in time in UTC, YYYY-MM-DDTHH:mm:ss with no offset. |
checkin_dt | string | Check-in date in the location's time zone, YYYY-MM-DD. |
checkin_id | string | Encrypted check-in id. |
firstName | string | Member's first name. |
lastName | string | Member's last name. |
email | string | Member's email. |
barcode | string | Member's barcode. |
fobNumber | string | Member's fob number. |
joinDate | string | Member's join date in the location's time zone, YYYY-MM-DD. |
packageName | string | Name of the membership or package the check-in used. |
packageID | string | Encrypted id of that membership or package. |
checkInStatus | string | Valid, Invalid, or error (member not found). |
checkInMessage | string | Human-readable outcome, using the v1 strings — see the table below. |
sessionType | string | Session type name, for a session check-in. |
sessionName | string | Session name. |
sessionID | string | Encrypted session id. |
sessionStartTime | string | Session start, ISO 8601 with the location's UTC offset. |
sessionInstructor | string | Instructor names, comma-separated. |
sessionInstructorID | string | Encrypted instructor ids, comma-separated, in the same order. |
phone | string | Member's mobile, else home phone. |
customProperties | array | The member's filled custom properties at the location, as { "fieldLabel": "…", "fieldValue": "…" } objects. |
Fields with no value are sent as an empty string "", never null — the session fields are all "" for a general (non-session) check-in, as in the example above.
Status and message
| Outcome | checkInStatus | checkInMessage |
|---|---|---|
| Successful | Valid | Valid check-in |
| Successful, member has failed or pending payments | Valid | Valid check-in - with alerts |
| Refused — membership suspended | Invalid | Invalid check-in - Suspended membership |
| Refused — membership on hold | Invalid | Invalid check-in - with alerts - Membership on hold |
| Refused — any other reason | Invalid | Invalid check-in - no valid membership or booking |
| Member not found | error | no record found |
As in v1, the success messages are capitalised (Valid check-in) for kiosk, Core, and widget check-ins, and lowercase (valid check-in) for barcode, fob, contactless, QR, app, and other sources. Compare case-insensitively.
Several sessions on one scan
When one scan checks a member into several booked sessions, Hapana sends one body for the first session and one extra body for each additional session — each as its own delivery with its own event id. The extra bodies are identical except for the session fields and a uid with a suffix, matching v1.
Re-scans
A re-scan inside the location's re-entry grace window (15 minutes by default) sends nothing. A later re-scan sends a new body, as v1 did.
Ids
Every id in the legacy body (siteID, clientID, checkin_id, packageID, sessionID, sessionInstructorID) is encrypted the same way v1 encrypted them. For records migrated from v1, the values match what v1 sent, so ids you stored from v1 keep matching. For records created after migration the format is the same and the value is stable. These are not the ids used by checkin.created or the Business API.
