# Tokora REST API, MCP & Webhooks (v3.0)

Tokora ships a JSON REST API and signed webhooks so the queue can drive the rest of your room:
a self-service kiosk, a second display, a Slack channel, an SMS gateway, or your own scripts.

**Tokora sends no email, on purpose.** The customer is standing in front of you and the display
board is the notification — there is no event worth an inbox. Rather than ship an SMTP settings
block nobody would ever fill in, there is no mailer at all. Webhooks are the integration surface.

## Authentication

Create a key in **API & Webhooks** (in the sidebar). 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.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot issue a ticket (an operator's or admin's can; the public kiosk is unchanged) (`403 forbidden`).

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `POST /api/tokens` | Issue a ticket |
| `POST /api/counters/{id}/call-next` | Call the next waiting customer to a counter |
| `GET  /api/board` | The display-board state — the same payload the wall screen renders; from 3.2 with `lang`, the board's language |
| `GET  /api/report?date=YYYY-MM-DD` | The daily report; from 3.2 with `perCounter` and `perStaff` (called, served, no-shows, average handling and wait, in seconds) |
| `GET  /api/services` · `GET /api/counters` | Reference data |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |

### Issue a ticket

    curl -X POST https://your-install/api/tokens \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"service_id": 1, "note": "wheelchair access"}'

This is the endpoint a custom kiosk calls, and it uses **the app's own numbering**, not a
re-implementation of it. From 3.2 it takes an optional `"lang"` (`en`, `fr`, `de`, `es` or `pl`, one of
the languages offered in Settings → Visitor languages): the visitor's ticket page and texts use it, and
the response carries it as `lang` (`null` means the owner's default). A language that is not offered is
refused with `422`, naming the ones that are.

- Numbers restart every service day, with no cron and no nightly job — the day is part of the
  ticket's identity, so "today's number 1" is simply the first ticket whose `service_day` is today.
- A UNIQUE index across (service, day, number) is what makes two kiosks safe. If two requests
  land on the same number at the same instant, one loses the race and is **retried**, not handed
  a duplicate. Two customers can never be given the same ticket.
- The `label` in the response comes from the same formatter the paper ticket, the display board
  and the counter console use, so nobody can be shown two different versions of their number.

### Call the next customer

    curl -X POST https://your-install/api/counters/3/call-next \
      -H "Authorization: Bearer apk_..."

Mirrors the counter console exactly: it completes whoever that counter was already serving, then
takes the head of the FIFO queue, honoring the counter's service filter if it has one. When
nobody is waiting it returns `200` with `"called": null` and a plain-English `detail` — an empty
queue is a normal state, not an error.

## MCP (Model Context Protocol) — new in 3.0

Tokora speaks MCP over stateless streamable HTTP at **`POST /mcp`**. Authentication is the same
revocable API key as the REST API (`Authorization: Bearer <key>`), and a key carries its owner's
role, so an agent can never do more than the person whose key it holds.

`GET /mcp` answers **405** with `Allow: POST` — deliberately without the key check, so a probing
client is told "right endpoint, wrong verb" rather than a bare 401 that reads as "no MCP here".

```
POST /mcp   {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}
POST /mcp   {"jsonrpc":"2.0","id":2,"method":"tools/list"}
POST /mcp   {"jsonrpc":"2.0","id":3,"method":"tools/call",
             "params":{"name":"call_next","arguments":{"counter_id":1}}}
```

| Tool | Writes? | What it does |
|---|---|---|
| `list_services` | no | The lines a visitor can join, with their token prefix |
| `queue_status` | no | What the wall board shows: now serving per counter, line lengths, next number |
| `issue_ticket` | **yes** | Takes a ticket for a visitor — the kiosk path, so an agent can be the receptionist. Needs an operator's or admin's key, as `POST /api/tokens` does; the public kiosk needs none |
| `call_next` | **yes** | Calls the next waiting ticket to a counter, through the atomic claim |
| `ticket_status` | no | Where a ticket stands, and how many people are ahead |
| `daily_stats` | no | Tickets issued and served, average wait and service time, per service and counter |

**The claim, and why it matters through MCP.** `call_next` does not read-then-write. The write
carries its own precondition, so if two counters — or a counter and an agent — call at the same
instant, exactly one wins the ticket and the other is handed the next waiter. This is the same
function the staff console and the REST API call; there is no second code path where the guard
could be missing.

**Argument hygiene.** Tool arguments take the same scalar guard as the REST body: a value may not
be an array or object unless the tool allowlists it, and unknown arguments are refused by name.

**One issue path.** `issue_ticket` delegates to the writer the kiosk itself uses, which carries the
numbering retry against the unique (service, day, number) index and mints the visitor's private
follow link. A ticket cannot come into existence any other way.

### 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 tokora 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": "tokora",
  "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:
Tokora implements the protocol, not an integration with a particular vendor.

## Webhooks

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

- `token.issued` — a ticket is handed out (kiosk, desk or API)
- `token.called` — a customer is called, including which counter
- `token.served` — a customer's turn is completed
- `token.transferred` — a ticket moved on to the next service, keeping its number (3.2); `label` is the number the visitor holds

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

    X-Tokora-Event: token.called
    X-Tokora-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 short timeout —
deliberately simple; the **Recent deliveries** log shows every attempt and its response code.
Design receivers to be idempotent.

This is how you bolt on the things Tokora deliberately does not do: pipe `token.called` into a
text-to-speech announcer, a second screen, a Slack channel, or an SMS gateway of your choosing.

## CSV import

Two entities, both under **Import**:

- **Services** — `name` and `prefix`, both required. Prefixes are normalized the way the app
  normalizes them and must be **unique**: two services sharing a prefix would print two different
  customers the same ticket label.
- **Counters** — `name` required; `service` optional. Leave the service blank for a counter that
  serves any queue.

Both run a **mandatory dry-run** whose report is produced by the same code that performs the
import — including duplicate detection *between two rows of the same file* — so the number it
promises is the number you get. A row that cannot be applied in full writes nothing.

## Limits, honestly

- The API acts at user level; there is no separate scope system.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- Tokens can be issued and called through the API; the recall / no-show / requeue actions stay in
  the counter console, where the person making the judgment call is standing.
- No email, by design (see above).


## Connect it to Zapier, Make or n8n

Tokora 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 Tokora, open **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://tokora.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `token.issued` | A ticket is handed out (kiosk, desk or API) |
| `token.called` | A customer is called, including which counter |
| `token.served` | A customer's turn is completed |
| `token.transferred` | A ticket moved on to the next service, keeping its number (3.2); is the number the visitor holds |

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

- **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/tokens`: Issue a ticket
- `POST /api/counters/{id}/call-next`: Call the next waiting customer to a counter

### Example recipes

- **token.issued → a row in Google Sheets:** webhook trigger on `token.issued` (A ticket is handed out (kiosk, desk or API)), then the Google Sheets "Create row" action with the fields you want.
- **token.called → a message in Slack or Teams:** webhook trigger on `token.called` (A customer is called, including which counter), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Tokora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/tokens` (Issue a ticket).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Tokora's own automated tests check its API.
