# Complia API (v2.0)

## Which deadlines are running out

```bash
curl -H "Authorization: Bearer apk_xxxx" https://complia.example.com/api/deadlines
```

```json
{
  "today": "2026-08-06",
  "ack_window_days": 29,
  "overdue": [
    {"id": 12, "reference": "CR-0044", "subject": "Late SAR response",
     "stored_status": "received", "status": "ack_overdue",
     "ack_due_at": "2026-02-03", "days_remaining": -184}
  ],
  "awaiting_acknowledgement": [
    {"id": 31, "subject": "Marketing emails after opt-out",
     "stored_status": "received", "status": "received",
     "ack_due_at": "2026-08-20", "days_remaining": 14}
  ],
  "counts": {"overdue": 1, "awaiting": 1}
}
```

Everything still awaiting acknowledgment, split into overdue and pending, with signed days
remaining. Poll it from a morning digest or a case-management dashboard.

## Status is derived, never stored

`stored_status` is what is in the database: `received`, `acknowledged`, `investigating`,
`resolved` or `rejected`. `status` adds the one that matters and is **computed on every read**:

| `status` | Meaning |
|---|---|
| `ack_overdue` | Not yet acknowledged and the due date has passed |
| `received` / `acknowledged` / `investigating` | The stored workflow state |
| `resolved` / `rejected` | Closed — the clock stops and never shows overdue |

Because `ack_overdue` is never written to a column, it cannot be stale and there is no nightly
job that could forget to set it. Filter on it directly:

```bash
curl -H "Authorization: Bearer apk_xxxx" \
  "https://complia.example.com/api/complaints?status=ack_overdue"
```

The due date itself is not late — a complaint due today still reads `received`.

## Authentication

Create a key in **Settings → API & webhooks**. It is shown once.

```
Authorization: Bearer apk_<40 hex chars>
```

`/api/*` is CSRF-exempt by design: the key is the credential and browsers never send it
implicitly.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot log a complaint or move one on (`403 forbidden`).

## Endpoints

| Method | Path | Notes |
|---|---|---|
| GET | `/api/ping` | Verify a key |
| GET | `/api/complaints` | `?status=` filters the **derived** status |
| POST | `/api/complaints` | Log one that arrived by phone, letter or email |
| GET | `/api/complaints/{id}` | One complaint with its full append-only log and its `related` records (kind, ref, url, note, the last status read from Privara; 3.2.0) |
| POST | `/api/complaints/{id}/status` | Move it through the workflow |
| GET | `/api/deadlines` | The feed above |
| GET | `/api/report` | The leadership report (3.2.0): `?period=12m` (default), `quarter`, `year`, or `custom&from=YYYY-MM-DD&to=YYYY-MM-DD`; the same numbers as the Leadership report page |
| GET | `/api/openapi.json` | OpenAPI 3.0.3 — imports as a custom connector |
| GET | `/healthz` | Unauthenticated `{"ok":true,"app":"complia","version":"2.0.0"}` |

### Logging a complaint that did not come through the web form

```bash
curl -X POST https://complia.example.com/api/complaints \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"complainant_name": "R. Vance", "complainant_email": "r@example.test",
       "subject": "Marketing emails after opt-out",
       "details": "Unsubscribed on 12 July, still receiving daily emails.",
       "reference": "CR-0051"}'
```

Most complaints arrive by phone or letter. Logging them through the API starts **the same
acknowledgment clock** the public form starts — the deadline comes from
`Complaints::ackDue()` either way, so nothing sits outside the register with an untracked
duty. The response includes the `tracking_token` so you can give the complainant the same
self-service status link a web submission gets.

### Moving it along

```bash
curl -X POST https://complia.example.com/api/complaints/31/status \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"status": "resolved", "outcome": "Suppression list corrected; confirmed by email 6 Aug."}'
```

The workflow is append-only and enforced by the product's own transition table:

