Gymora API, as shipped in the download
Gymora REST API & Webhooks (v2.0)
Gymora ships a JSON REST API and signed webhooks so door systems, kiosks, class-booking sites and your own scripts can check members in, read arrears and record payments — using exactly the same rules the front desk uses.
Authentication
Create a key in Settings → API & Webhooks. A key belongs to a user and can do exactly what that user can do in the app. Send it on every call:
Authorization: Bearer apk_...
The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.
Endpoints
| Method & path | What it does |
|---|---|
GET /api/ping | Verify a key |
GET /api/members | Members with their current membership; ?q= searches name/email/phone |
POST /api/members | Create a member (name required; email, phone, join_date, notes) |
GET /api/members/{id} | One member + membership, the check-in guard, recent payments and check-ins |
POST /api/members/{id}/payments | Record a payment (amount or amount_cents; optional due_date, method, paid) |
POST /api/checkins | Check a member in (member_id, optional class_name) |
GET /api/arrears | Overdue payments with the total, as of today |
GET /api/openapi.json | OpenAPI 3 spec (no auth) |
Check a member in
curl -X POST https://your-install/api/checkins \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"member_id": 12, "class_name": "Vinyasa 18:00"}'
201 → {"checkin": {...}, "credits_remaining": 7}
403 → {"error": "forbidden", "detail": "Membership is frozen. Unfreeze it before checking in."}
The API is not a second implementation. Every check-in runs Membership::checkInGuard() — the same function behind the desk screen — so a frozen, cancelled, expired or credit-less membership is refused identically, and detail carries the same sentence a receptionist sees.
Class credits are burned atomically. A credit-bearing pass decrements with a guarded UPDATE ... WHERE credits_remaining > 0; if two requests race for the last credit, exactly one wins and the other gets a 403. Two turnstiles cannot both spend it.
Webhooks
Add receiver URLs in Settings → API & Webhooks. Events:
member.checked_in— includes the member, the check-in andcredits_remainingcheckin.denied— includes the member and the reason the guard refusedpayment.recorded— any payment lands
Each delivery is an HTTP POST with a JSON body and:
X-Gymora-Event: member.checked_in
X-Gymora-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the endpoint's secret>
Verify the signature before trusting a payload. Delivery is one attempt with a 5-second timeout — deliberately simple; the Recent deliveries log on the settings page shows every attempt and response code. Design receivers to be idempotent.
Both front doors fire the same events: a check-in at the desk and a check-in over the API are indistinguishable to your receiver.
Kiosk and turnstile integrations
checkin.denied carrying a human-readable reason is the point: a door controller can show "Membership expired on 2026-07-31 — renew to continue" on its own screen without re-implementing any membership rules.
Limits, honestly
- The API acts at user level; there is no separate scope system beyond the app's roles.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- List endpoints cap at 200 rows; this is a gym tool, not a data warehouse.
- Membership expiry is computed on view — there is no scheduler, so there is no "membership expired" webhook.
GET /api/membersreportseffective_statuslive.
MCP — the agent endpoint (new in 3.0)
Gymora speaks MCP (Model Context Protocol) on one route, so an assistant can read the register and work the desk without anyone building a bridge first.
POST /mcp
Authorization: Bearer apk_... ← the SAME revocable key the REST API uses
Content-Type: application/json
Stateless streamable-HTTP: one JSON-RPC message per request, no session, no SSE. A GET gets 405 with Allow: POST, as the transport requires.
Connect it to an assistant
Mint the key in the app first: Settings → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.
Every client needs the same three facts, and nothing in the handshake is vendor-specific:
| The address | https://your-install/mcp |
| The key | header Authorization: Bearer apk_xxxx |
| The transport | MCP over streamable HTTP, stateless |
Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:
claude mcp add --transport http gymora https://your-install/mcp \
--header "Authorization: Bearer apk_xxxx"
ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):
{
"type": "mcp",
"server_label": "gymora",
"server_url": "https://your-install/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "gymora", "name": "Gymora", "url": "https://your-install/mcp",
"token": "apk_xxxx", "enabled": true }
]
}
**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.
A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:
curl -X POST https://your-install/mcp \
-H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Gymora implements the protocol, not an integration with a particular vendor.
The tools
| Tool | Writes? | What it does |
|---|---|---|
list_members | no | The register with each member's effective membership status and whether they may check in right now — the desk's own verdict, not a second opinion. |
member_detail | no | One member: membership, credits, recent visits, payments, arrears. |
check_in | YES | Records attendance and, on a class pack, spends one credit. |
memberships_expiring | no | Memberships ending within N days. |
revenue_report | no | Collected in a month plus outstanding arrears, in integer cents. |
What the endpoint refuses, and why
check_incannot overspend a pass. It calls the sameperform_checkin()the front desk, the REST API and the kiosk call — one atomic credit burn, product-wide. Twelve concurrent agent check-ins against six credits yield exactly six visits and six refusals; credits never go negative.- Arguments must be single values. Anything nested is rejected — the same guard
Api::body()applies to the REST API. - Unknown arguments are refused by name. An agent that typos
stautsis told so, rather than silently getting unfiltered results. - Out-of-enum values are refused, with the valid options listed.
- Roles apply exactly as they do in the browser. A key belonging to a viewer is refused
check_in— a different answer for the same role would make the agent path a privilege hole.
Every MCP write lands in the audit trail beside the browser and API writes, naming the key's user as the actor and recording via: mcp.
Example
curl -X POST https://your-install/mcp \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"check_in","arguments":{"member_id":42}}}'