# Membora REST API & Webhooks (v2.0)

Membora ships a JSON REST API and signed webhooks so your club's other systems — a website
membership form, a bank-feed reconciliation script, a mailing-list sync — can read the
register and record attendance, using exactly the same rules the secretary 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 also carries a **scope**. A **read-only** key is refused every write at the door, in
one place, rather than in each route — the route that forgets is exactly the one that
would leak.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/members` | Members with their current subscription; `?q=` searches name/email/phone |
| `POST /api/members` | Create a member (`name` required; `email`, `phone`, `join_date`, `notes`) |
| `GET  /api/members/{id}` | One member + subscription, **the voting verdict**, payments, attendance, offices held |
| `POST /api/members/{id}/payments` | Record a payment (`amount` or `amount_cents`; optional `due_date`, `method`, `paid`) |
| `GET  /api/meetings` | Meetings, each with its live quorum state |
| `GET  /api/meetings/{id}` | One meeting with its attendance and its motions, each motion tallied |
| `POST /api/attendance` | Record attendance (`member_id`, `meeting_id`, optional `state`) |
| `GET  /api/arrears` | Overdue subscriptions with the total, as of today |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |

### Record attendance at a meeting

    curl -X POST https://your-install/api/attendance \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"member_id": 12, "meeting_id": 3, "state": "present"}'

    201 → {"attendance": {...}, "quorum": {"present": 7, "required": 8, "quorate": false}}
    409 → {"error": "conflict", "detail": "That meeting was canceled."}

Note what the 201 gives you: **the running quorum**. That is the number a club actually
wants back, and it is why the response is not just an echo of what you sent.

**Standing is not checked here, deliberately.** A member whose subscription has lapsed is
recorded as present, because they were present, and they count toward quorum. Whether
arrears cost them a *vote* is a separate question, answered at the ballot — see
`GET /api/members/{id}`, which returns a `voting` object with `allowed` and a reason.

**One row per member per meeting.** The write upserts against a unique database index, so
recording the same person twice updates the one row rather than adding a second. This is
not tidiness: quorum is counted from these rows, and a duplicate would make a meeting look
quorate when it was not.

**The API is not a second implementation.** Every attendance write runs the same
`record_attendance()` the roll call, the meeting page and the MCP endpoint call, and every
standing question runs the same `Membership::votingGuard()`. There is no second, weaker
path.

## Webhooks

Add receiver URLs in **Settings → API & Webhooks**. Events:

- `meeting.attendance` — the member, the meeting, the state recorded, and the resulting quorum
- `motion.decided` — the motion, its outcome, and **the numbers it was decided on**
  (`present`, `quorum_required`). Fired after the write, so a subscriber can never learn of
  an outcome that was rolled back.
- `attendance.refused` — an attendance that could not be recorded (a canceled or full meeting),
  with the reason
- `payment.recorded` — any payment lands

An endpoint saved before 1.0.3 under the old name `meeting.attended` receives `meeting.attendance`.

Each delivery is an HTTP POST with a JSON body and:

    X-Membora-Event: meeting.attendance
    X-Membora-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.

Every door fires the same events: a mark taken on the roll, one posted over the API and one
made by an agent are indistinguishable to your receiver.

## Why `motion.decided` carries the numbers

Because the decision is only worth as much as the meeting that took it. A receiver that
files minutes, updates a public register or notifies members can state "carried, 9 of 8
present" without asking Membora a second question — and without re-implementing quorum,
which is the rule most likely to be got wrong twice.

## 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 club register, not a data warehouse.
- A subscription *lapsing* is computed on view — there is no scheduler, so there is no
  "subscription lapsed" webhook. `GET /api/members` reports the effective standing live.
- Postal and proxy votes are not modeled. A vote is recorded against the member who cast
  it; a club that runs postal ballots will need to record the outcome as a motion and keep
  the ballot papers elsewhere.

## MCP — the agent endpoint (new in 3.0)

Membora speaks **MCP (Model Context Protocol)** on one route, so an assistant can read the
register and help keep the minutes 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 about the register but never change it — the endpoint then lists only the six read tools
and refuses the rest by name, so a careless prompt cannot record a vote. 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 membora 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": "membora",
  "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:
Membora 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** standing and whether their vote would be recorded — the ballot's own verdict, not a second opinion. |
| `member_detail` | no | One member: subscription, attendance record, offices held, how they voted, payments, arrears. |
| `list_meetings` | no | Meetings with their live quorum state — present against required, and whether the meeting may decide anything. |
| `meeting_detail` | no | One meeting: who was present, who sent apologies, its motions and how each vote fell. |
| `subscriptions_lapsing` | no | Subscriptions ending within N days — the members about to lose their vote. Life and honorary rates never appear. |
| `subscription_report` | no | Collected in a month, outstanding arrears, meetings held, motions decided. Integer cents. |
| `record_attendance` | **YES** | Marks a member present, apologies or expected. Answers with the running quorum. |
| `record_vote` | **YES** | Records how a member voted on an open motion. |

A **read-only key sees only the six read tools.** They are not merely refused — they are
absent from `tools/list`, so the model never proposes them in the first place. Ask for one
by name anyway and the server answers with a sentence explaining that the key is read-only.

### What the endpoint refuses, and why

- **`record_attendance` never refuses on standing.** A member in arrears is recorded as
  present, because they were, and they count toward quorum. If this ever started refusing,
  the register would have stopped being a record of fact.
- **`record_attendance` cannot inflate quorum.** It calls the same `record_attendance()` the
  roll call, the meeting page and the REST API call, upserting against a unique index — so
  an agent and a clerk working the same roll cannot create two rows for one person.
- **`record_vote` refuses a member whose subscription has lapsed, been suspended or
  resigned.** That is the constitution's rule, applied at the ballot rather than at the
  door, and the refusal names the reason and the lapse date so the agent can relay it.
- **`record_vote` refuses a decided motion.** Re-voting one rewrites history instead of
  correcting it; the correction is a new motion, which is also how a committee would do it.
- **A motion cannot be carried at an inquorate meeting.** The refusal states the numbers —
  "4 present, 5 required". Withdrawal is always allowed.
- **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
  `record_attendance` — 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":"record_attendance",
                     "arguments":{"member_id":42,"meeting_id":3,"state":"present"}}}'

    → {"ok":true,"member":"Alicia Moreno","meeting":"Annual General Meeting",
       "state":"present","quorum":{"present":8,"required":8,"quorate":true}}


## Connect it to Zapier, Make or n8n

Membora 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 Membora, 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://membora.yourcompany.com`.

### When something happens in Membora: 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 Membora under **Settings → API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Membora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `meeting.attendance` | The member, the meeting, the state recorded, and the resulting quorum |
| `motion.decided` | The motion, its outcome and the numbers it was decided on, sent after the vote is saved |
| `attendance.refused` | An attendance that could not be recorded (a canceled or full meeting), with the reason |
| `payment.recorded` | Any payment lands |

Every delivery carries `X-Membora-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 Membora

- **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/attendance`: Record attendance (`member_id`, `meeting_id`, optional `state`)

### Example recipes

- **meeting.attendance → a row in Google Sheets:** webhook trigger on `meeting.attendance` (The member, the meeting, the state recorded, and the resulting quorum), then the Google Sheets "Create row" action with the fields you want.
- **motion.decided → a message in Slack or Teams:** webhook trigger on `motion.decided` (The motion, its outcome, and the numbers it was decided on (, ). Fired after the write, so a subscriber can never learn of an outcome…), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Membora:** 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; Membora's own automated tests check its API.
