Trainora API, as shipped in the download
Trainora — API, webhooks and the agent door
Everything here runs on your own server. Nothing is proxied, and no request leaves your network unless you point it somewhere.
Authentication
Mint a key in the app: API & agents → API keys. Send it as a bearer token:
Authorization: Bearer trk_...
Each key has a scope:
| Scope | What it can do |
|---|---|
| Read and write | Everything the key's user could do in the browser. |
| Read only | GET, HEAD and OPTIONS. Anything else is refused with 403 forbidden at the door — not per route, because the route that forgets is the one that leaks. |
A key can never exceed its user's role. A read key belonging to an admin still cannot write.
Endpoints
| Method | Path | What it does | ||
|---|---|---|---|---|
GET | /api/ping | Verify a key; returns its scope. | ||
GET | /api/matrix | The whole grid: every person against every competency, with each cell's state and expiry date. | ||
GET | /api/attention | Expired, never recorded and expiring — worst first. `?state=expired\ | none\ | expiring`. |
GET | /api/subjects | The register of people. | ||
GET | /api/subjects/{id} | One person and what they hold. | ||
GET | /api/requirements | The competencies tracked, with their validity in months. | ||
GET | /api/records | Records, newest first. ?subject_id= ?requirement_id= | ||
POST | /api/records | Record a completion. | ||
GET | /api/openapi.json | OpenAPI 3 description of all of the above. | ||
GET | /healthz | Liveness, no key required. |
Record a completion
curl -X POST https://trainora.example.com/api/records \
-H "Authorization: Bearer trk_xxxx" -H "Content-Type: application/json" \
-d '{"subject_id": 4, "requirement_id": 2, "achieved_on": "2026-08-01", "cert_ref": "FH2-000123"}'
cert_ref (since 3.3, optional) is the certificate or reference number the awarding body printed: up to 120 characters, kept on the record, and returned as cert_ref on every record the API sends (GET /api/records, the record.created webhook). The MCP tool record_completion takes it too.
You cannot set the expiry. It is computed from the competency's own validity, because a client that could choose its own expiry date would make the whole register meaningless. The same call refuses, with a sentence, when:
- the date is in the future, or is not a real date;
- the competency does not apply to that person's team or role;
- the person or the competency is archived, or does not exist.
These are the same refusals the grid shows and the MCP tool returns, because all four doors call one function.
Past records from a spreadsheet (3.3)
Records → Import past records (administrators) reads a CSV by header name: person, training and completed_on are needed; email, cert_ref, result and note are optional. The dry run names every row it would skip and why. Each row is then recorded through the same function as above, so the same refusals apply, and there is no expiry column for the same reason. A row with the same person, training and date as an existing record is skipped, so running a file twice adds nothing. An import does not send a record.created webhook for each row; the audit trail records the import.
Webhooks
record.created fires whenever a completion is recorded, from any door. Configure receivers under API & agents. One delivery attempt per event, with a log — that is a log, not a retry queue.
Limits, honestly
- List endpoints cap at 500 rows. This is a compliance register for one organization, not a warehouse.
- There is no endpoint that creates a person, a competency or a session. Those are deliberate screens with their own guards; the API is for reading the register and recording completions.
- No endpoint issues a certificate. Trainora does not issue certificates — see the hand-off below.
The certificate hand-off
GET /export/certora-recipients.csv emits exactly name,course,email, one row per person who currently holds something that has not expired. Those three header names are [Certora](../29-certora)'s recipient-import contract; import the file there and issue from it.
It is a file hand-off on purpose: no request leaves this box, and Trainora never learns whether a certificate was issued. If you want that link automated, drive Certora's own API with this file.
MCP — the agent door
Trainora speaks the Model Context Protocol at POST /mcp, so Claude, ChatGPT agents, n8n's AI nodes or your own code can use the register rather than merely read it. It is the same product underneath: the same bearer key, the same roles, and the same expiry engine. An agent cannot do anything its key's user could not do in the browser — and it cannot compute an expiry date, ever.
Transport is streamable HTTP, stateless: one JSON-RPC 2.0 request in, one JSON response out.
Connect it to an assistant
Mint the key in the app first: API & agents → 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://trainora.example.com/mcp |
| The key | header Authorization: Bearer trk_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 trainora https://trainora.example.com/mcp \
--header "Authorization: Bearer trk_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": "trainora",
"server_url": "https://trainora.example.com/mcp",
"authorization": "trk_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://trainora.example.com/mcp \
-H "Authorization: Bearer trk_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: Trainora implements the protocol, not an integration with a particular vendor.
Tools
| Tool | Reads or writes | What it is for |
|---|---|---|
matrix | reads | The whole grid, with coverage. |
attention | reads | What is expired, missing or expiring — the list to work down. |
subject_detail | reads | One person: what they hold and what they are missing. |
requirements | reads | The competencies and how long each stays valid. |
record_completion | writes | Record that somebody met a competency on a date. |
Every read tool is annotated readOnly, which is what makes a read-scoped key see only those four. A read key calling record_completion by name gets a refusal naming the tool, not a silent failure.
Connect it to Zapier, Make or n8n
Trainora 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 Trainora, open API & agents → API keys. 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://trainora.yourcompany.com.
When something happens in Trainora: 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 Trainora under API & agents → API keys, and choose the event or events below.
- Make the event happen once in Trainora, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
record.created | Whenever a completion is recorded, from any door. Configure receivers under |
When something happens elsewhere: act in Trainora
- 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/records: Record a completion.
Example recipes
- record.created → a row in Google Sheets: webhook trigger on
record.created(Whenever a completion is recorded, from any door. Configure receivers under), then the Google Sheets "Create row" action with the fields you want. - record.created → a message in Slack or Teams: webhook trigger on
record.created(Whenever a completion is recorded, from any door. Configure receivers under), then the Slack or Microsoft Teams "Send message" action. - A new form entry or row elsewhere → Trainora: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling
POST /api/records(Record a completion).
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Trainora's own automated tests check its API.