- `received` → `acknowledged` · `investigating` · `resolved` · `rejected`
- `acknowledged` → `investigating` · `resolved` · `rejected`
- `investigating` → `resolved` · `rejected`
- `resolved` / `rejected` — **terminal**; a closed complaint cannot be reopened

**Closing requires an outcome.** `resolved` or `rejected` without an `outcome` is refused with
`422` — a register that records a closure with no reasoning is not evidence of anything.

```json
{"error":"validation","detail":"An outcome summary is required to resolve or reject a complaint."}
```

The first move out of `received` also stamps the acknowledgment, satisfying the duty and
stopping the clock. Every response carries `allowed_transitions`, so a UI can render only the
buttons that will actually work.

## Webhooks

| Event | Fires when |
|---|---|
| `complaint.created` | One is logged (public form **or** API) |
| `complaint.status_changed` | It moves state |

```
X-Complia-Event: complaint.created
X-Complia-Signature: sha256=<hmac_sha256(raw_body, secret)>
```

```python
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-Complia-Signature"]):
    abort(401)
```

One attempt per event with a 5-second timeout. The last 200 deliveries per endpoint are logged
with their response code.

**There is deliberately no `complaint.ack_overdue` event.** Going overdue is a date passing, not
something Complia observes — firing it would require a background job, and Complia runs none.
Poll `/api/deadlines` or `?status=ack_overdue` on your own schedule instead: same answer,
computed fresh, and you control the cadence.

## Errors

| Code | Meaning |
|---|---|
| `401 unauthorized` | Missing, malformed, revoked or unknown key |
| `404 not_found` | No such complaint |
| `422 validation` | Refused by a guard — missing fields, an illegal transition, or closing without an outcome |

## MCP — the agent endpoint (new in 3.0)

    POST /mcp
    Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses

| Tool | Writes? | What it does |
|---|---|---|
| `list_complaints` | no | Filter by derived status (`ack_overdue` works) or by due state. |
| `complaint_detail` | no | One complaint with its full append-only log. |
| `create_complaint` | **YES** | Logs a complaint that arrived by phone/letter/email **and starts its statutory clock**. |
| `advance_status` | **YES** | Moves it through the workflow. |
| `deadlines_report` | no | Overdue and approaching acknowledgment deadlines. |
| `leadership_report` | no | The leadership report for a period: on time, late, not yet; median days; outcomes; month by month; open by owner (3.2.0). |

### 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://complia.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 complia https://complia.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": "complia",
  "server_url": "https://complia.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://complia.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:
Complia implements the protocol, not an integration with a particular vendor.

### What the agent cannot do

`advance_status` calls `advance_complaint_status()` — the same function the register screen and
the REST API call. There is exactly one implementation of the workflow, so an agent:

- **cannot close a complaint without an outcome** — refused with the product's own sentence;
- **cannot reopen a closed one** — `resolved` and `rejected` are terminal;
- **cannot invent a deadline** — `ack_due_at` is computed once by `Complaints::ackDue()` when the
  complaint is logged, and `ack_overdue` is derived on read, never stored.

Roles apply exactly as in the browser: a viewer's key is refused both write tools. Every MCP
write lands in the audit trail with `via: mcp`, naming the key's user.


## Connect it to Zapier, Make or n8n

Complia 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 Complia, open **Settings → 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://complia.yourcompany.com`.

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

| Event | Fires when |
|---|---|
| `complaint.created` | One is logged (public form or API) |
| `complaint.status_changed` | It moves state |
| `complaint.ack_overdue` | There is deliberately no event. Going overdue is a date passing, not |

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

- **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/complaints`: Log one that arrived by phone, letter or email
- `POST /api/complaints/{id}/status`: Move it through the workflow

### Example recipes

- **complaint.created → a row in Google Sheets:** webhook trigger on `complaint.created` (One is logged (public form or API)), then the Google Sheets "Create row" action with the fields you want.
- **complaint.status_changed → a message in Slack or Teams:** webhook trigger on `complaint.status_changed` (It moves state), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Complia:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/complaints` (Log one that arrived by phone, letter or email).

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