Confida API, as shipped in the download
Confida REST API & Webhooks (v3.0)
Handler side only. Confida's API exists so a compliance team can run cases from their own tools. It does not, and must not, reach the reporter.
The rules this API is built around
- There is no reporter-side API and there never will be. API keys belong to handlers. A credential issued to a reporter is a durable identifier for someone whose safety may depend on not having one. Reporters use their case code plus a passphrase they chose, in a browser.
- There is no reporter identity in the system to leak. The
casestable has no name, no email and no IP column. A test asserts this — if a future migration adds one, the suite fails. passphrase_hashnever leaves the server.Api::caseOut()is an allow-list, not a row splat, because that hash could be ground offline to impersonate a reporter inside their own case thread. A test plants a recognisable hash and asserts it appears nowhere in the JSON.- Webhooks carry metadata, never content. No report details, no message bodies. A receiver is an operational trigger, not a disclosure channel — a whistleblowing report must not end up in a third party's HTTP logs.
- Emails carry the case code and nothing else. Same reason.
Endpoints
| Method & path | What it does | ||
|---|---|---|---|
GET /api/ping | Verify a key | ||
GET /api/cases | ?status= · `?sla=on_track\ | ack_overdue\ | feedback_overdue` — list omits report details |
GET /api/cases/{code} | One case with its details and full thread (a handler's job) | ||
POST /api/cases/{code}/status | Move a case; acknowledging starts the feedback clock, same rule as the UI | ||
GET /api/sla | Aggregate counters only — totals by SLA state and by status | ||
GET /api/openapi.json | OpenAPI 3.0 |
curl -H "Authorization: Bearer apk_..." "https://speakup.example.com/api/cases?sla=ack_overdue"
curl -H "Authorization: Bearer apk_..." https://speakup.example.com/api/sla
/api/sla is the endpoint to wire to a monitor: it returns `{"sla":{"on_track":N,"ack_overdue":N, "feedback_overdue":N},"overdue_total":N}` with no case data at all, so a dashboard can show a red number without anyone's report passing through it.
Two-factor authentication — turn it on
2FA matters more here than anywhere else in the shared core. A handler account is the single point at which anonymous disclosures become readable. Settings → Security enrols an authenticator app with a QR rendered locally (no external service) and ten one-time recovery codes.
Webhooks
| Event | Fires when |
|---|---|
case.created | A report is submitted |
case.message | A reporter replies on their case |
case.status_changed | A case moves — acknowledged, moved, closed or dismissed, on screen, on the board, in bulk or over REST — payload carries previous_status |
X-Confida-Event: case.message
X-Confida-Signature: sha256=<hmac_sha256(raw_body, your_webhook_secret)>
Import — N/A
There is no categories table (categories are a settings list), and there is nothing else that could sensibly be bulk-loaded: cases arrive from people, one at a time, by design.
Backup
/backup.json exports every table. passphrase_hash is included — it must survive a restore or every open reporter loses access to their own case thread. Only the SMTP credential is redacted.
Own It 3.0 — the agent endpoint and the rest
MCP — Confida for an AI assistant
Confida speaks MCP (Model Context Protocol) on one route, so an assistant can triage the case register, open a case and draft a reply to a reporter — without anyone writing an integration.
POST /mcp
Authorization: Bearer apk_... ← the SAME revocable key the REST API uses
Content-Type: application/json
The transport is stateless streamable-HTTP: one JSON-RPC request per POST, no session to keep alive. GET /mcp answers 405 with Allow: POST, so a misconfigured client is told what to do rather than left guessing at a 404.
| Tool | Writes? | What it does |
|---|---|---|
list_cases | no | The register with derived SLA status; filter by workflow status, SLA status or overdue |
case_detail | no | One case with its report, its timeline and its message thread |
post_handler_message | YES | Posts a message to the reporter, always as the handler |
stats | no | Counts by status and SLA, and what is overdue right now |
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://speakup.example.com/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 confida https://speakup.example.com/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": "confida",
"server_url": "https://speakup.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "confida", "name": "Confida", "url": "https://speakup.example.com/mcp",
"token": "apk_xxxx", "enabled": true }
]
}
**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"* — not an mcpServers array. Pasted as-is, the block above 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://speakup.example.com/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: Confida implements the protocol, not an integration with a particular vendor.
There is no reporter-side tool, and there never will be
This is the rule the whole endpoint is built around, and it is worth stating as a refusal rather than an omission. No tool files a report. No tool reads or writes a case as the reporter.
- Filing: a report's value rests on the reporter choosing their own passphrase — it is the only thing that lets them come back. An agent filing on someone's behalf would create a case nobody can follow up on, and would let an organisation manufacture reports against itself.
- Reading: the reporter side authenticates with a case code plus that passphrase. A tool that accepted a code and returned anything — including "not found" — would turn an API key into an oracle for whether a given code exists. A case code is the only thing standing between a reporter and identification by someone who glimpsed one on a screen.
post_handler_message posts as the handler, always. sender is not a parameter anywhere in the 3.0 layer, and the suite asserts the string 'reporter' appears in no code path in it.
Anonymity holds across every tool
There is no reporter identity to leak because the database has no column for one — not a name, not an email address, not an IP. list_cases returns the register without report bodies. A case under the two-person rule is refused by case_detail and by post_handler_message, and list_cases withholds even its subject line: if the agent listing showed it, the seal would be a UI convention rather than a control.
{"content":[{"type":"text","text":"This case is under the two-person rule. Request access, then a SECOND handler must confirm before its details and message history can be read or answered."}],
"isError":true}
Role gates are the same ones the screen applies: a key whose owner is a viewer can list and count, but is refused on case_detail and on any write.
Deadline calendar (iCal)
GET /calendar.ics?t=<token>
Mint the token in Backup & restore; the URL is shown once. The feed carries the two statutory deadlines — acknowledge receipt (Art 9(1)(b), 7 days) and give feedback (Art 9(1)(f), 3 months) — for every open case, as all-day events.
It carries the case code, the category and which deadline it is. It deliberately does not carry the subject line or the report. A calendar subscription syncs to phones and third-party calendar services, which means it leaves this server's control; a subject line is the field most likely to describe an identifiable situation, and it has no business there.
A wrong or missing token is 403.
Case file (PDF)
GET /cases/{id}/file.pdf
The report as submitted, the timeline and the full message thread. Messages are attributed to Reporter or Handler — roles, not people, because there is no person to name. The passphrase hash never appears in it, and the suite asserts that against the live value. A sealed case refuses to render until it is unlocked.
Retention purge
GET /purge/run?t=<token> ← same token as the scheduled backup
Destroys the content of cases closed more than purge_after_days ago: report text, message bodies and the resolution become [purged]. The code, category, status and statutory dates survive, because they are the evidence that the report was handled on time. 0 days means never, and that is the default. The audit entry is {"count":N} — naming the cases would defeat the purge.
Roles
| Role | Can |
|---|---|
viewer | See the register and the audit trail — the SLA clocks, not the reports |
member | …plus open cases, answer reporters, save views |
admin | …plus settings, branding, restore, retention, the team |
An API key can do exactly what its owner can do. viewer is a genuine oversight seat: it lets an audit committee confirm the Directive's deadlines are being met without reading what people wrote. The last active admin cannot be demoted or deactivated.
Erasure — deliberately N/A
Confida ships no GDPR subject-export/erasure kit, and that is the correct answer rather than a gap. The kit exists to find a named person's rows and blank them. Confida records no person: there is no name, email or IP column on a case, and the reporter is known only by a random code and a passphrase hash. There is nothing to export and nothing to anonymise, because the data is anonymous where it is written.
Adding the kit would mean inventing an identity column to satisfy a checklist — which would make the product less private, not more. What Confida offers instead is the retention purge above: destruction by time, not by subject.
Attachments — deliberately N/A
There is no file upload for reporters, on purpose. An attached photo or document is the most likely way a whistleblower identifies themselves by accident: camera model and GPS coordinates inside an image, dc:creator or Last-Modified-By inside a Word or PDF file, a username visible in the path of a screenshot. Stripping all of that reliably across those formats is not something to promise casually, and a half-working stripper is worse than no upload button because it converts "I know this is risky" into false confidence.
The reporting page says so plainly and asks for pasted or described evidence instead. The only two upload surfaces in the product are the admin's branding logo and the admin's restore file; the suite asserts there is never a third.