Check-in eligibility
For door and access-control systems. Keep a list of who may check in at each site, and ask Hapana on every tap so attendance and credits are recorded.
Door access uses two calls:
GET /public-api/v2/business/access/checkin-eligibilityreturns, for every member at a site, whether they may check in. Store it, and keep it fresh by pulling only what changed. It is what your door falls back on when it cannot reach Hapana.POST /public-api/v2/business/access/checkinis called on every tap. Hapana decides, and when the answer is yes it records the check-in and uses a credit if the membership needs one. Your system opens the door; Hapana never does.
Hapana makes every decision. You never need to work out membership rules, credits or opening hours yourself.
Two integration models. This guide is for systems that let Hapana decide: you cache the verdict list and ask on every tap. If you would rather mirror the member list yourself and decide at the reader from your own copy, follow the door access guide instead. Pick one; the two paths are not meant to be mixed.
Before you start
- Create an API key in Control Center → API and Integrations → API keys with the
access:readandaccess:writescopes. Send it asAuthorization: Bearer hk_live_…. - Requests go to
https://public-api.hapana-app.com. Usehttps://public-api.hapana-staging.comwith anhk_test_…key while you build. - Hapana turns door access on for each site you integrate with. Until it is on, the eligibility list for that site is empty. Ask your Hapana contact to set it up.
- Every request names the site with
siteId. A site that is not in your brand returns404. - Your door must be active at the site. Hapana registers each of your doors at the site against your door system, and those doors decide what the site serves you. While no such door is active there, the eligibility list and the bookings feed return
403withcode: "PROVIDER_NOT_ACTIVE_AT_SITE", and a tap is refused withDOOR_NOT_ACTIVE. When you see the403, purge what you cached for that site and stop polling it until Hapana confirms the door is active again. See doors.
Step 1: Load the full list
Call the eligibility endpoint without updatedSince to get every member with a verdict at the site. Ask for up to 500 per page.
curl 'https://public-api.hapana-app.com/public-api/v2/business/access/checkin-eligibility?siteId=<site-id>&limit=500' \
-H 'Authorization: Bearer hk_live_…'{
"object": "list",
"data": [
{
"object": "checkin_eligibility",
"memberId": "<member-id>",
"barcode": "100234",
"credentials": [{ "type": "BARCODE", "value": "100234" }],
"canCheckIn": true,
"reason": "OK",
"doors": {
"<door-id>": { "canCheckIn": true, "reason": "OK" }
},
"updatedAt": "2026-10-07T09:14:03.120Z"
}
],
"hasMore": true,
"nextCursor": "<cursor>",
"url": "/public-api/v2/business/access/checkin-eligibility"
}While hasMore is true, request the next page with cursor=<nextCursor> and the same siteId. A page can hold fewer entries than limit even when more follow, so always go by hasMore.
| Field | Use |
|---|---|
memberId | Hapana's id for the member. Use it as your key for the row. |
barcode, credentials | What the member's card or app code carries. barcode is null when the member has none. Index it for lookups at the door. |
canCheckIn, reason | The answer for the site's main entrance, and why. See reason codes. |
doors | The answer for each door, keyed by Hapana's door id. See doors. |
updatedAt | When this member's answer last changed. For display only; do not use it to page or to pull changes. |
A member is in the list whether the answer is yes or no. A member who is not in the list has no answer yet. Treat them as unknown, not as allowed, and ask Hapana when they tap.
canCheckIn is the authoritative answer. A client's membershipStatus is a cached membership and package status, recomputed only when a membership or package changes status, so it can be out of date. canCheckIn is worked out at entry from credits, failed payments, blocks, suspensions and the site's check-in settings, so the two can disagree. Gate entry on canCheckIn, not on membershipStatus.
Step 2: Pull only what changed
After the full load, poll with updatedSince to get only the members whose answer changed since then. Page through the result the same way.
curl 'https://public-api.hapana-app.com/public-api/v2/business/access/checkin-eligibility?siteId=<site-id>&updatedSince=2026-10-07T09:00:00Z&limit=500' \
-H 'Authorization: Bearer hk_live_…'- Set
updatedSincefrom your own clock. Record the time you send each pull and pass it, minus a minute to allow for clock differences, asupdatedSinceon the next one. Do not pass back the highestupdatedAtyou received: those rows would come back on every pull. - Receiving the same row twice is harmless. Apply each row as the latest answer for that member.
- A member who loses access shows up in your next pull with
canCheckIn: false. - How soon a change appears. Changes to a membership, a package or its credits are recomputed within seconds. Changes that happen on a date, such as a start or expiry date being reached or a scheduled suspension, and failed payments, are picked up by a reconcile that runs every 15 minutes, so allow up to 15 minutes for those.
- A cursor from a full load does not work on a changes pull, or the other way round. Mixing them returns
400. - The list answers "may this member check in at all". It does not go down as credits are used during the day. The tap response in step 3 carries the credit balance.
Step 3: Ask Hapana on every tap
When a member taps, send the site, what they tapped, and the door. Send barcode or memberId; one of them is required.
curl -X POST 'https://public-api.hapana-app.com/public-api/v2/business/access/checkin' \
-H 'Authorization: Bearer hk_live_…' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: <unique-per-tap>' \
-d '{ "siteId": "<site-id>", "barcode": "100234", "doorId": "<door-id>" }'{
"object": "checkin_result",
"canCheckIn": true,
"reason": "OK",
"checkInId": null,
"timestamp": "2026-10-07T09:15:41.002Z",
"creditsRemaining": 7,
"creditsAtRenewal": 8,
"renewalAt": "2026-11-01T00:00:00.000Z",
"isUnlimited": false
}- A refusal is still a
200. The request worked and the answer is no:canCheckIn: falsewith areason. Error statuses mean the request itself failed. - The endpoint is designed to answer within 1.5 seconds, so a short timeout is safe. If it does not answer in time, or you cannot reach Hapana, decide from your stored list and replay the tap later (see offline taps).
- When the answer is yes, Hapana records the visit and uses a credit when the membership needs one. The visit is recorded just after the response is sent, so
checkInIdis alwaysnull. creditsRemainingis the balance after this visit, andcreditsAtRenewalandrenewalAtsay when it resets. All four credit fields arenullwhen the member's membership does not use credits. On an unlimited membershipisUnlimitedistrueand the counts arenull. Use them to keep your stored answer accurate between pulls.- A barcode or member Hapana does not recognise is refused with
NO_ACTIVE_MEMBERSHIP, and nothing is recorded.
Reason codes
reason appears on every list row, every door answer and every tap response. New codes may be added, so treat a code you do not recognise as a refusal.
| Code | Meaning |
|---|---|
OK | May check in. |
NO_CREDITS | The membership needs credits and none are left. |
CHECKIN_DISABLED | Check-in is turned off for this member, or not allowed this way. |
PAYMENT_FAIL | The member has a failed or outstanding payment. |
SUSPENDED | The membership is suspended or on hold. |
MEMBER_BLOCKED | The member is blocked. |
NO_ACTIVE_MEMBERSHIP | No membership or booking that allows entry right now. |
ACCESS_HOURS | Outside the hours this member may enter. |
ZONE_SCHEDULE | This door or area is closed right now. |
ACCESS_DENIED | Refused for a door-specific rule, for example the member's membership does not cover this area. |
DOOR_NOT_ACTIVE | The doorId on the tap is not an active door of yours at this site, or you sent no doorId and the site has no active door of yours. Nothing is recorded. Re-pull doors. |
Sites with several doors
A site can have more than one door, and a member may be allowed through some but not others: the front entrance but not the sauna, for example. Hapana gives every door an id. List them with GET /public-api/v2/business/access/doors?siteId=… (scope access:read) and map each doorId to your own device.
{
"object": "list",
"hasMore": false,
"url": "/public-api/v2/business/access/doors",
"data": [
{
"object": "door",
"doorId": "a605c1d3-…",
"siteId": "6c8861f9-…",
"name": "Main entrance",
"status": "ACTIVE",
"isMainEntrance": true,
"zone": {
"zoneId": "…",
"name": "Main",
"entryMode": "ACCESS_ONLY",
"walkIn": "ALLOWED",
"accessSchedule": { "mode": "TWENTY_FOUR_HOUR" },
"sessionTypeIds": [],
"rooms": []
},
"updatedAt": "2026-10-22T09:00:00.000Z"
}
]
}- The list is not paginated. Only the site's door-access partner doors are returned; a site with none returns an empty list. Until door listing is switched on for your account the call answers
404withcode: FEATURE_NOT_ENABLED; ask your Hapana contact to enable it. - A door switched off in Hapana stays listed with
status: INACTIVE; a removed door disappears. An inactive door has no entry in the feed'sdoorsmap. Refuse taps at an inactive door on your side and do not send them to Hapana: treat a missing entry as a refusal, never as an allow.isMainEntrancetells you which doors are the site's front entrance; thezonesummary is read-only, the tap still decides. - Each list row's
doorsmap holds the answer for every door at the site. Decide from the entry for the door that was tapped. - Send that door's id as
doorIdon the tap. Without it, Hapana answers for the site's first door. - A door with no answer for the member yet comes back as
canCheckIn: falsewithACCESS_DENIED.
Bookings feed
Some doors open only for a booked class. The bookings feed gives you the upcoming bookings at a site, each with the window in which the member may enter, so you can pre-load them on your controller. The tap stays the authority: a booking never bypasses POST /access/checkin.
Once a day, or whenever your cache is empty, pull the full window:
curl 'https://public-api.hapana-app.com/public-api/v2/business/access/bookings?siteId=<site-id>&days=7&limit=500' \
-H 'Authorization: Bearer hk_live_…'Every 15 minutes, pull only what changed. Set changedSince to the serverTime of your previous pull minus 5 minutes:
curl 'https://public-api.hapana-app.com/public-api/v2/business/access/bookings?siteId=<site-id>&days=7&changedSince=2026-10-20T08:40:00Z&limit=500' \
-H 'Authorization: Bearer hk_live_…'{
"object": "list",
"serverTime": "2026-10-20T09:00:02.441Z",
"data": [
{
"object": "booking_feed_entry",
"bookingId": "<booking-id>",
"status": "CONFIRMED",
"member": { "memberId": "<member-id>", "barcode": "100234" },
"session": {
"sessionId": "<session-id>",
"name": "Reformer 45",
"sessionTypeId": "<session-type-id>",
"status": "SCHEDULED",
"startTime": "2026-10-20T18:00:00.000Z",
"endTime": "2026-10-20T18:45:00.000Z",
"timezone": "Australia/Sydney",
"roomId": "<room-id>",
"locationId": "<site-id>"
},
"entryWindow": { "from": "2026-10-20T17:30:00.000Z", "to": "2026-10-20T18:15:00.000Z" },
"updatedAt": "2026-10-20T08:52:19.003Z"
},
{
"object": "booking_feed_entry",
"bookingId": "<booking-id-2>",
"status": "CANCELLED",
"member": { "memberId": "<member-id-2>", "barcode": null },
"session": {
"sessionId": "<session-id>",
"name": "Reformer 45",
"sessionTypeId": "<session-type-id>",
"status": "SCHEDULED",
"startTime": "2026-10-20T18:00:00.000Z",
"endTime": "2026-10-20T18:45:00.000Z",
"timezone": "Australia/Sydney",
"roomId": "<room-id>",
"locationId": "<site-id>"
},
"entryWindow": { "from": "2026-10-20T17:30:00.000Z", "to": "2026-10-20T18:15:00.000Z" },
"updatedAt": "2026-10-20T08:58:40.710Z"
}
],
"hasMore": false,
"url": "/public-api/v2/business/access/bookings"
}| Parameter | Use |
|---|---|
siteId | Required. One site per call. |
from, days | The window: sessions starting from from for days days (1 to 14, default 7). from defaults to 2 hours before now, so a class that has already started is still in the window while its members may enter. It can be at most 7 days in the past, else 400. |
changedSince | Optional. Keeps only rows whose booking or session changed after this time. Must be no more than 30 days before from and not in the future, else 400. |
cursor, limit | Page the same way as the eligibility list. limit is 1 to 500, default 100. |
| Field | Use |
|---|---|
serverTime | Hapana's clock when the page was built. Store it and derive the next changedSince from it, never from your own clock. |
bookingId, status | Your key for the row. CONFIRMED and ATTENDED rows are pre-loads; a CANCELLED row removes one. |
member | memberId and the barcode the member will tap with, or null when they have none. |
session | The class as it stands now: id, name, type, status (SCHEDULED or CANCELLED), start and end in UTC, the site's IANA timezone, and the roomId and locationId when set. |
entryWindow | When the member may enter for this booking: the same window the tap applies, built from the site's check-in settings. It opens the site's early check-in minutes before the start, or 2 hours before when the site sets none, and never earlier than 2 hours. It closes the site's late check-in minutes after the start but never after the class ends; at the start when late check-in is 0; at the end when the site sets none. Use it as is, and stop configuring early-entry minutes per gym. |
updatedAt | When the booking or its session last changed. For display only. |
- Upsert by
bookingId. Rows are delivered at least once, and duplicates across the 5-minute overlap are expected. - A
CANCELLEDrow removes the pre-load. A cancelled booking, a late cancellation, a cancelled session and a deleted session all look the same to you. - A session that moves in time reappears with its new
startTimeandentryWindow. Replace the row. - A session that moves out of your window is not reported by the delta. The daily full pull handles it: replace your cache for the site with the full result.
- The feed is a pre-load, not a decision. Send every tap to
POST /access/checkinso attendance and credits are recorded.
When a membership becomes active
Subscribe your webhook to membership.activated to learn the moment a member can start using the door, instead of waiting for your next pull. It is sent once per membership per location when the membership first becomes usable: a purchase paid for today, a membership whose first payment has just cleared, or a pre-sale reaching its start date (including a pre-sale that was migrated in). Treat it as a signal to pre-load the member, not as permission to open the door: an active membership does not clear a site block, a suspension or a door-specific rule, and the tap on POST /access/checkin stays the authority that admits the member and records the visit.
It is never sent for a resume from hold, a restore after cancellation, an extension or a credit reactivation; those arrive as membership.updated. A pre-sale being sold is membership.created only. Memberships inserted by a bulk import or migration as already active send nothing. Locations the member can use the membership at but does not belong to receive nothing.
{
"id": "evt_1b4f0c3d9a2e4c7f8e6d5a4b3c2d1e0f",
"object": "event",
"type": "membership.activated",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-22T03:12:09.117Z",
"livemode": true,
"data": {
"clientId": "<member-id>",
"siteId": "<site-id>",
"serviceId": "<membership-id>",
"serviceSiteId": "<selling-site-id>",
"serviceTemplateId": "<plan-id>",
"parentServiceId": null,
"barcode": "8675309",
"activatedAt": "2026-10-22T03:12:08.902Z",
"trigger": "purchase"
}
}siteIdis the location this delivery is for;serviceSiteIdis the location that sold the membership.parentServiceIdis set when the membership is an add-on, otherwisenull.triggerispurchase,payment_activation,scheduled_activation,renewalormovement.barcodeis the member's barcode at delivery time,nullwhen they have none yet. At a location that assigns barcodes automatically, the first activation of a member without one can arrive about 10 seconds later, carrying the newly assigned barcode.activatedAtis when the membership became usable.- The event
idis derived from the membership and location, so a redelivery carries the same id. Treat a repeated id as already handled. - What to call next. Pull
GET …/access/checkin-eligibility?siteId=…&updatedSince=…for that site. The member's verdict row is written asynchronously, within seconds of the event. Or simply wait for your next scheduled pull. - Delivery requires the site to have lifecycle events enabled by Hapana. Verify the signature like any other event; the full field table is in the event reference.
Offline taps and retries
When your door decided a tap without Hapana, send the tap once you are back online so the visit is recorded.
- Set
occurredAtto when the member tapped. Hapana decides as of that moment and records the visit at that time. It may be at most 5 minutes in the future and 7 days in the past; outside that window the tap is refused with422and nothing is recorded. - If your door refused the member, send
doorDecision: "DENY"and optionally adoorDecisionReasonof up to 200 characters. Hapana logs the attempt without recording a visit or using a credit. LeavingdoorDecisionout, or sending"ALLOW", asks Hapana to decide as usual. - Send an
Idempotency-Keythat is unique to each tap. A retry with the same key and body returns the first response, so a tap is never counted twice and a credit is never used twice. - A key belongs to one exact request. To correct a decision you already sent, for example from
DENYtoALLOW, send it with a new key. The same key with a different body returns422.
Errors
| Status | Meaning |
|---|---|
400 | Malformed request: a missing siteId, neither barcode nor memberId, a bad timestamp, a cursor from the other kind of pull, or a changedSince more than 30 days before from. |
401 | The API key is missing, wrong or revoked. |
403 | The API key lacks access:read or access:write, or the brand's plan does not include API access. |
403 with code: "PROVIDER_NOT_ACTIVE_AT_SITE" | No door is active for your door system at this site. Purge your cached list for the site and stop polling it until Hapana confirms the door is active again. The body is { "error": "Forbidden", "code": "PROVIDER_NOT_ACTIVE_AT_SITE", "message": "…" }; branch on code, never on message. |
404 | The site is not in your brand. |
409 | A tap with this Idempotency-Key is still being processed. Retry shortly. |
422 | occurredAt is outside the allowed window, or an Idempotency-Key was reused with a different body. |
Every field is listed in the Business API reference under Door access.
