Complia API, as shipped in the download
Complia API (v2.0)
Which deadlines are running out
curl -H "Authorization: Bearer apk_xxxx" https://complia.example.com/api/deadlines
{
"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 acknowledgement, 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:
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.
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 |
| POST | /api/complaints/{id}/status | Move it through the workflow |
| GET | /api/deadlines | The feed above |
| 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
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 acknowledgement 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
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·rejectedacknowledged→investigating·resolved·rejectedinvestigating→resolved·rejectedresolved/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.
{"error":"validation","detail":"An outcome summary is required to resolve or reject a complaint."}
The first move out of received also stamps the acknowledgement, 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)>
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 acknowledgement deadlines. |
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:
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):
{
"type": "mcp",
"server_label": "complia",
"server_url": "https://complia.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "complia", "name": "Complia", "url": "https://complia.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://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 —
resolvedandrejectedare terminal; - cannot invent a deadline —
ack_due_atis computed once byComplaints::ackDue()when the complaint is logged, andack_overdueis 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.