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.
A key acts as the user it belongs to: every write checks that user's role, as the screens do, and answers 403 forbidden when it does not allow the action (3.2; before, any valid key could write). Adding a member, checking someone in and recording a payment need a front-desk or admin key.
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, canceled, 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"
}
**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"*. Pasted as-is, a block written for another client 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}}}'
Connect it to Zapier, Make or n8n
Gymora talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.
Before you start
- In Gymora, open Settings → API & Webhooks. Mint an API key (choose Read only if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example
https://gymora.yourcompany.com.
When something happens in Gymora: send it to another tool
- In the other tool, create the trigger that gives you a URL to receive webhooks: - Zapier: the "Webhooks by Zapier" app, trigger Catch Hook (Webhooks by Zapier needs a paid Zapier plan). - Make: module Webhooks → Custom webhook. - n8n: the Webhook node.
- Copy that URL into Gymora under Settings → API & Webhooks, and choose the event or events below.
- Make the event happen once in Gymora, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
member.checked_in | A member checks in; includes the member and the check-in |
checkin.denied | Includes the member and the reason the guard refused |
payment.recorded | Any payment lands |
Every delivery carries X-Gymora-Signature, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.
When something happens elsewhere: act in Gymora
- Zapier: "Webhooks by Zapier", action POST (or Custom Request).
- Make: module HTTP → Make a request.
- n8n: the HTTP Request node.
Send the request to your install's address plus the path from the Endpoints table above, with the header Authorization: Bearer <your API key> and a JSON body. A read-only key cannot write.
The endpoints that change something:
POST /api/members: Create a member (namerequired;email,phone,join_date,notes)POST /api/members/{id}/payments: Record a payment (amountoramount_cents; optionaldue_date,method,paid)POST /api/checkins: Check a member in (member_id, optionalclass_name)
Example recipes
- member.checked_in → a row in Google Sheets: webhook trigger on
member.checked_in(Includes the member, the check-in and), then the Google Sheets "Create row" action with the fields you want. - checkin.denied → a message in Slack or Teams: webhook trigger on
checkin.denied(Includes the member and the reason the guard refused), then the Slack or Microsoft Teams "Send message" action. - A new form entry or row elsewhere → Gymora: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling
POST /api/members(Create a member (namerequired;email,phone,join_date,notes)).
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Gymora's own automated tests check its API.