API v1MCPOAuth 2.1

Documentation

API & MCP reference

REST API and MCP server: authentication, endpoints, documents, limits, credits and the connectors for Claude Code, Cursor, n8n, ChatGPT and claude.ai.

Connect your AI agent (Claude Code, Cursor, n8n, or your own code) to pseudonymizer.eu to pseudonymize Dutch legal documents: GDPR/AVG-compliant: and convert them back.

Two integration paths, same engine:

  • REST API: works with any language/framework.
  • MCP server: native tools for MCP-capable agents (Claude Code/Desktop, Cursor, n8n).

Access requires an active subscription (or beta allow-list). Usage draws from the same credit balance as the web app.

1. Authentication

All requests authenticate with an API key in the Authorization header:

Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxx

Create and manage keys on https://app.pseudonymizer.eu/account → API keys. The full key is shown once at creation: store it securely (it is hashed server-side and never retrievable again). Keys can be revoked anytime.

HTTP Meaning
401 Missing / invalid / revoked key
403 No active subscription or beta access for this account

2. Base URL

https://app.pseudonymizer.eu/api/v1

(direct Supabase URL also works: https://app.pseudonymizer.eu/api/v1)

All requests send Authorization: Bearer … and, for POST, Content-Type: application/json.

3. Async model (jobs)

Pseudonymization can take seconds to a few minutes. Write operations are asynchronous:

  1. POST an operation → you get 202 { "job_id": "...", "status": "queued" }.
  2. Poll GET /v1/jobs/{job_id} (back off: 1s → 2s → 5s) until status is succeeded or failed.
status: queued → running → succeeded | failed

Jobs whose text exceeds 100,000 characters (up to 2,000,000) run in parts of ~100k across several server invocations and take minutes (roughly 30–60 s per part, three parts in parallel). While running such a job carries progress: { "phase": "legend" | "apply", "parts": n, "done": k, "resumes": r }. Polling is also what revives the job if its worker was interrupted, so keep polling (every 15–30 s) until it finishes.

balance, credits/packages and credits/topup are synchronous (immediate response).

4. Endpoints

POST /v1/pseudonymize

Body, one of:

  • { "content": "…" }: plain text/markdown, max 2,000,000 chars. Above 100,000 chars the text is processed in parts of ~100k with one consistent legend across the parts; such jobs take minutes and report progress (see §3).
  • { "document": { "data": "<base64>", "filename": "dagvaarding.docx" }, "options": { "keep_images": false } }: a file (.docx, .pdf, .md, .txt, .html), see Documents below.

The web app has the same limits (2,000,000 chars, parts of ~100k). Cost: ceil(pages / 5) credits, pages = ceil(chars / 2500); a PDF costs 1 extra credit (OCR). Job result (text):

{ "legend": { "Jan Jansen": "[EISER_1]", "Amsterdam": "[PLAATS_1]" },
  "pseudonymized_content": "[EISER_1], wonende te [PLAATS_1] …" }

Job result (document): the same legend plus pseudonymized_content (Markdown), document_model, pseudonymized_document and report: see Documents.

POST /v1/depseudonymize

Body: { "content": "…", "legend": { "Jan Jansen": "[EISER_1]", … } } or { "document": { "data": "<base64>", "filename": "dagvaarding_gepseudonimiseerd.docx" }, "legend": { … } } (the legend returned by pseudonymize). Free, no AI, no credits. Job result: { "original_content": "…" }; for a document also original_document (base64, same format, <name>_origineel.<ext>) and report. Pseudonym tokens that were rewritten on the way ([eiser 1], [Eiser_1]) are recognised too. A PDF cannot be reversed (unsupported_document): send the Word or Markdown output instead.

POST /v1/ocr

Body: { "document": { "data": "<base64 PDF>", "filename": "x.pdf" } }. Cost: 1 credit. Job result: { "content": "…", "pages": 12 }

GET /v1/jobs/{id}

{ "job_id": "…", "operation": "pseudonymize", "status": "succeeded",
  "credits_charged": 1, "result": { … }, "error": null }

GET /v1/jobs/{id}/document

The output file of a succeeded document job (pseudonymized_document or original_document) as a binary download: the Content-Type of the format and Content-Disposition: attachment; filename="…". 404 no_document for text jobs or jobs that have not succeeded.

Documents (files in and out)

pseudonymize and depseudonymize accept a file instead of content: "document": { "data": "<base64, with or without a data: prefix>", "filename": "<name with extension>" }. The extension decides the format of input and output; without one the content is sniffed (zip → .docx, %PDF → .pdf, HTML → .html, other text → .md). Max ~15 MB (20,000,000 base64 characters); the extracted text must stay within 2,000,000 characters (above 100,000 the legend is generated in parts, see §3).

Input What happens pseudonymized_document
.docx replaced in place in the Word file: body, headers/footers, footnotes, text boxes, hyperlinks, metadata; tracked changes accepted, comments removed; images replaced by a grey placeholder (options.keep_images: true keeps them, unchecked) .docx, formatting intact
.md, .txt, .html parsed into a document model (headings, lists, tables, bold/italic, links); the legend is applied across formatting boundaries same format
.pdf OCR (Mistral, 1 extra credit: also for PDFs with a text layer), then the document model .docx (editable): the visually identical, rasterised PDF exists only in the web app

The job result of a document job:

{ "legend": { "Jan Jansen": "[EISER_1]" },
  "pseudonymized_content": "# Dagvaardingnn[EISER_1], wonende te …",
  "document_model": { "version": 1, "source": { "name": "dagvaarding.docx", "kind": "docx" },
                      "blocks": [ … ], "notes": [], "headers": [], "footers": [], "meta": {} },
  "pseudonymized_document": { "data": "<base64>", "filename": "dagvaarding_gepseudonimiseerd.docx",
                              "content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
  "report": { "source_kind": "docx", "replacements": 12, "matched_keys": 5, "leaks": [], "warnings": [],
              "docx": { "accepted_revisions": 0, "removed_comments": 1, "removed_images": 1, "kept_images": 0,
                        "removed_objects": 0, "metadata_cleared": ["dc:creator"], "removed_parts": ["docProps/custom.xml"] } } }
  • pseudonymized_content is Markdown (plain text for .txt): what you pass on to an LLM or to LawBot.
  • document_model is the PII-free structured model (the JSON download of the web app); it contains no legend.
  • report.leaks lists legend originals still present in the output; the job still succeeds: you decide.
  • Results are kept 24 hours. Download the file with GET /v1/jobs/{id}/document, or decode data yourself.
  • Errors: invalid_document (400, unreadable), unsupported_document (400: .doc, .docm, other formats, or a PDF on depseudonymize), empty_document (400), document_too_large (413), content_too_large (413, extracted text over 2,000,000 characters).

GET /v1/balance

{ "total_credits": 123, "expiring": [ { "amount": 50, "expires_at": "2026-08-01T…" } ] }

GET /v1/credits/packages

{ "packages": [ { "price_id": "price_…", "credits": 50, "amount": 999, "currency": "eur", "label": "Starter Pack" } ] }

(amount is in cents.)

POST /v1/credits/topup (human-in-the-loop)

Body: { "price_id": "price_…" } (from /credits/packages). Returns a Stripe checkout URL: the agent must hand this to a human to pay. The agent cannot charge a card autonomously. After payment, credits are added; confirm via /balance.

{ "checkout_url": "https://checkout.stripe.com/…", "credits": 50, "price_id": "price_…" }

Error format

{ "error": { "code": "insufficient_credits", "message": "…" } }

Codes: unauthorized (401), forbidden (403), insufficient_credits (402), invalid_content / invalid_legend / invalid_document / unsupported_document / empty_document / missing_* (400), content_too_large / document_too_large (413), job_not_found / no_document (404), rate_limited / too_many_active_jobs (429, with a Retry-After header in seconds), internal_error (500).

Limits and credits

  • Starting paid jobs (pseudonymize, ocr) is limited per account, shared with the web app: 20 per minute, 300 per hour, and at most 6 running at the same time. depseudonymize (free) is limited to 60 per minute. Polling (GET /v1/jobs/{id}, /v1/balance) is not limited.
  • Credits are reserved when the job starts (so insufficient_credits is returned immediately as a 402) and refunded automatically if the job fails. credits_charged on a job shows what was finally charged (0 for failed jobs). A PDF document job reserves the OCR credit at the start and the pseudonymisation credits once the OCR text is known (both refunded if the job fails).
  • Long jobs (text above 100,000 characters) keep their reservation alive while they make progress; a job without progress for 15 minutes is failed and refunded.
  • On a 429, wait Retry-After seconds before retrying. Over MCP the same limits apply; the tool result is an error with code and retry_after_seconds.

5. Example (cURL round-trip)

KEY=pk_live_xxx
BASE=https://app.pseudonymizer.eu/api/v1

# 1. start
JOB=$(curl -s -X POST "$BASE/pseudonymize" -H "Authorization: Bearer $KEY" 
  -H "Content-Type: application/json" 
  -d '{"content":"Jan Jansen, wonende te Amsterdam, vordert 5000 euro."}' | jq -r .job_id)

# 2. poll
curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $KEY" | jq .

# → { "status":"succeeded", "result": { "legend": {...}, "pseudonymized_content":"[EISER_1], …" } }

6. MCP server

Endpoint: https://app.pseudonymizer.eu/mcp (Streamable HTTP, stateless). Auth: Authorization: Bearer pk_live_… (API key), or OAuth 2.1 for clients that sign in interactively (ChatGPT custom MCP apps, claude.ai custom connectors, Claude Code without a key): see OAuth 2.1 below.

Register in Claude Code

claude mcp add --transport http pseudonymizer https://app.pseudonymizer.eu/mcp 
  --header "Authorization: Bearer pk_live_xxx"

Register in Cursor / Claude Desktop

Add to the MCP config (mcpServers):

{ "pseudonymizer": {
    "type": "http",
    "url": "https://app.pseudonymizer.eu/mcp",
    "headers": { "Authorization": "Bearer pk_live_xxx" } } }

n8n

Use the MCP Client node with HTTP transport, URL https://app.pseudonymizer.eu/mcp, and an Authorization: Bearer pk_live_xxx header.

OAuth 2.1 (ChatGPT apps, claude.ai connectors, Claude Code)

Add https://app.pseudonymizer.eu/mcp as a custom MCP app (ChatGPT: Workspace settings → Apps → Create, auth OAuth) or as a custom connector (claude.ai: Customize → Connectors). No key is needed: the first call returns 401 with WWW-Authenticate: Bearer resource_metadata="https://app.pseudonymizer.eu/.well-known/oauth-protected-resource", the client discovers the authorization server (https://app.pseudonymizer.eu/.well-known/oauth-authorization-server) and sends the user to https://app.pseudonymizer.eu/oauth/authorize, where they sign in and grant access. Connected apps are listed (and can be revoked) on https://app.pseudonymizer.eu/account.

  • Client registration: Client ID Metadata Documents (preferred) or Dynamic Client Registration (/oauth/register); public clients only (token_endpoint_auth_method: none, PKCE S256 required). Redirect URIs must match exactly; loopback URIs (http://localhost/…, http://127.0.0.1/…) match without the port.
  • Resource indicator: tokens are issued for exactly https://app.pseudonymizer.eu/mcp (resource, RFC 8707).
  • Scopes: mcp (all tools), openid/email (UserInfo at /oauth/userinfo, for ChatGPT workspaces with domain restrictions), offline_access (refresh tokens are always issued).
  • Tokens: access tokens expire after 1 hour, refresh tokens rotate (30 days); reuse of a rotated refresh token revokes the whole connection. POST /oauth/revoke (RFC 7009). The same Pro/beta gating applies as for API keys.
  • Claude Code: claude mcp add --transport http pseudonymizer https://app.pseudonymizer.eu/mcp (no header) and sign in via /mcp.

Tools

Tool Input Output
pseudonymize_document { content } or { file_base64, filename, keep_images? } { job_id, status }
depseudonymize_document { content, legend } or { file_base64, filename, legend } { job_id, status }
ocr_document { file_base64, filename? } { job_id, status }
get_job { job_id, include_document?, content_offset?, content_limit? } { status, progress?, result?, error? }: for document jobs the base64 data of the output file is left out unless include_document: true (filename, content type and size stay); text results above 100,000 characters come back in slices (content_chars, content_next_offset): call again with content_offset
get_balance {} { total_credits, expiring }
list_credit_packages {} { packages }
buy_credits { price_id } { checkout_url, credits } (hand to a human)
list_chatgpt_agents {} { agents, dossiers, templates }
prepare_chatgpt_dispatch { platform?, agent_id?, prompt, documents: [{ job_id, name? }], dossier? } { platform, mode, input_preview, input_chars, max_chars, credit_cost, blocked, leaks, confirm_hash, package_id?, package_parts? }
send_chatgpt_dispatch same as prepare + confirm_hash (+ package_id when mode is package) { dispatch_id, status, platform, mode, conversation_url, credits_charged, dossier_id, package_id?, handoff?, claude_url? } (human approval required)
get_chatgpt_dispatch { dispatch_id } { status, conversation_url, error_code }
get_dispatch_package { package_id, part? } { part, parts, next_part, content, … }: a part (~50k chars) of the documents of a large dispatch, 24 h, free

A typical agent flow: pseudonymize_document → poll get_job → use the result; when credits run low, list_credit_packages → buy_credits → show the checkout URL to the user → get_balance.

Files via MCP. file_base64 + filename work exactly like document in the REST API (same formats, result and limits, see Documents above). A base64 file does not belong in a conversation, so get_job leaves data out by default; ask for it with include_document: true (small files) or fetch it through the REST API with GET /v1/jobs/{id}/document. pseudonymized_content (Markdown) is what an agent normally needs, and what prepare_chatgpt_dispatch bundles for LawBot.

Send to LawBot Pro (ChatGPT Workspace Agents)

Pro users can send pseudonymized documents with an instruction to their own ChatGPT Business Workspace Agent (e.g. LawBot Pro); a chat is then waiting in ChatGPT. The agent and its token are linked by the human on https://app.pseudonymizer.eu/account → LawBot Pro koppelen.

  1. pseudonymize_document → poll get_job until succeeded (one job per document).
  2. list_chatgpt_agents → pick an agent_id (and optionally a dossier or template).
  3. prepare_chatgpt_dispatch with the job ids → returns the exact text that would go to ChatGPT, the credit cost and a confirm_hash. Nothing is sent. leaks lists originals from the legend that are still in the text: a warning, not a block: show them to the human, who decides (they may be kept on purpose, e.g. company names). blocked is too_long when the dispatch exceeds the limits.
  4. Show input_preview and credit_cost to the human and get explicit approval.
  5. send_chatgpt_dispatch with the same arguments plus confirm_hash (any change is rejected with preview_mismatch) → returns conversation_url. Hand that link to the human; the agent’s answer lives only in that ChatGPT chat. Follow the run with get_chatgpt_dispatch.

dossier: { kind: "none" } (default, new chat), { kind: "new", label } (start a dossier) or { kind: "existing", id } (continue in the dossier’s chat). The legend never leaves Pseudonymizer. A dispatch costs the same as pseudonymizing the documents. Documents are bundled as Markdown (headings, lists and tables of the original are kept; the preamble tells the agent so), both from the web app and from document jobs.

Send to Claude. Claude has no trigger API, so platform: "claude" (no agent_id, no dossier) prepares a hand-off instead: the documents are stored as a package (24 h) and send returns handoff (the text the human pastes into Claude) and claude_url (https://claude.ai/new?q=…). Claude then fetches the package with get_dispatch_package through the Pseudonymizer connector. Status is handed_off; same credits as a dispatch.

Large dispatches (packages). ChatGPT accepts at most 100,000 characters per trigger. Above that, prepare returns mode: "package" with a package_id: only the instruction and a package reference are sent to ChatGPT, and the pseudonymized documents (never the legend) are kept for 24 hours as a package of ~50k-character parts. The agent fetches them itself with get_dispatch_package: which requires the Pseudonymizer MCP app to be published in the ChatGPT workspace, attached to the agent (end-user account) and connected by the user (OAuth, see above). Pass the package_id from the preview to send_chatgpt_dispatch; any other value is rejected (preview_mismatch). Up to 2,000,000 characters per dispatch. Errors are tool errors with a code, e.g. agent_not_shared, agent_unavailable (ChatGPT workspace credit balance 0 or API channel off), token_invalid, insufficient_credits.

Note: the claude.ai web connector UI supports OAuth only (no static bearer header); since the OAuth 2.1 server (see above) the MCP server works there as well. API keys remain the simplest option for Claude Code, Cursor, n8n and custom agents.

7. Credit costs (summary)

  • Pseudonymization: ceil(pages / 5) credits (pages = ceil(chars / 2500)).
  • OCR: 1 credit (also charged for a PDF sent as document to pseudonymize, on top of the pseudonymization).
  • De-pseudonymization: free.
  • Send to LawBot Pro (ChatGPT agent): the same as pseudonymizing the documents sent; the run itself uses the customer’s own ChatGPT workspace credits.
  • Credits are valid 60 days; oldest are consumed first (FIFO).

8. Privacy

Job results (which contain the legend = a pseudonym→original mapping = personal data) are stored briefly and purged automatically after 24 hours. Do not rely on jobs persisting beyond that.

Last updated: 29 Oct 2026 Ask the assistant → Suggest a change →