# 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

1. **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.
2. **There is no reporter identity in the system to leak.** The `cases` table has no name, no email
   and no IP column. A test asserts this — if a future migration adds one, the suite fails.
3. **`passphrase_hash` never 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.
4. **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.
5. **Emails carry the case code and nothing else.** Same reason.
6. **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:

```sh
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):

```json
{
  "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](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://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.

```json
{"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

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 Confida under **Settings → API keys**, and choose the event or events below.
3. 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.
