# 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 and `credits_remaining`
- `checkin.denied` — includes the member and the **reason** the guard refused
- `payment.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/members` reports `effective_status` live.

## 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:

```sh
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):

```json
{
  "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](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(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:

```sh
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_in` cannot overspend a pass.** It calls the same `perform_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 `stauts` is 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

1. 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.
2. Copy that URL into Gymora under **Settings → API & Webhooks**, and choose the event or events below.
3. 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 (`name` required; `email`, `phone`, `join_date`, `notes`)
- `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`)

### 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 (`name` required; `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.
