Door access
Gate physical entry on a member's Hapana membership. Mirror the member list locally, keep it current with webhooks, and decide allow or deny at the reader in single-digit milliseconds.
You are building a door-access integration — Brivo, Openpath, ICT, Kisi or your own RFID stack. The reader cannot wait on a network round trip, so the decision has to be local. That shapes the whole design:
- Seed a local mirror of every member, once per site.
- Subscribe to member webhooks so the mirror stays current.
- Decide at the reader, entirely from the mirror.
- Reconcile nightly, because webhooks are delivered at-least-once, not exactly-once.
Everything below is on the Business API and needs a brand API key. The Member API is for apps acting as one signed-in member and is the wrong tool here.
Two integration models. This guide is the client-mirror path: you hold the member list and decide at the reader. If you would rather have Hapana decide, keeping a verdict list and asking on every tap so attendance and credits are recorded, follow the check-in eligibility guide instead.
Before you start
Ask the brand for an API key with sites:read, clients:read, access:read, packages:read and checkins:write. Add clients:write and access:write only if you will write fob numbers back. Without access:read, member reads leave out barcode and fobNumber entirely, and the integration has nothing to match a scan against. Keys look like hk_live_… in production and hk_test_… in the sandbox, and are shown once at creation — they are stored hashed and cannot be recovered.
The base URL is https://public-api.hapana-app.com and every path is prefixed /public-api/v2. Send the key as a bearer token:
curl 'https://public-api.hapana-app.com/public-api/v2/sites' \
-H 'Authorization: Bearer hk_live_…'Step 1 — Seed the mirror
GET /clients requires a siteId, so seed one site at a time. Start from GET /sites, which returns every site of the brand in one response. Member pages carry up to 500 records:
GET /public-api/v2/clients?siteId=<site-uuid>&limit=500Every list response uses the same envelope:
{
"object": "list",
"data": [ /* Client objects */ ],
"hasMore": true,
"nextCursor": "M2E3MThmMTktZGRiYy00YzRkLThhMzgtZWRiNmZkODRiMjZh",
"url": "/public-api/v2/clients"
}While hasMore is true, pass the nextCursor value back as cursor. The cursor is opaque — treat it as a token, never decode or construct one.
GET /public-api/v2/clients?siteId=<site-uuid>&limit=500&cursor=<nextCursor>A 10,000-member site is 20 requests. The rate limit is reported on every response as x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset — read those rather than assuming a number, and back off when remaining hits zero.
The fields that matter
| Field | What to do with it |
|---|---|
id | Stable member id. Use it as the join key for every webhook that follows. |
barcode | What the member's card or QR encodes. Index it — this is your scan lookup. Returned only to a key holding access:read. |
fobNumber | The door code, when the brand issues fobs rather than barcodes. Returned only to a key holding access:read. Writable — see below. |
membershipStatus | Top-level signal. Deny anything that is not active. |
suspended | Explicit suspension flag. Deny when true, whatever the status says. |
updatedAt | Last change. Keep the high-water mark for the nightly reconcile. |
dateOfBirth is returned only to a key that also holds clients:pii, because a date of birth is personal data a door-access integration has no need for. Ask for that scope only if you genuinely use it.
Step 2 — Work out which sites a member may enter
This is the part most integrations get wrong. Entry rights are not on the member — they come from the packages the member holds, because a single-site pass and a brand-wide membership are different products.
Call the member's packages with no siteId. Omitted, it returns every package the member holds across the whole brand. Pages carry up to 200 packages; follow nextCursor exactly as for members:
GET /public-api/v2/clients/<client-id>/packages?limit=200{
"object": "client_package",
"id": "6c4f7801-e548-40d6-84bb-c8ee96a861d2",
"siteId": "68cfcf68-3a07-40af-8265-dbc61de9eae4",
"accessSiteIds": ["68cfcf68-3a07-40af-8265-dbc61de9eae4"],
"name": "Neighborhood",
"type": "MEMBERSHIP",
"status": "ACTIVE",
"onHold": false
}accessSiteIds is every site that package can be used at, including its own. It is resolved live from the brand's site-sharing configuration at the moment you ask, so it already accounts for a package shared into another club and for a shared-in package whose home site has been deactivated.
A member may enter a site when any package with status: "ACTIVE" and onHold: false lists that site in accessSiteIds. Store that union against the member in your mirror and refresh it whenever a membership or package event arrives.
Step 3 — Keep the mirror current
Webhook endpoints are configured by the brand in Hapana under Control Center → API & Integrations → Webhooks, per site. Ask the brand to point one at your HTTPS endpoint and subscribe to the events below. Payloads are deliberately thin — the member id, the site and, for membership events, the package — so treat every event as a signal to re-read, never as the new state:
| Event | Do this |
|---|---|
member.created | Fetch the member, insert them, then fetch their packages for the access set. |
member.updated | Refetch the member — barcode, fob and status all arrive this way, as does lifting a site block. |
member.suspended | Deny at the site in the event — that site has blocked the member. Sent only to that site; access elsewhere is unchanged. |
member.deleted | Revoke and remove. |
membership.created, membership.renewed, membership.updated, membership.cancelled, membership.expired | Re-read the member's packages and recompute the access set. A suspended membership arrives as membership.updated with newStatus: "SUSPENDED"; the member keeps access wherever another live package still covers them. |
Three behaviours to design for:
membership.createddoes not mean paid or active. A sale scheduled to start later is created asPENDING_START_DATE, which is why the access set comes from re-readingstatus, not from the event.- Memberships imported by a brand migration send no
membership.created. The seed and the nightly reconcile pick them up. - A site block takes effect when staff save it, even if its start date is in the future, so
member.suspendedcan arrive ahead of the block date. Re-read rather than assuming the block is already active.
The membership events, member.suspended and member.deleted are being enabled site by site. Until a site has them, the nightly reconcile is what keeps it current — another reason not to skip it.
The events are named member.*, not client.*. Deliveries carry X-Hapana-Signature: t=<timestamp>,v1=<hex>, an HMAC-SHA256 over ${timestamp}.${rawBody} using the endpoint's signing secret. Verify it against the raw body before parsing, and reject timestamps outside your tolerance window. Each delivery gets up to three attempts in total, roughly one and then two minutes apart, so make your handler idempotent on the event id.
Step 4 — Decide at the reader
Entirely local, no API call:
- Look up the member by
barcodeorfobNumber. No match → deny. suspendedis true → deny.membershipStatusis not active → deny.- This site is not in the member's access set → deny.
- Otherwise → open the door.
Then record the entry, asynchronously and off the critical path, so it appears in the brand's attendance reporting:
POST /public-api/v2/checkins
Idempotency-Key: <your-scan-id>
{ "siteId": "<site-uuid>", "clientId": "<client-id>" }The call returns 201 with the check-in, including "source": "PUBLIC_API" so staff can see where it came from. A 201 is not the same as a successful entry: when Hapana refuses the check-in the record still comes back 201, with "status": "FAIL" and a failReason. Read status, and treat a FAIL as a sign your mirror has drifted for that member.
Send an Idempotency-Key unique to the scan; retrying with the same key replays the original response instead of recording a second entry. Separately, a repeat tap at the same site inside its re-entry grace window — 15 minutes unless the brand changes it — returns the earlier check-in rather than creating a new one.
Note that siteId goes in the body here, unlike the member update above where it is a query parameter.
Queue these and retry on failure. A door that stays shut because the check-in call timed out is a worse outcome than a check-in that lands a few seconds late.
Step 5 — Reconcile nightly
Webhooks are at-least-once, and an endpoint that is down long enough will miss some. Once a day, reconcile each site against a full snapshot:
- Page the whole member list for the site and replace your copy of it. Remove any member you hold for that site who is not in the list.
- Re-read the packages of every member you mirror and recompute their access sets. Member lists do not change when a package does, so this is the only reconcile that catches a missed membership event.
Between snapshots you can fetch just the members that changed since your last pass:
GET /public-api/v2/clients?siteId=<site-uuid>&updatedAfter=2026-09-01T00:00:00Z&limit=500Use the time your previous sync started as the next updatedAfter, so a record written while that sync was running is not skipped. updatedAfter reflects changes to the member and to their link with that site — not package changes, which only a package re-read shows.
updatedAfter is not usable from outside our network yet. Requests carrying it are currently rejected at our edge with a 403 before they reach the API, and the response is an HTML error page rather than our JSON envelope. The fix is in flight. Until it lands, rely on the full snapshot — at 500 per page that is cheap enough for a site of any realistic size.
Writing a fob number back
If your system issues the credential rather than Hapana, write it back so staff see the same code on the member's profile. Note that siteId is a query parameter here, not a body field:
POST /public-api/v2/clients/<client-id>?siteId=<site-uuid>
{ "fobNumber": "99001234" }Fob numbers are unique across the brand. Assigning one that another member already holds returns 400 naming the holder, rather than silently moving it — clear it from the other profile first. Send null to revoke. This needs both clients:write and access:write; without the second the call is refused with 403.
Testing
Ask for a sandbox key against a demo brand rather than testing on live members. Sandbox keys are prefixed hk_test_ and behave identically. Work through, in order: seed one site, assign a fob and read it back, confirm a duplicate is refused, fire a test delivery from the brand's webhook screen and check your signature verification, then run a full scan-to-check-in cycle against a real reader.
