Skip to documentation

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

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.

Success
{
  "message": "OK",
  "data": { ... }
}
Error
{
  "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:

Business key headers
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"

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:

Partner key headers
# 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.

HTTP status codes
StatusMeaning
200 / 201 / 204OK, created, or done with nothing to return.
400Bad request: the body isn't valid JSON, or a query parameter is missing or malformed.
401No key was sent, or the key is wrong or revoked.
403Forbidden: the caller is known but isn't allowed to do this (for example, a staff account without the role to manage keys).
404The thing asked for doesn't exist here, such as a card number with no active card, or an unknown user.
409Conflict with what already exists, such as a card that is already given to someone.
422The 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.
429Too many requests. Slow down and retry later (see Rate limits).
5xxSomething 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 5xx responses and network errors the same way. Don't retry other 4xx responses unchanged: they will fail again.
  • Check-ins are safe to retry. If POST /access/check lets the same member in again within 2 minutes, it returns the same check_in_id instead 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 #

GET /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"

Response

200 OK
{
  "message": "OK",
  "data": {
    "business_id": "8f1d3c2a-5b7e-4f0a-9c61-2d4e8a7b9c10",
    "key_id": "3a9b7c5d-1e2f-4a6b-8c0d-9e8f7a6b5c4d"
  }
}

Check a member in #

POST /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

POST /access/check body
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"}'

Response fields

Access decision 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

200 OK · allowed
{
  "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"
  }
}
200 OK · refused
{
  "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

  • 400 The body isn't valid JSON.
  • 401 Missing, wrong or revoked key.
  • 422 credential is empty, or type is something other than pass or card.
  • 500 Couldn'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.

Access decision reasons
ReasonAllowedMeaningMessage shown
activeYesThe member has a current membership here or at another branch.Welcome in.
membership_expiredNoTheir latest membership here has ended.Membership has ended. Renew in the Kulawise app.
no_membershipNoA real Kulawise member, but with no membership here.No membership here.
unknown_credentialNoNot a Kulawise pass, or a card number not registered here.This isn't a Kulawise pass. / This card isn't registered here.
pass_expiredNoA rotating pass whose one-time code is too old.This code has expired. Open your pass again.
app_update_requiredNoThe old fixed pass QR, which doors don't accept.Update the Kulawise app to use your pass at this door.

List members (offline cache) #

GET /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"

Response fields

Member list 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.
200 OK
{
  "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 #

GET /api/v1/access/cards

Every active access card given out here.

Access card fields
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.
200 OK
{
  "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 #

POST /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

POST /access/cards body
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"}'
201 Created
{
  "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

  • 409 That card is already given to someone here. Revoke it first.
  • 422 card_uid isn't hex, or no member here matches the details.

Revoke a card #

DELETE /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.

DELETE /access/cards path parameters
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"

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:

  1. Scan. Read the member's pass QR or card from the reader.
  2. Check. Send it to POST /access/check with a short timeout (2 to 3 seconds).
  3. Open. If allowed is true, pulse the relay.
  4. Tell them. Show message on 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.");
}

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 2xx within 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 as X-Kulawise-Delivery) to skip ones you've seen.
  • Other events you can subscribe to: order.paid, ticket.admitted and member.added.

Every delivery

Webhook envelope fields
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.
Request headers
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.

member.checked_in data fields
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.
member.checked_in
{
  "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>".

  1. Read the raw body before parsing it as JSON.
  2. Compute the HMAC and compare it to v1 in constant time.
  3. 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);
// });

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
Send request email →

Then authenticate with your partner key as described in Partner keys.

Fetch user health metrics #

GET /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

GET /partners/health-metrics 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

200 OK
{
  "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

  • 400 Neither email nor username was given, or date isn't YYYY-MM-DD.
  • 401 The partner key is missing, invalid or inactive.
  • 404 No 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"

Resources

Changelog #

  1. 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_in webhooks carry source: staff, self or api.