Developers

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-eligibility returns, 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/checkin is 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:read and access:write scopes. Send it as Authorization: Bearer hk_live_….
  • Requests go to https://public-api.hapana-app.com. Use https://public-api.hapana-staging.com with an hk_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 returns 404.
  • 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 403 with code: "PROVIDER_NOT_ACTIVE_AT_SITE", and a tap is refused with DOOR_NOT_ACTIVE. When you see the 403, 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.

FieldUse
memberIdHapana's id for the member. Use it as your key for the row.
barcode, credentialsWhat the member's card or app code carries. barcode is null when the member has none. Index it for lookups at the door.
canCheckIn, reasonThe answer for the site's main entrance, and why. See reason codes.
doorsThe answer for each door, keyed by Hapana's door id. See doors.
updatedAtWhen 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 updatedSince from your own clock. Record the time you send each pull and pass it, minus a minute to allow for clock differences, as updatedSince on the next one. Do not pass back the highest updatedAt you 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: false with a reason. 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 checkInId is always null.
  • creditsRemaining is the balance after this visit, and creditsAtRenewal and renewalAt say when it resets. All four credit fields are null when the member's membership does not use credits. On an unlimited membership isUnlimited is true and the counts are null. 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.

CodeMeaning
OKMay check in.
NO_CREDITSThe membership needs credits and none are left.
CHECKIN_DISABLEDCheck-in is turned off for this member, or not allowed this way.
PAYMENT_FAILThe member has a failed or outstanding payment.
SUSPENDEDThe membership is suspended or on hold.
MEMBER_BLOCKEDThe member is blocked.
NO_ACTIVE_MEMBERSHIPNo membership or booking that allows entry right now.
ACCESS_HOURSOutside the hours this member may enter.
ZONE_SCHEDULEThis door or area is closed right now.
ACCESS_DENIEDRefused for a door-specific rule, for example the member's membership does not cover this area.
DOOR_NOT_ACTIVEThe 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 404 with code: 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's doors map. 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. isMainEntrance tells you which doors are the site's front entrance; the zone summary is read-only, the tap still decides.
  • Each list row's doors map 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 doorId on 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: false with ACCESS_DENIED.

Bookings feed

Preview — contract may change before release. The bookings feed is being built. The behaviour below is decided; field names are provisional until it ships.

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"
}
ParameterUse
siteIdRequired. One site per call.
from, daysThe 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.
changedSinceOptional. 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, limitPage the same way as the eligibility list. limit is 1 to 500, default 100.
FieldUse
serverTimeHapana's clock when the page was built. Store it and derive the next changedSince from it, never from your own clock.
bookingId, statusYour key for the row. CONFIRMED and ATTENDED rows are pre-loads; a CANCELLED row removes one.
membermemberId and the barcode the member will tap with, or null when they have none.
sessionThe 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.
entryWindowWhen 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.
updatedAtWhen 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 CANCELLED row 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 startTime and entryWindow. 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/checkin so attendance and credits are recorded.

When a membership becomes active

Preview — ships with INT-1857. The membership.activated webhook is in review. The contract below matches that change and is final unless the review alters it.

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"
  }
}
  • siteId is the location this delivery is for; serviceSiteId is the location that sold the membership. parentServiceId is set when the membership is an add-on, otherwise null.
  • trigger is purchase, payment_activation, scheduled_activation, renewal or movement. barcode is the member's barcode at delivery time, null when 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. activatedAt is when the membership became usable.
  • The event id is 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 occurredAt to 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 with 422 and nothing is recorded.
  • If your door refused the member, send doorDecision: "DENY" and optionally a doorDecisionReason of up to 200 characters. Hapana logs the attempt without recording a visit or using a credit. Leaving doorDecision out, or sending "ALLOW", asks Hapana to decide as usual.
  • Send an Idempotency-Key that 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 DENY to ALLOW, send it with a new key. The same key with a different body returns 422.

Errors

StatusMeaning
400Malformed 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.
401The API key is missing, wrong or revoked.
403The 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.
404The site is not in your brand.
409A tap with this Idempotency-Key is still being processed. Retry shortly.
422occurredAt 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.