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 recognizable 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.
- A key does what its holder's role allows, and no more. Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot change a case (
403 forbidden).
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, full thread, answers (the category's extra questions, 3.2.0) and linked case codes (3.2.0) — a handler's job: a key whose role cannot read threads gets 403 forbidden, and a sealed case gets 403 sealed until the two-person rule is met (3.2.0; before, this route skipped both gates) | ||
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
reporter_lang (3.2.0) is on every case record, from the API and the MCP tools alike: the code (en, de, fr, es, it, pl, nl, pt, ro, el) of the reporting pages the report was written on, so a handler can reply in that language; "" on cases filed before 3.2.0. Webhook payloads leave it out.
/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 enrolls 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 and is the shareable backup: every credential-named column (passwords, 2FA secrets, API-key and invite hashes, the SMTP password, the OIDC client secret, the backup and calendar token hashes, webhook signing secrets), every webhook URL (matched by pattern) and the reporters' passphrase_hash arrive as [REDACTED]. After restoring it on a fresh install, re-enter your webhook URLs, as you do signing secrets. Reporters cannot reopen their threads from a JSON restore; the SQLite backup (/backup/sqlite) keeps everything, so restore from that when their access has to survive.
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"
}
**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://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 organization 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. So do, as of 4.1.1, the report's subject line, the notes in the case log and on case links, and any photo the reporter attached (the sealed file and its row): the purge does not reach them. 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 anonymize, 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
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. So since 3.1 a reporter can attach a photo only, and only one whose identifying metadata can be removed by parsing the file itself; it is rebuilt without that metadata and sealed at rest (see the manual's Attachments section). Documents are refused, because their metadata cannot be reliably removed. The upload surfaces in the product are exactly three: the admin's branding logo, the admin's restore file and the reporter's photo; the suite asserts there is never a fourth.
Connect it to Zapier, Make or n8n
Confida 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 Confida, open Settings → 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://confida.yourcompany.com.
When something happens in Confida: 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 Confida under Settings → API keys, and choose the event or events below.
- Make the event happen once in Confida, so the other tool receives a sample and you can map its fields.
| 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 |
Every delivery carries X-Confida-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 Confida
- 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/cases/{code}/status: Move a case; acknowledging starts the feedback clock, same rule as the UI
Example recipes
- case.created → a row in Google Sheets: webhook trigger on
case.created(A report is submitted), then the Google Sheets "Create row" action with the fields you want. - case.message → a message in Slack or Teams: webhook trigger on
case.message(A reporter replies on their case), then the Slack or Microsoft Teams "Send message" action.
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Confida's own automated tests check its API.