Kulawise developers
Kulawise API reference
Let members through your doors with the Access API, react to check-ins with webhooks, and read consenting users' health metrics with the Partner API.
Overview #
Kulawise has two sets of platform APIs:
- The Access API is for gyms and studios. Your door controller, turnstile or front-desk app sends what a reader scanned (a member's pass QR or an access card) and gets back a yes or no, with a message to show the member. Allowed members are checked in automatically.
- The Partner API is for apps and games that, with a user's consent, read that user's daily health metrics: steps, calories, distance, water, gym check-ins and points.
Base URL
https://api.kulawise.com/api/v1
Every request goes over HTTPS. Requests and responses are JSON:
send Content-Type: application/json with a body. Timestamps are RFC 3339 in UTC, and
IDs are UUIDs.
Response envelope
Access API responses wrap their result in a {message, data} envelope. Errors
have a single error string that is written to be shown to a person.
{
"message": "OK",
"data": { ... }
} {
"error": "card_uid must be the card's number as hex (e.g. 04A2B3C4D5)"
} The Partner health metrics endpoint predates the envelope and returns its object directly. See Health metrics.
Authentication #
Each API has its own kind of key. A key identifies who is calling, so there is no separate account or business ID to send.
Business API keys #
The Access API uses business keys. They start with kwb_live_. An owner or manager
makes them in the Kulawise business dashboard under Developers; give each one a
name like "Front door" so you can tell them apart. The full key is shown once,
when it is made. Kulawise keeps only a hash, so copy it then. A business can have up to 20 active
keys.
Send the key in either header:
| Header | Type | Required | Description |
|---|---|---|---|
Authorization | string | One of | Bearer kwb_live_… |
X-Kulawise-Key | string | One of | The key on its own. Checked first if both are sent. |
curl https://api.kulawise.com/api/v1/access/me \
-H "Authorization: Bearer kwb_live_YOUR_KEY"
# or
curl https://api.kulawise.com/api/v1/access/me \
-H "X-Kulawise-Key: kwb_live_YOUR_KEY" const res = await fetch("https://api.kulawise.com/api/v1/access/me", {
headers: { Authorization: `Bearer ${process.env.KULAWISE_KEY}` },
});
const { data } = await res.json();
console.log(data.business_id); import os, requests
res = requests.get(
"https://api.kulawise.com/api/v1/access/me",
headers={"Authorization": f"Bearer {os.environ['KULAWISE_KEY']}"},
timeout=10,
)
print(res.json()["data"]["business_id"])
A key belongs to one business (one branch) and everything it does is scoped there. Revoking a
key in the dashboard takes effect instantly: the next request with it gets
401.
Partner keys #
The Partner API uses partner keys, which we provision for you (see
Request access). Upon approval, you will receive a production API
key (e.g. kula_...). Pass this key in the request headers on every call:
# Recommended header:
X-Partner-Key: kula_YOUR_SECRET_KEY
# Or via Authorization Bearer:
Authorization: Bearer kula_YOUR_SECRET_KEY Keeping keys safe #
- Keep keys on servers and door controllers. Never put one in a mobile app, a web page or a public repository.
- Load keys from an environment variable or secret store, not from source code.
- Use one key per device or integration, so you can revoke one without touching the others.
- If a key may have leaked, revoke it straight away and make a new one. The
kwb_live_prefix makes leaked keys easy for secret scanners to spot.
Errors #
Kulawise uses ordinary HTTP status codes. A 2xx means it worked, a 4xx
means something about the request needs changing, and a 5xx means something went
wrong on our side. Error bodies look like {"error": "…"}.
A door saying no is not an error: POST /access/check answers
200 with "allowed": false and a reason.
| Status | Meaning |
|---|---|
200 / 201 / 204 | OK, created, or done with nothing to return. |
400 | Bad request: the body isn't valid JSON, or a query parameter is missing or malformed. |
401 | No key was sent, or the key is wrong or revoked. |
403 | Forbidden: the caller is known but isn't allowed to do this (for example, a staff account without the role to manage keys). |
404 | The thing asked for doesn't exist here, such as a card number with no active card, or an unknown user. |
409 | Conflict with what already exists, such as a card that is already given to someone. |
422 | The JSON is fine but a value isn't: a missing credential, a card number that isn't hex, or a member who can't be found. |
429 | Too many requests. Slow down and retry later (see Rate limits). |
5xx | Something failed on our side, or we couldn't check the key just then (503). Safe to retry with backoff. |
Rate limits & retries #
Requests are rate limited to protect the platform. Normal use, such as a turnstile checking each member as they arrive, stays well within the limits. The exact limits may change, so don't hard-code them.
- On
429, back off before retrying: wait, then retry with exponentially growing delays (for example 1s, 2s, 4s) plus a little random jitter. - Retry
5xxresponses and network errors the same way. Don't retry other4xxresponses unchanged: they will fail again. - Check-ins are safe to retry. If
POST /access/checklets the same member in again within 2 minutes, it returns the samecheck_in_idinstead of recording a second check-in. A turnstile that retries, or asks twice for one walk-in, can't double-count. - Refresh the member list every few minutes, not on every scan.
Access API
Concepts #
A door has a reader (a QR scanner, an RFID reader, or both) and a relay that opens it. Your
software sits in between: it sends what the reader saw to Kulawise and opens the relay if the
answer is allowed.
Member pass QR #
Every member has a pass in the Kulawise app. At doors it is a rotating QR: the
one-time part of it changes every 30 seconds, so a screenshot stops working almost at once. The
QR text looks like kulawise:pass:<code>:<8-digit code>. Send it exactly
as scanned; you never need to parse it.
Older app versions showed a fixed QR with no one-time part. Doors refuse it with reason
app_update_required, since a door can't tell a fixed QR from a photo of one. A code
that is too old (a screenshot, or a phone clock far off) gets pass_expired.
RFID access cards #
You can also give members RFID/NFC cards or fobs. A card is identified by its UID, as hex. Readers
format it differently (04:a2:b3:c4, 04-A2-B3-C4, 04a2b3c4);
Kulawise removes colons, dashes and spaces and upper-cases it, so all of those are
04A2B3C4. A UID is 4 to 32 hex characters. A card can only be given to someone who is
already your member or customer, and one card number can be active for one person at a time.
Branches #
A key belongs to one branch, but memberships and cards work across the business's branches. A
member of your main location is let in at your other branches too. When the membership that
decided it is from another branch, the decision's membership.business names that
branch.
Test your key #
/api/v1/access/me Returns the business and key behind the request. Use it to check a new key or a device's connection.
curl https://api.kulawise.com/api/v1/access/me \
-H "Authorization: Bearer kwb_live_YOUR_KEY"
# or
curl https://api.kulawise.com/api/v1/access/me \
-H "X-Kulawise-Key: kwb_live_YOUR_KEY" const res = await fetch("https://api.kulawise.com/api/v1/access/me", {
headers: { Authorization: `Bearer ${process.env.KULAWISE_KEY}` },
});
const { data } = await res.json();
console.log(data.business_id); import os, requests
res = requests.get(
"https://api.kulawise.com/api/v1/access/me",
headers={"Authorization": f"Bearer {os.environ['KULAWISE_KEY']}"},
timeout=10,
)
print(res.json()["data"]["business_id"]) Response
{
"message": "OK",
"data": {
"business_id": "8f1d3c2a-5b7e-4f0a-9c61-2d4e8a7b9c10",
"key_id": "3a9b7c5d-1e2f-4a6b-8c0d-9e8f7a6b5c4d"
}
} Check a member in #
/api/v1/access/check
Send what the reader saw. The answer to act on is allowed: open, or don't. When a
member is allowed in they are checked in, once per 2 minutes (see
retries). A refusal is still a 200; read
reason to know why.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
credential | string | Required | The scanned pass QR text, or an access card's number. |
type | string | Optional | pass or card, when the reader knows. Leave it out and Kulawise works it out: text starting kulawise:pass: is a pass, anything else a card. |
check_in | boolean | Optional | Record a check-in when allowed. Defaults to true. Send false to only ask. |
curl -X POST https://api.kulawise.com/api/v1/access/check \
-H "Authorization: Bearer kwb_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"credential": "kulawise:pass:Q7K2M9:48213907"}' const res = await fetch("https://api.kulawise.com/api/v1/access/check", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KULAWISE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ credential: scanned }), // QR text or card number
});
const { data } = await res.json();
if (data.allowed) openRelay();
showOnScreen(data.message); res = requests.post(
"https://api.kulawise.com/api/v1/access/check",
headers={"Authorization": f"Bearer {KEY}"},
json={"credential": scanned}, # QR text or card number
timeout=5,
)
data = res.json()["data"]
if data["allowed"]:
open_relay()
show_on_screen(data["message"]) Response fields
| Field | Type | Required | Description |
|---|---|---|---|
allowed | boolean | Always | Open the door when true. |
reason | string | Always | Why. See Decision reasons. |
message | string | Always | A short message to show the member on the door's screen. |
credential_type | string | Always | pass or card. |
member | object | If known | Who presented it: id, first_name, last_name, username, profile_image (each name and the image may be null). Missing when the credential wasn't recognised. |
membership | object | If any | The membership that decided it: name, period (e.g. monthly, left out if none), ends_at, and business (only when it is from another branch). |
check_in_id | uuid | If checked in | The check-in recorded. The same ID comes back for a repeat within 2 minutes. |
checked_in_at | timestamp | If checked in | When that check-in was recorded. |
Example responses
{
"message": "OK",
"data": {
"allowed": true,
"reason": "active",
"message": "Welcome in.",
"credential_type": "pass",
"member": {
"id": "c2b8e4f1-7a3d-4e9b-b5c6-0f1e2d3c4b5a",
"first_name": "Ada",
"last_name": "Okafor",
"username": "ada",
"profile_image": "https://cdn.kulawise.com/u/ada.jpg"
},
"membership": {
"name": "Unlimited",
"period": "monthly",
"ends_at": "2026-11-02T09:00:00Z"
},
"check_in_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"checked_in_at": "2026-10-11T07:42:18Z"
}
} {
"message": "OK",
"data": {
"allowed": false,
"reason": "membership_expired",
"message": "Membership has ended. Renew in the Kulawise app.",
"credential_type": "card",
"member": {
"id": "c2b8e4f1-7a3d-4e9b-b5c6-0f1e2d3c4b5a",
"first_name": "Ada",
"last_name": "Okafor",
"username": "ada",
"profile_image": null
},
"membership": {
"name": "Unlimited",
"period": "monthly",
"ends_at": "2026-09-30T09:00:00Z",
"business": "Ade Fit Club Lekki"
}
}
} Errors
400The body isn't valid JSON.401Missing, wrong or revoked key.422credentialis empty, ortypeis something other thanpassorcard.500Couldn't check just then. Retry, or fall back to your cached list.
Decision reasons #
reason is always one of these. Show message to the member; use
reason in your own logic.
| Reason | Allowed | Meaning | Message shown |
|---|---|---|---|
active | Yes | The member has a current membership here or at another branch. | Welcome in. |
membership_expired | No | Their latest membership here has ended. | Membership has ended. Renew in the Kulawise app. |
no_membership | No | A real Kulawise member, but with no membership here. | No membership here. |
unknown_credential | No | Not a Kulawise pass, or a card number not registered here. | This isn't a Kulawise pass. / This card isn't registered here. |
pass_expired | No | A rotating pass whose one-time code is too old. | This code has expired. Open your pass again. |
app_update_required | No | The old fixed pass QR, which doors don't accept. | Update the Kulawise app to use your pass at this door. |
List members (offline cache) #
/api/v1/access/members Everyone whose membership lets them in here right now, with their access card numbers. Cache it on the door so it keeps working through an internet outage, and refresh it every few minutes. The list has no pass secrets, so offline you can match cards only; rotating passes need the API.
curl https://api.kulawise.com/api/v1/access/members \
-H "Authorization: Bearer kwb_live_YOUR_KEY" const res = await fetch("https://api.kulawise.com/api/v1/access/members", {
headers: { Authorization: `Bearer ${process.env.KULAWISE_KEY}` },
});
const { data: members } = await res.json(); members = requests.get(
"https://api.kulawise.com/api/v1/access/members",
headers={"Authorization": f"Bearer {KEY}"},
timeout=10,
).json()["data"] Response fields
| Field | Type | Required | Description |
|---|---|---|---|
id | uuid | Always | The member's Kulawise user ID. |
first_name | string | null | Always | First name. |
last_name | string | null | Always | Last name. |
username | string | null | Always | Kulawise username. |
ends_at | timestamp | Always | When their membership ends. Stop letting them in after this, even offline. |
card_uids | string[] | Always | Their active access card numbers, normalised. Empty if they have none. |
{
"message": "OK",
"data": [
{
"id": "c2b8e4f1-7a3d-4e9b-b5c6-0f1e2d3c4b5a",
"first_name": "Ada",
"last_name": "Okafor",
"username": "ada",
"ends_at": "2026-11-02T09:00:00Z",
"card_uids": ["04A2B3C4D5E6F7"]
},
{
"id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"first_name": "Tunde",
"last_name": null,
"username": "tunde",
"ends_at": "2026-10-20T09:00:00Z",
"card_uids": []
}
]
} List cards #
/api/v1/access/cards Every active access card given out here.
| Field | Type | Required | Description |
|---|---|---|---|
id | uuid | Always | The card record's ID. |
card_uid | string | Always | The card number, normalised to upper-case hex. |
label | string | null | Always | Your label for it, like Blue fob. |
user_id | uuid | Always | The member it belongs to. |
first_name | string | If set | The member's first name. |
last_name | string | If set | The member's last name. |
username | string | If set | The member's Kulawise username. |
email | string | If set | The member's email. |
created_at | timestamp | Always | When the card was given. |
{
"message": "OK",
"data": [
{
"id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"card_uid": "04A2B3C4D5E6F7",
"label": "Blue fob",
"user_id": "c2b8e4f1-7a3d-4e9b-b5c6-0f1e2d3c4b5a",
"first_name": "Ada",
"last_name": "Okafor",
"username": "ada",
"email": "ada@example.com",
"created_at": "2026-10-01T10:15:00Z"
}
]
} Give a card #
/api/v1/access/cards
Links a card to a member of this business. Identify the member by one of user_id,
email or phone. They must already be your member or customer.
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
card_uid | string | Required | The card number as the reader sends it. Separators are removed and it is upper-cased. |
label | string | Optional | A name for the card, like Blue fob. |
user_id | uuid | One of | The member's Kulawise user ID (from /access/members or a decision). |
email | string | One of | The member's email. |
phone | string | One of | The member's phone number. |
curl -X POST https://api.kulawise.com/api/v1/access/cards \
-H "Authorization: Bearer kwb_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"card_uid": "04:a2:b3:c4:d5:e6:f7", "label": "Blue fob", "email": "ada@example.com"}' const res = await fetch("https://api.kulawise.com/api/v1/access/cards", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KULAWISE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
card_uid: "04:a2:b3:c4:d5:e6:f7",
label: "Blue fob",
email: "ada@example.com",
}),
});
if (res.status === 409) console.log("That card is already given to someone"); res = requests.post(
"https://api.kulawise.com/api/v1/access/cards",
headers={"Authorization": f"Bearer {KEY}"},
json={"card_uid": "04:a2:b3:c4:d5:e6:f7", "label": "Blue fob", "email": "ada@example.com"},
timeout=10,
)
card = res.json()["data"] {
"message": "Card linked",
"data": {
"id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"card_uid": "04A2B3C4D5E6F7",
"label": "Blue fob",
"user_id": "c2b8e4f1-7a3d-4e9b-b5c6-0f1e2d3c4b5a",
"created_at": "2026-10-11T08:01:12Z"
}
} Errors
409That card is already given to someone here. Revoke it first.422card_uidisn't hex, or no member here matches the details.
Revoke a card #
/api/v1/access/cards/{uid} Stops a card opening the door, for example when it is lost or handed back. The card number can be in any of the formats readers use.
| Name | Type | Required | Description |
|---|---|---|---|
uid | string | Required | The card number (path parameter). |
curl -X DELETE https://api.kulawise.com/api/v1/access/cards/04A2B3C4D5E6F7 \
-H "Authorization: Bearer kwb_live_YOUR_KEY" const res = await fetch("https://api.kulawise.com/api/v1/access/cards/04A2B3C4D5E6F7", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.KULAWISE_KEY}` },
});
// 204 No Content on success, 404 if no active card has that number res = requests.delete(
"https://api.kulawise.com/api/v1/access/cards/04A2B3C4D5E6F7",
headers={"Authorization": f"Bearer {KEY}"},
timeout=10,
)
assert res.status_code == 204 Returns 204 No Content. 404 if no active card here has that number; 422 if it isn't hex.
Guide: connect a turnstile #
A complete door loop takes four steps:
- Scan. Read the member's pass QR or card from the reader.
- Check. Send it to
POST /access/checkwith a short timeout (2 to 3 seconds). - Open. If
allowedistrue, pulse the relay. - Tell them. Show
messageon the door's screen either way.
For outages, keep a copy of GET /access/members, refreshed every few minutes. If the
check can't reach Kulawise, match a card number against that list and let in members whose
ends_at hasn't passed. Offline entries aren't recorded as check-ins.
const BASE = "https://api.kulawise.com/api/v1";
const KEY = process.env.KULAWISE_KEY;
let cache = new Map(); // card UID -> member, for when the network drops
async function refreshCache() {
try {
const res = await fetch(`${BASE}/access/members`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) return; // keep the last good list
const { data } = await res.json();
const next = new Map();
for (const m of data) for (const uid of m.card_uids) next.set(uid, m);
cache = next;
} catch { /* offline: keep the last good list */ }
}
refreshCache();
setInterval(refreshCache, 5 * 60 * 1000);
// Call this with whatever the reader saw: QR text or card number.
async function onScan(credential) {
try {
const res = await fetch(`${BASE}/access/check`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ credential }),
signal: AbortSignal.timeout(3000),
});
if (res.ok) {
const { data } = await res.json();
if (data.allowed) openRelay();
return showOnScreen(data.message);
}
if (res.status < 500 && res.status !== 429) {
const { error } = await res.json();
return showOnScreen(error);
}
} catch { /* network down or timed out: fall through */ }
// Offline fallback: only access cards can be matched locally.
const uid = credential.replace(/[:\-\s]/g, "").toUpperCase();
const m = cache.get(uid);
if (m && new Date(m.ends_at) > new Date()) {
openRelay();
return showOnScreen("Welcome in.");
}
showOnScreen("Can't check right now. Please see the front desk.");
} import os, re, time, threading
from datetime import datetime, timezone
import requests
BASE = "https://api.kulawise.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['KULAWISE_KEY']}"}
cache = {} # card UID -> member, for when the network drops
def refresh_cache():
global cache
while True:
try:
res = requests.get(f"{BASE}/access/members", headers=HEADERS, timeout=10)
if res.ok:
cache = {uid: m for m in res.json()["data"] for uid in m["card_uids"]}
except requests.RequestException:
pass # offline: keep the last good list
time.sleep(300)
threading.Thread(target=refresh_cache, daemon=True).start()
def on_scan(credential: str):
try:
res = requests.post(f"{BASE}/access/check", headers=HEADERS,
json={"credential": credential}, timeout=3)
if res.ok:
data = res.json()["data"]
if data["allowed"]:
open_relay()
return show_on_screen(data["message"])
if res.status_code < 500 and res.status_code != 429:
return show_on_screen(res.json()["error"])
except requests.RequestException:
pass # network down or timed out: fall through
# Offline fallback: only access cards can be matched locally.
uid = re.sub(r"[:\-\s]", "", credential).upper()
m = cache.get(uid)
if m and datetime.fromisoformat(m["ends_at"].replace("Z", "+00:00")) > datetime.now(timezone.utc):
open_relay()
return show_on_screen("Welcome in.")
show_on_screen("Can't check right now. Please see the front desk.") Webhooks
Webhooks #
Webhooks tell your server when something happens, as a signed JSON POST. Add an
endpoint in the business dashboard and choose its events. You get a signing secret
(whsec_…) once, when it is made. A business can have up to 5 endpoints.
- Endpoints must be public
https://URLs. Redirects aren't followed, so register the final URL. - Answer with any
2xxwithin 10 seconds. Anything else is retried with backoff, up to 10 times over about a day. Endpoints that keep failing may be switched off. - Deliveries can arrive more than once or out of order. Use the
id(also sent asX-Kulawise-Delivery) to skip ones you've seen. - Other events you can subscribe to:
order.paid,ticket.admittedandmember.added.
Every delivery
| Field | Type | Required | Description |
|---|---|---|---|
id | uuid | Always | This delivery's ID. |
event | string | Always | The event name, e.g. member.checked_in. |
business_id | uuid | Always | The business it happened at. |
created_at | timestamp | Always | When the event happened. |
data | object | Always | The event's own data. |
POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Kulawise-Webhooks/1
X-Kulawise-Event: member.checked_in
X-Kulawise-Delivery: 0b6c2f5e-8d4a-4b1e-9f3c-7a2d5e8b1c4f
X-Kulawise-Signature: t=1791704538,v1=5f8e0c3a... member.checked_in #
Sent when a member is checked in: at a door through the Access API, by staff scanning their pass
at the desk, or by the member checking themselves in. A repeat /access/check within 2
minutes doesn't send another.
| Field | Type | Required | Description |
|---|---|---|---|
customer | object | Always | id, first_name, last_name, phone, email. Names and phone are empty strings when not set. |
membership_name | string | Always | The membership they came in on. |
membership_period | string | Always | Its billing period, e.g. monthly. |
ends_at | timestamp | Always | When that membership ends. |
source | string | Always | How they checked in: staff (a desk scan), self (the member scanned the entrance code) or api (a door through the Access API). |
self | boolean | Self check-in | true for self check-ins. Kept for older integrations; use source. |
{
"id": "0b6c2f5e-8d4a-4b1e-9f3c-7a2d5e8b1c4f",
"event": "member.checked_in",
"business_id": "8f1d3c2a-5b7e-4f0a-9c61-2d4e8a7b9c10",
"created_at": "2026-10-11T07:42:18Z",
"data": {
"customer": {
"id": "c2b8e4f1-7a3d-4e9b-b5c6-0f1e2d3c4b5a",
"first_name": "Ada",
"last_name": "Okafor",
"phone": "+2348012345678",
"email": "ada@example.com"
},
"membership_name": "Unlimited",
"membership_period": "monthly",
"ends_at": "2026-11-02T09:00:00Z",
"source": "api"
}
} Verifying signatures #
Every delivery has an X-Kulawise-Signature header of the form
t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256, keyed with
your endpoint's secret, of the timestamp, a full stop, and the raw request body:
"<t>.<body>".
- Read the raw body before parsing it as JSON.
- Compute the HMAC and compare it to
v1in constant time. - Reject timestamps more than a few minutes old, to stop replays.
import crypto from "node:crypto";
// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyKulawise(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: app.post("/hooks/kulawise", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyKulawise(req.body, req.get("X-Kulawise-Signature"), process.env.KULAWISE_WHSEC))
// return res.sendStatus(400);
// const event = JSON.parse(req.body);
// res.sendStatus(200);
// }); import hmac, hashlib, time
def verify_kulawise(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = parts.get("t", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask:
# @app.post("/hooks/kulawise")
# def hook():
# if not verify_kulawise(request.get_data(), request.headers["X-Kulawise-Signature"], WHSEC):
# abort(400)
# event = request.get_json()
# return "", 200 Partner API
Request partner access #
To keep partner onboarding frictionless and eliminate dashboard bloat, we provision your API keys directly. Send a quick email to our engineering team at hello@kulawise.com.
Please include in your message:
- Your application name
- Website
- Information on what you do and how Kulawise would be used by your users
Then authenticate with your partner key as described in Partner keys.
Fetch user health metrics #
/api/v1/partners/health-metrics With your user's consent, query their aggregated daily health data by supplying either their Kulawise account email or username (especially convenient if the user signed up via Apple Sign-In).
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Optional* | The user's registered Kulawise email address. |
username | string | Optional* | The user's Kulawise username (e.g. alex). Ideal for Apple Sign-In accounts. |
date | string | Optional | Date in YYYY-MM-DD format. Defaults to today's date. |
*At least one of email or username must be provided in the request query.
Sample JSON response
{
"email": "user@example.com",
"username": "alex",
"user_name": "Alex Chen",
"date": "2026-10-06",
"metrics": {
"steps": 8450,
"calories_burned": 420,
"distance_meters": 6200,
"water_liters": 2.5,
"calories_consumed": 1850,
"gym_checkin": {
"is_checked_in": true,
"checkin_time": "2026-10-06T08:30:00Z"
},
"points": 1450
},
"synced_at": "2026-10-06T02:40:00Z"
} gym_checkin.checkin_time and checkout_time appear only when there is a
check-in or check-out that day.
Errors
400Neitheremailnorusernamewas given, ordateisn'tYYYY-MM-DD.401The partner key is missing, invalid or inactive.404No Kulawise user has that email or username.
Code examples #
# Query by username:
curl -X GET "https://api.kulawise.com/api/v1/partners/health-metrics?username=alex" \
-H "X-Partner-Key: kula_YOUR_SECRET_KEY"
# Or query by email:
curl -X GET "https://api.kulawise.com/api/v1/partners/health-metrics?email=user@example.com" \
-H "X-Partner-Key: kula_YOUR_SECRET_KEY" async function getKulawiseMetrics({ email, username, date }) {
const params = new URLSearchParams();
if (username) params.append("username", username);
else if (email) params.append("email", email);
if (date) params.append("date", date);
const response = await fetch(
`https://api.kulawise.com/api/v1/partners/health-metrics?${params.toString()}`,
{
headers: {
"X-Partner-Key": process.env.KULAWISE_API_KEY,
},
}
);
if (!response.ok) {
if (response.status === 404) return null; // User not found
throw new Error(`Kulawise API error: ${response.statusText}`);
}
const data = await response.json();
return data.metrics;
} import os
import requests
def get_kulawise_metrics(username: str = None, email: str = None):
api_key = os.getenv("KULAWISE_API_KEY")
url = "https://api.kulawise.com/api/v1/partners/health-metrics"
headers = {
"X-Partner-Key": api_key
}
params = {}
if username:
params["username"] = username
elif email:
params["email"] = email
response = requests.get(url, headers=headers, params=params)
if response.status_code == 404:
return None # User not found
response.raise_for_status()
return response.json()["metrics"] package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
type MetricsResponse struct {
Email string `json:"email"`
Username *string `json:"username,omitempty"`
UserName string `json:"user_name"`
Date string `json:"date"`
Metrics map[string]any `json:"metrics"`
SyncedAt string `json:"synced_at"`
}
func GetKulawiseMetrics(identifier, queryType string) (*MetricsResponse, error) {
qParam := "username"
if queryType == "email" {
qParam = "email"
}
reqURL := fmt.Sprintf("https://api.kulawise.com/api/v1/partners/health-metrics?%s=%s", qParam, url.QueryEscape(identifier))
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Partner-Key", os.Getenv("KULAWISE_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // User not found
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error: status %d", resp.StatusCode)
}
var data MetricsResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
return &data, nil
} <?php
function getKulawiseMetrics(?string $username = null, ?string $email = null): ?array {
$apiKey = getenv('KULAWISE_API_KEY');
$query = $username ? ['username' => $username] : ['email' => $email];
$url = 'https://api.kulawise.com/api/v1/partners/health-metrics?' . http_build_query($query);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'X-Partner-Key: ' . $apiKey,
'Accept: application/json'
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 404) {
return null; // User not found
}
if ($httpCode !== 200) {
throw new Exception("Kulawise API error: status {$httpCode}");
}
$data = json_decode($response, true);
return $data['metrics'] ?? null;
} Resources
Changelog #
-
Access API and rotating passes
- New Access API for doors and turnstiles, with business API keys (
kwb_live_…) made in the dashboard. - RFID/NFC access cards and an offline member list.
- Member passes now rotate every 30 seconds at doors. The old fixed QR is refused with
app_update_required. member.checked_inwebhooks carrysource:staff,selforapi.
- New Access API for doors and turnstiles, with business API keys (