Access Control API (v1)
The Access Control API connects door hardware to a studio that runs on EYalla. A card reader, turnstile controller or face terminal asks whether a member may come in, and EYalla answers granted or denied. Between scans, your system can read the studio's member list, for example to enrol faces or cards on a terminal.
It is available to studios that have the Access Control add-on switched on.
This is not the Merchant API. Requests go to the studio's own web address, not to
api.eyalla.net, and they authenticate with anX-API-KeyandX-API-Secretpair, not a Bearer token. Keys from one API never work on the other.
What the studio sends you
The studio creates your credentials in its EYalla admin under Bookings → Access API keys. There it can issue a key, change its secret, revoke it or delete it, and every scan you send appears in its access logs. Ask for one key per integration, so the studio can switch one vendor off without affecting another.
| Item | What it is |
|---|---|
| Base address | https://<studio address>/api/v1/external. Every path on this page is added to it. |
| API key | ak_ followed by 32 letters and digits. It names your integration. |
| API secret | 48 letters and digits. The studio sees it only once, when the key is made or the secret is changed, and should send it to you separately from the key. |
| Permissions | Which calls the key may make (see Permissions). |
| Hourly limit | Requests the key may make per hour. The default is 1,000. |
How it works
- Scan. The member shows their wallet card, taps an access card, or is recognised by a face terminal.
- Ask. Your device sends the code it read to
POST /access/verify. - Decide. EYalla checks the account and the membership's dates and pauses, then records the scan in the studio's access log.
- Act. Open the door only when
statusisgranted. Keep it shut for anything else.
| Call | Permission | Purpose |
|---|---|---|
POST /access/verify |
access.verify |
Decide one entry, or record one exit. |
GET /members |
members.read |
Read the studio's members, one page at a time. |
GET /members/{uuid} |
members.read |
Read one member. |
Authentication
Send every request over HTTPS with both credential headers. The key says who you are; the secret proves it. Send and accept JSON.
X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-API-Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
Content-Type: application/json
- Keep the secret on a server or controller where it can be changed. Do not build it into device firmware that cannot be updated.
- If the studio changes the secret, the old one stops working at once. Ask for the new one.
- A key the studio has revoked, or one past its expiry date, is refused with
403 key_inactive. - The secret is checked before anything else about the key is revealed. A wrong secret and an unknown key give the same answer.
Permissions
| Permission | Allows |
|---|---|
access.verify |
Checking members at the door, and recording exits. |
members.read |
Reading the member list and single members. |
* |
Every permission, including any added to this API later. |
A key that lacks the permission for a call is refused with 403 missing_scope, and the message names the permission it needs. A door reader that only checks entries needs only access.verify.
Checking a member at the door
POST /access/verify
Send one request for each scan. Once a request has passed authentication, the studio's add-on is on and the body is valid, the answer is always HTTP 200, and the decision is in the body.
Request
| Field | Required | Values | Notes |
|---|---|---|---|
credential_type |
Yes | wallet, card or qr |
What credential_id is (see below). |
credential_id |
Yes | Text, up to 255 characters | The code exactly as your device read it. |
action |
No | entry (default) or exit |
See Entry and exit. |
device_id |
No | Text, up to 100 characters | Your name for the reader, e.g. front-door-01. It is kept with the scan in the studio's log. It may instead be sent as the X-Device-ID header. |
{
"credential_type": "wallet",
"credential_id": "Hq3Tz8kLmP0aWc5RuY2vNd7XbE4sJf1GtK9oQiA6",
"action": "entry",
"device_id": "front-door-01"
}
What each credential is
| Type | credential_id is |
Where it comes from |
|---|---|---|
wallet |
The code in the barcode of the member's membership card: 40 letters and digits. Send it exactly as your scanner read it. | The card in Apple Wallet and the studio's web card show it as a QR code. Google Wallet shows it as a Code 128 barcode. Your scanner must read both. When the studio revokes or reissues a card, the card gets a new code and the old code is refused. Loyalty cards never open the door. |
card |
The access-card number paired with the member at the studio's front desk. | Send the number in exactly the format the studio pairs it, for example 10 decimal digits with leading zeros. Readers report the same card in different formats, so agree on the format with the studio before go-live. |
qr |
The member's door ID: the uuid from the member list. |
For devices that identify the member themselves, such as a face terminal that matched a face you enrolled against the uuid, or a code you issued that carries the uuid. |
The QR on the studio app's membership page does not open the door. That page shows a short-lived QR code for front-desk check-in, and this API refuses it. At the door, members use their wallet card (Apple Wallet, Google Wallet or the web card), and its barcode is sent as
wallet.
Entry and exit
entryasks for a decision. Denied scans are recorded too.exitis never refused: the answer is alwaysgranted, and the scan is recorded as an exit. If the code matches no member,data.userisnull.
Response
| Field | Meaning |
|---|---|
status |
granted or denied. This is the only field to act on. |
success |
true exactly when status is granted. |
message |
A sentence for people. It may be in the studio's language. Show it or log it, but never parse it. |
data.log_id |
The number of this scan in the studio's access log. Quote it when you ask the studio about a scan. |
data.user |
null when the code matches no member. Otherwise uuid, full_name and membership_status. |
data.has_booking_today |
true if the member has a class booked today, false if not, or null if the studio does not take class bookings on EYalla. It does not affect the decision: a member does not need a booking to enter. |
data.timestamp |
When the decision was made, in ISO 8601 with a UTC offset, for example +03:00. |
Granted:
{
"success": true,
"status": "granted",
"message": "Access granted.",
"data": {
"log_id": 5120,
"user": {
"uuid": "9b2f6c1e-3d4a-4f8e-a1b2-7c9d0e1f2a3b",
"full_name": "Mona Al-Ali",
"membership_status": "active"
},
"has_booking_today": true,
"timestamp": "2026-10-08T07:02:11+03:00"
}
}
Denied, a known member:
{
"success": false,
"status": "denied",
"message": "Membership is frozen.",
"data": {
"log_id": 5121,
"user": {
"uuid": "4e8a2d70-91c5-4b3f-8e6d-0a1b2c3d4e5f",
"full_name": "Yousef Hamad",
"membership_status": "frozen"
},
"has_booking_today": false,
"timestamp": "2026-10-08T07:03:40+03:00"
}
}
Denied, a code that matches no member:
{
"success": false,
"status": "denied",
"message": "Invalid access credential.",
"data": {
"log_id": 5122,
"user": null,
"has_booking_today": null,
"timestamp": "2026-10-08T07:04:02+03:00"
}
}
Why a scan is denied
message |
data.user |
Meaning |
|---|---|---|
| Invalid access credential. | null |
The code matches no member, or the wallet card was revoked or replaced. |
| This account is inactive. | membership_status: none |
The studio switched the member's account off or removed it. |
| Membership is frozen. | membership_status: frozen |
The member's membership is paused. |
| No active membership found. | The latest membership's status, e.g. expired, cancelled, pending |
No active membership covers today: it has ended, was cancelled, has not started yet, or is still waiting for payment. |
| Access denied. | Varies | Any other refusal, for example the studio's set-up is incomplete. Contact the studio. |
In a verify answer,
membership_statusis the status recorded on the member's latest membership. It can still readactiveon a denial when that membership's end date has passed. Decide onstatusalone. The member list reports the door's own view.
Logging, time-outs and retries
- Every call, granted or denied, is written to the studio's access log with your
device_idand your IP address. Two identical scans make two entries; nothing is merged. - Do not resend a scan that received an answer.
- If no answer arrives (time-out or network fault), keep the door shut and let the member scan again. A request time-out of 3 to 5 seconds suits a door. Open on failure only if the studio asks for that in writing.
An invalid request
A body that fails validation gets HTTP 422 with the field-by-field reasons. Send Accept: application/json so the answer is JSON.
{
"message": "The selected credential type is invalid.",
"errors": {
"credential_type": ["The selected credential type is invalid."]
}
}
The member list
GET /members
Lists the studio's members: everyone who holds, or has held, a membership there. Shop-only customers and removed accounts are not listed. The most recently changed members come first.
| Query | Default | Meaning |
|---|---|---|
page |
1 | The page to read. |
per_page |
50 | Members per page, from 1 to 100. |
status |
none (everyone) | Only members with this membership_status. status=active lists exactly the members the door would let in now. With a filter, total and last_page count the filtered list. |
search |
none | Part of a member's name or e-mail address. |
Request:
GET /api/v1/external/members?status=active&per_page=100&page=1
Response:
{
"data": [
{
"uuid": "9b2f6c1e-3d4a-4f8e-a1b2-7c9d0e1f2a3b",
"access_card_id": "0004512890",
"name": "Mona Al-Ali",
"membership_status": "active",
"updated_at": "2026-10-07T18:22:05+03:00"
}
],
"meta": { "current_page": 1, "last_page": 3, "per_page": 100, "total": 241 }
}
| Field | Meaning |
|---|---|
uuid |
The member's door ID. Every listed member has one, and it does not change. Use it as your key for the member. |
access_card_id |
The access card paired with the member at the front desk, or null if none is paired. |
name |
The member's name. |
membership_status |
The door's view of the member now (see Membership status). |
updated_at |
The last change to the member's account record, in ISO 8601. It is not a reliable signal that a membership started, ended or was paused. |
One member
GET /members/{uuid}
Returns the same fields for one member, plus email and is_active (whether the studio has the account switched on). A uuid that is not on the member list returns HTTP 404:
{ "success": false, "message": "Member not found." }
Paging
Read pages in order from 1 to meta.last_page. A full pass over 250 members takes three requests at 100 per page.
Membership status
| Value | Meaning |
|---|---|
active |
The door lets the member in now. |
pending |
The membership has not started yet, or is waiting for payment. |
expired |
The membership has ended. |
frozen |
The membership is paused. |
cancelled |
The membership was cancelled. |
none |
The studio has switched the member's account off. |
Keeping your copy up to date
- Re-read the whole list on a schedule. A membership can start, end or be paused without
updated_atchanging, so comparingupdated_atalone misses those changes. Every 15 minutes during opening hours suits most doors and costs only a few requests. - Match members by
uuid. The list can change while you read it, so a member may appear on two pages, or move to a page you have already read. Remove a member from your copy only after a complete pass in which they did not appear. - Ask at the door whenever you can. A copy is only as fresh as your last pass. If your device decides from its copy while it is offline, it can let in someone whose membership ended after that pass. Whenever the device is online, the verify call is the answer.
Limits
Hourly limit per key
- Each key may make a set number of requests per hour. The default is 1,000; the studio can set any figure from 10 to 100,000.
- Every device that uses the same key shares the same allowance.
- The hour starts with the key's first request and runs for 60 minutes.
- Ask the studio for a limit above your busiest hour of scans plus your sync requests. For example, 400 scans and four 3-page syncs need about 412 requests.
- The limit is set when the key is made. To change it, the studio issues a new key, which comes with a new secret.
Every response that passes authentication carries these headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed per hour. |
X-RateLimit-Remaining |
Requests left in the current hour. |
X-RateLimit-Reset |
A Unix time one hour after this request. The current hour ends at or before it. |
Over the limit, the answer is HTTP 429 with a Retry-After header in seconds:
{
"success": false,
"error": "rate_limited",
"message": "API rate limit exceeded. Try again later.",
"retry_after": 3600
}
retry_after is always the full hour. The limit may lift sooner, and a request after the hour ends is accepted.
Flood guard
More than 600 requests in one minute from one IP address are refused with HTTP 429 and Retry-After: 60, before the key is checked. No normal door comes close to this.
Errors
Errors use one shape. Act on the HTTP status and error; the message is for people.
{
"success": false,
"error": "key_inactive",
"message": "This API key is inactive or expired."
}
| HTTP | error |
Meaning and what to do |
|---|---|---|
| 401 | missing_credentials |
A credential header is missing. |
| 401 | invalid_api_key |
Unknown key or wrong secret, or the studio has no access control set up. Check both values with the studio. |
| 403 | key_inactive |
The studio revoked the key, or it is past its expiry date. |
| 403 | missing_scope |
The key lacks the permission this call needs. |
| 403 | module_disabled |
The studio's Access Control add-on is not active. The studio must switch it back on. |
| 404 | (none) | A single-member read for a uuid that is not on the member list. |
| 422 | (field errors) | The request body is invalid. |
| 429 | rate_limited |
Over the hourly limit or the flood guard. Wait for Retry-After. |
| 5xx | (none) | A temporary fault. At the door, keep it shut. For a sync, retry after 1, 2, then 4 minutes. |
Examples
Replace <studio address>, the key and the secret with the values the studio sent you.
An entry with a wallet card:
curl -X POST "https://<studio address>/api/v1/external/access/verify" \
-H "X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-API-Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"credential_type":"wallet","credential_id":"<code from the barcode>","action":"entry","device_id":"front-door-01"}'
An exit with an access card, naming the reader in a header:
curl -X POST "https://<studio address>/api/v1/external/access/verify" \
-H "X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-API-Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Device-ID: exit-gate-02" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"credential_type":"card","credential_id":"0004512890","action":"exit"}'
The members the door lets in now, 100 at a time:
curl "https://<studio address>/api/v1/external/members?status=active&per_page=100&page=1" \
-H "X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-API-Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json"
One member:
curl "https://<studio address>/api/v1/external/members/<uuid>" \
-H "X-API-Key: ak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-API-Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Accept: application/json"
A door's decision, in outline:
// Called for every scan at an entry reader.
response = POST /access/verify { credential_type, credential_id, action: "entry", device_id }
with a 3 to 5 second time-out
if no response, or HTTP status is not 200:
keep the door shut // show "please try again" or call staff
else if response.status == "granted":
open the door
else:
keep the door shut // response.message may be shown to the member
Security and personal data
- Use HTTPS only. Never put the key or the secret in a web address, a log file or a support ticket.
- If a secret may have leaked, tell the studio. It can change the secret in seconds, and the old one stops working at once.
- The member list is personal data: names, card numbers and, for single reads, e-mail addresses. Keep only what your device needs, protect it like any personal record, and delete it when the integration ends or the studio asks.
- Do not treat a wallet code as a permanent identity. The studio can revoke or reissue a card, and its code changes when it does. Key your records on the member's
uuid.
Go-live checklist
- You have the base address, the key and the secret, and the secret came separately from the key.
- Each reader sends its own
device_id. - Your scanners read both QR codes and Code 128 barcodes.
- The card-number format is agreed with the studio's front desk, and one test card paired at the desk is granted at your reader.
- Tested with the studio: a member with an active membership is
granted; a frozen and an expired member aredenied; an unknown code isdenied; an exit is recorded in the studio's access log. - The door opens only when
statusisgranted, and stays shut after a time-out or an error. - The hourly limit covers your busiest hour of scans plus syncs.
- If you keep a copy of the member list, it re-reads the whole list on a schedule.
Questions about a studio's set-up, keys or members go to that studio. Questions about this API go to developers@eyalla.com.