Privara API, as shipped in the download
Privara REST API & Webhooks (v3.0)
Privara ships a JSON REST API and signed webhooks so your ticketing system, privacy portal or compliance dashboard can watch and action data-subject requests without anyone re-keying them.
The one endpoint that is deliberately missing
There is no POST /api/requests. A DSAR is a legal act by a data subject, and Privara's defensibility rests on the emailed verification token proving the person who asked is the person whose data it is. An API that minted verified requests on someone's behalf would hollow that out — and the register would no longer be evidence of anything. Submission stays the public form at /request. Everything after submission is fully automatable.
Authentication
Create a key in API & Webhooks. Send it on every call:
Authorization: Bearer apk_...
The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.
Endpoints
| Method & path | What it does |
|---|---|
GET /api/ping | Verify a key |
GET /api/requests | Newest 300 requests; ?status= filters on the derived status |
GET /api/requests/{id} | One request by id or reference, with its full audit log |
POST /api/requests/{id}/status | Set status (new, verifying, in_progress, completed, refused) |
POST /api/requests/{id}/extend | Apply the Art. 12(3) two-month extension |
GET /api/openapi.json | OpenAPI 3.0 description of everything above |
Status is derived, not stored
?status=overdue works even though overdue is not a value in the database. Privara computes the display status from the deadline every time — the API calls Dsar::deriveStatus(), the same function the register screen uses, so a filtered API list and the screen can never disagree.
curl -H "Authorization: Bearer apk_..." \
'https://privacy.example.com/api/requests?status=overdue'
Closing a request
curl -X POST https://privacy.example.com/api/requests/PRV-4K2M/status \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"status":"completed","resolution_note":"Export sent 12 June, receipted."}'
completed and refused stamp closed_at; moving back to an open status clears it. Every change writes a line to the request's audit log, marked as having come from the API.
resolution_note is optional and is written only when you send it, so re-closing a request without the field no longer blanks a note that was already there. (Before 3.0 it was written unconditionally on close.)
Setting overdue returns 422 with the reason spelled out: it is derived from the deadline and today's date, so nobody can store it — the API refuses it in the same words the screen and the MCP tool use, because all three call one transition function.
A key whose owner is a viewer gets 403 here and on the extension. Before 3.0 the REST routes checked only that the key was valid, so a viewer's key could move any request in the register and — because these two routes wrote no audit entry at all — leave nothing behind. Both are fixed: the role gate matches the UI, and every REST mutation appears in the audit trail with the key owner as the actor.
The extension
curl -X POST https://privacy.example.com/api/requests/12/extend \
-H "Authorization: Bearer apk_..."
Returns 422 if the request is already extended. The new date comes from Dsar::extendedDeadline() — the same working-day roll the UI applies, not a second implementation that could drift from it. As of 3.0 that is literally true: the screen and this endpoint both call one _apply_extension(), where previously each carried its own copy of the rule.
What the API will not tell you
Two fields are withheld from every response by design:
verify_token— it is a capability. Anyone holding it can confirm a request as the subject.ip— the submitter's address is retained for the register's own integrity, not for export.
GET /api/requests/{id} does return details and the audit log, because an integration that cannot read the request cannot action it.
Webhooks
Add a receiver in API & Webhooks and choose events:
| Event | Fires when |
|---|---|
request.created | A request is logged — from the public form, or by hand on screen or through the agent — and its statutory clock has started |
request.status_changed | Status changes from any path: screen, bulk action, REST or agent (payload also carries previous_status) |
Every delivery is signed:
X-Privara-Event: request.created
X-Privara-Signature: sha256=<hmac_sha256(raw_body, your_webhook_secret)>
Compare with hash_hmac('sha256', $rawBody, $secret) and reject on mismatch. Deliveries and their response codes are logged in the same settings page.
Errors
| Code | Meaning |
|---|---|
401 | Missing, unknown or revoked key |
404 | No such request (id and reference both tried) |
422 | Validation — detail names the offending field |
Own It 3.0 — the agent endpoint and the rest
MCP — Privara for an AI assistant
Privara speaks MCP (Model Context Protocol) on one route, so an assistant can read the register, log what arrived in the post and move a request through the workflow — 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_requests | no | The register, filtered by derived status or by what falls due within N days |
request_detail | no | One request with its clocks and its full action log |
create_request | YES | Logs a request that arrived by post, phone or counter |
advance_status | YES | Moves a request through the workflow |
deadlines_report | no | What is overdue, what is due soon, and counts by status |
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://privacy.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 privara https://privacy.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": "privara",
"server_url": "https://privacy.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "privara", "name": "Privara", "url": "https://privacy.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://privacy.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: Privara implements the protocol, not an integration with a particular vendor.
overdue is not a status you can set
This is the rule the whole product turns on. A request is overdue when today is past its effective deadline — that is a fact about the calendar, worked out on every read by Dsar::deriveStatus(). It is never written to a column, because a stored "overdue" would be a snapshot of a Tuesday that is wrong by Wednesday.
So advance_status accepts only the stored vocabulary — new, verifying, in_progress, completed, refused — and refuses overdue by name, with the reason:
{"content":[{"type":"text","text":"A request cannot be set to \"overdue\" — that is derived from the statutory deadline and today's date, and it changes on its own. Set the work status instead."}],
"isError":true}
That is not a second rule written for machines. advance_status calls _apply_status() — the same function the browser form and the bulk action call — so the vocabulary, the closed_at rule and the request_log entry are identical whichever surface writes. There is no path in the product that changes a status without passing through it. Reopening a closed request clears closed_at, by the same token: a live request has no closing date.
list_requests still reports overdue, alongside the stored_status it was worked out from.
The clock is the product's, never the caller's
create_request takes a name, an email, a type, the request text and how it arrived. It does not take a deadline. It calls _intake_request() — the function behind the Log a request screen — which hands off to Dsar::create() for validation, the reference, the received date and the statutory deadline (one month, rolled to the next working day).
The request is logged unverified, and its log says so, because a letter is not an identity check. Verify the person before you disclose anything.
Two things follow from this and are worth stating plainly:
- An agent cannot create a request by a route a person does not have. The intake screen came first; MCP calls the same function.
- The public REST API still has no create endpoint. 2.0's promise holds: nothing unauthenticated puts a row in this register. A DSAR that arrives through the web form comes from the data subject, verified by email link, exactly as before.
Subject data over the wire
No tool ever serializes verify_token — it is the subject's own credential, and anyone holding it could confirm a request as them.
Refusals
Nested arguments, unknown arguments (refused by name), out-of-enum values (which list the valid options), a bad email address (the form's validator, not a second copy of it), and role: a viewer's key is refused advance_status and create_request.
Domain refusals come back as tool errors (isError: true) — those are the ones a model can read and correct. Protocol mistakes are JSON-RPC errors.
Every MCP write lands in the audit trail beside the browser and API writes.
Quick check
curl -s -X POST http://your-install/mcp \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Deadline calendar (iCal)
GET /calendar.ics?t=<calendar token>
Every open request's effective deadline as an all-day event — the extension where one was granted, the original otherwise. Mint the token in Settings → Deadline calendar; minting a new one revokes the old immediately. The feed is built from the same v3_deadline_data() the Deadlines screen, the PDF extract and deadlines_report use, so a calendar cannot show a date the register disagrees with.
Deadline digest
GET /digest/run?t=<backup token>
Call it once a day from cron. It sends what is overdue and what falls due within your horizon, to the notification address (or the DPO address). Three deliberate behaviours:
- Off by default, and it will not attempt to send with no SMTP configured.
- Silent on a quiet day — a reminder that always arrives is a reminder nobody reads.
- An unsent digest is not marked sent, so a mail server outage means a retry tomorrow, not a day of deadlines nobody was told about.
Register extract (PDF)
GET /register.pdf
The DPO's file copy: counts by status and every request with its dates and deadline, as of today. The status column is Dsar::deriveStatus() — the PDF does not decide lateness for itself, and the page says which date it is true as of.
Scheduled backup
GET /backup/run?t=<backup token>
Writes a dated JSON backup, keeps the newest fourteen. Credentials are stripped — password hashes, SMTP and OIDC secrets, the backup and calendar tokens, and every subject's verification token. Restore is a two-step: upload, read the table-by-table dry run, then commit. A backup from another Ownware product is refused by name.
Roles
| Role | Can |
|---|---|
viewer | Read the register and the audit trail |
member | …plus handle requests, attach correspondence, save views |
admin | …plus settings, templates, erasure, the team |
An API key can do exactly what its owner can do. The last active admin cannot be demoted or deactivated.
Erasure, on the erasure register
GET /requests/{id}/subject.json exports everything Privara holds about that person — every request they filed, matched on their email, each with its action log.
Anonymisation is different here than in other products, and deliberately so. **It is refused while a request is open.** A DSAR record is the controller's own evidence that it handled the right lawfully and on time; erasing the subject from a live request would destroy the audit of the very right being exercised, and leave the statutory deadline unanswerable.
Once the request is closed — completed or refused — anonymisation is offered. The name, email, request text, resolution note and IP go, along with the free text of the log. The reference, type, dates, deadline, outcome and the log's actions and timestamps all stay, so the register still shows that this request existed and how it ended.
Attachments
Correspondence and evidence against a request: POST /requests/{id}/attach, served back at /attachments/{id} only to a signed-in user — these are letters about a named person, never a public path. The file's actual content decides its type; SVG is refused, because an SVG can carry script.
Single sign-on (OIDC)
Configure an issuer in Settings and a Sign in with single sign-on button appears. SSO signs people in; it does not create accounts — the user must already exist and be active in Privara, and their provider must assert a verified email. An optional domain restriction narrows it further.