Redact
Redact finds personal data in a text and replaces it with labels such as [PERSON-1] and [CPR-1]. You get the pseudonymised text back and, when you ask for it, the list of labels that lets you put the originals back later, for example into an answer from your own language model.
All calls go to https://api.komplai.dk. Redact takes both kinds of key: a personal API key (kp_live_...) and an organisation integration key (kp_org_...) that komplai issues for one of your organisation's own systems, such as a robot or an integration platform.
Before you start
- Your organisation's agreement covers Redact through the API, and you are assigned Redact. An organisation integration key belongs to no person and needs no assignment.
- Your key has the scopes for the operations you call:
| Scope | Operations |
|---|---|
redact:detect | POST /v1/redact/detect |
redact:process | POST /v1/redact, text jobs, PDF and Word files, POST /v1/redact/restore |
access:read | GET /v1/access |
Create a personal key on the hub's API keys page at hub.komplai.dk/api-access. API keys and authentication explains both kinds of key.
Without an approved data processing agreement, use test data only. The examples on this page use fictitious people.
Your first call
Send a short text to POST /v1/redact with the rules (regex) and the name model (ner). These two layers also run when you leave detectors out.
curl https://api.komplai.dk/v1/redact \
-H "Authorization: Bearer $KOMPLAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Borgeren Mette Kjærgaard Holm, cpr-nr. 120385-1234, bor på Fiktivvej 12, 8000 Aarhus C. Hun kan kontaktes på 21 34 56 78 eller mette.holm@example.invalid.",
"detectors": ["regex", "ner"],
"include": ["mapping"]
}'The answer, with some fields left out:
{
"redacted": "Borgeren [PERSON-1], cpr-nr. [CPR-1], bor på [ADRESSE-1]. Hun kan kontaktes på [TLF-1] eller [EMAIL-1].",
"processing": "local",
"offsetEncoding": "utf-16",
"configuration": {
"engine": "pipeline",
"detectors": ["regex", "ner"],
"processing": "local"
},
"execution": {
"completed": true,
"detectorsRun": ["regex", "ner"],
"nerCalls": 1,
"llmCalls": 0
},
"mapping": {
"[PERSON-1]": "Mette Kjærgaard Holm",
"[CPR-1]": "120385-1234",
"[ADRESSE-1]": "Fiktivvej 12, 8000 Aarhus C",
"[TLF-1]": "21 34 56 78",
"[EMAIL-1]": "mette.holm@example.invalid"
}
}redacted is the pseudonymised text. mapping is the list of labels: keep it if you want to put the originals back, and protect it like the source text, because it holds the personal data.
include asks for extra reports. Each one contains originals, so ask only for what you need:
| Report | What it holds |
|---|---|
mapping | The list of labels, label to original, for POST /v1/redact/restore |
changes | Exact positions of each change in your text and in redacted |
findings | Everything the layers found, with offsets, type, confidence and layer |
skipped | CPR-shaped numbers the rules left in the text, with the reason |
Check the result
No method guarantees anonymity. Check the result before you use it, and treat the list of labels and the extra reports as personal data.
Read your setup first
GET /v1/access shows what your key reaches, where each layer processes text for your organisation, the NER places and the models a call may choose, and your limits. It sends no text and runs no model.
curl https://api.komplai.dk/v1/access \
-H "Authorization: Bearer $KOMPLAI_TOKEN"Under services[].hosting.processing.layers you find one entry per layer. The ner entry lists its places and models, and each llm entry lists the language models at that processing. A model with available: true can be chosen, and reason says why another cannot, for example not_validated for a model that is not validated for Redact. The ner entry, shortened:
{
"detector": "ner",
"places": [
{ "id": "eu-de", "location": "EU · Germany", "available": true, "default": true },
{ "id": "local", "location": "Local infrastructure · Denmark", "available": false, "reason": "unconfigured" }
],
"models": [
{ "id": "danish-pii-ner-v1.3.0", "name": "Dansk NER 1.3.0", "status": "stable", "default": true, "places": ["eu-de", "local"] }
]
}text_contract.jobs_accepting_submissions says whether text jobs are accepted right now.
Choose the layers
detectors takes one, two or all three layers. Without it, regex and ner run.
| Layer | What it finds | Where it runs |
|---|---|---|
regex | Rules for CPR numbers, phone numbers, e-mail addresses and more | komplai's servers in Germany (EU) |
ner | A model for names, organisations and addresses | The NER place: komplai's servers in Germany, or local infrastructure in Denmark |
llm | A language model for what rules and NER miss, such as health data in free text | Local infrastructure in Denmark, or komplai's EU cloud |
The language model reads the text in parts of at most 12,000 characters and can take up to a minute. When its answer for a part cannot be used, the part is asked again in two overlapping halves. If an answer still cannot be used, the call fails with incomplete_detection or invalid_detection and returns nothing.
Your organisation's own redaction rules always apply on POST /v1/redact, POST /v1/redact/detect, text jobs and files. An administrator writes them in the hub under Organisation › Redact › Rules; Redact rules shows how.
A number that looks like a CPR number but does not decode to a possible birth date stays in the text. Ask for include: ["skipped"] to get each one with the reason. Such a number can still identify a person, for example an erstatningspersonnummer with day plus 60.
NER places and models
nerDeployment says where the ner layer runs:
eu-de: komplai's servers in Germany (EU).local: komplai's local infrastructure in Denmark.
Leave it out for the default place, the one GET /v1/access marks with default: true. Name local only when GET /v1/access lists it as available for the operation you call: places have available for calls with detectors, and detect.available for POST /v1/redact/detect and calls without detectors.
nerModel names a NER model by its id from GET /v1/access. Leave it out for the default model. POST /v1/redact/detect and calls without detectors run only the default model.
curl https://api.komplai.dk/v1/redact \
-H "Authorization: Bearer $KOMPLAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Borgeren Mette Kjærgaard Holm bor på Fiktivvej 12, 8000 Aarhus C.",
"detectors": ["ner"],
"nerDeployment": "eu-de",
"nerModel": "danish-pii-ner-v1.3.0"
}'configuration.nerDeployment and configuration.nerModel in the answer name what ran. A place that cannot be used answers ner_location_unavailable and names the place; an unknown or unavailable model answers ner_model_unknown or ner_model_unavailable. NER never moves to another place or model.
Language models and processing
processing says where the llm layer runs:
local(default): komplai's local infrastructure in Denmark, with no AI charge. The queue is shared with other organisations, without priority.eu-cloud: komplai's EU cloud in France. It answers faster, needs EU processing approved for your organisation, and its usage counts towards the organisation's monthly AI spending cap. Every call to the model counts on its own, including a part asked again in halves.
model names a language model validated for Redact at that processing, by its id from the llm layer in GET /v1/access. Leave it out for the default model there. A model that is not validated for Redact answers llm_model_not_validated, and a local model that local infrastructure does not offer right now answers local_model_unavailable.
curl https://api.komplai.dk/v1/redact \
-H "Authorization: Bearer $KOMPLAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Sagsbehandleren talte med Mette Kjærgaard Holm om hendes diabetes.",
"detectors": ["regex", "ner", "llm"],
"processing": "local",
"model": "qwen3.8-27b-q8",
"include": ["findings"]
}'The answer replaces the health data too, "Sagsbehandleren talte med [PERSON-1] om hendes [HELBRED-1].", and configuration.llmModel names the model that ran.
The API never switches between local and eu-cloud by itself and never picks another model. When the monthly AI spending cap is reached, EU calls stop with ai_credit_exhausted; local processing keeps working.
Short texts or text jobs
| Operation | Text limit | What you get |
|---|---|---|
| POST /v1/redact/detect | 16,000 | Findings with offsets into your text. The text is not changed, no language model runs and your organisation's rules apply. |
| POST /v1/redact | 750,000 | The pseudonymised text in the response, for work that finishes within 300 seconds. |
| POST /v1/redact/jobs | 2,000,000 | A text job: stored encrypted and processed in the background. |
Limits and every offset count UTF-16 code units, as in JavaScript and .NET. A text job of 2,000,000 code units is about 800 standard pages.
Before a synchronous call runs, the API estimates the time from komplai's measured speeds. A run that cannot finish in 300 seconds is refused with use_job, its estimatedSeconds and holdSeconds, and nothing is processed or charged. The estimate cannot cover the EU language model or a wait in the shared queue, so such a run can still end with processing_timeout. Send long texts and runs in the EU cloud as text jobs.
You can also send POST /v1/redact with the header Prefer: respond-async: the call then answers 202 with a text job, exactly as POST /v1/redact/jobs does. Without that header a call never turns into a job.
Run a text job
- Submit the text to POST /v1/redact/jobs with a new UUID in
Idempotency-Key. The answer is 202 with the job'sid, andLocationpoints to its status. - Read GET /v1/redact/jobs/{id} until
stateissucceeded. Wait at least 2 seconds between reads, because each read counts against your request limit. - Read the result at GET /v1/redact/jobs/{id}/result. It is the same as POST /v1/redact returns.
- Delete the job with DELETE /v1/redact/jobs/{id} when you have what you need.
Build the body from a text file with jq, then submit it:
jq -n --rawfile text sagsakt.txt \
'{text: $text, detectors: ["regex", "ner"], include: ["mapping"]}' > job.json
curl -i https://api.komplai.dk/v1/redact/jobs \
-H "Authorization: Bearer $KOMPLAI_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data @job.json{
"id": "3f2b8c1e-7d4a-4e9b-a6c5-2d1e0f9a8b7c",
"state": "queued",
"createdAt": "2026-10-20T08:00:00.000Z",
"expiresAt": "2026-10-20T09:00:00.000Z",
"progress": { "completed": 0, "total": 0 },
"reused": false
}Put the id in JOB_ID, then read the status, the result and delete the job:
curl "https://api.komplai.dk/v1/redact/jobs/$JOB_ID" \
-H "Authorization: Bearer $KOMPLAI_TOKEN"
curl "https://api.komplai.dk/v1/redact/jobs/$JOB_ID/result" \
-H "Authorization: Bearer $KOMPLAI_TOKEN"
curl -X DELETE "https://api.komplai.dk/v1/redact/jobs/$JOB_ID" \
-H "Authorization: Bearer $KOMPLAI_TOKEN"What to know about jobs:
stateisqueued,running,succeeded,failed,cancelledorinvalidated. The last three are final and carryerrorwith the reason. The result route answers 202 while the job runs and 410 when it ended without a result.- If the connection drops while you submit, send the same request again with the same
Idempotency-Key. Within the job's lifetime you get the same job back (reused: true) instead of a second one. Never reuse a key for another text: that answers idempotency_conflict. - A job belongs to the key that created it. Another key gets 404, and after an organisation integration key is rotated or revoked, its jobs can no longer be read.
- A job has no time limit of its own while it makes progress. One that makes no progress for 65 minutes fails with redact_job_stalled. A job that is interrupted, for example by a restart, resumes from its encrypted checkpoint; a call to the EU cloud that was in flight is sent again once and counts towards the monthly AI spending cap like any call.
- Text jobs with personal data need a storage policy approved for your organisation (approved_redact_storage_policy_required).
PDF and Word files
Reading PDF and Word files is in Beta. POST /v1/redact/file takes a PDF with a text layer or a Word .docx as base64 in JSON, extracts its text and runs the same Redact processing as for text, with the same options. The answer is pseudonymised text, not a redacted file. pages gives each page's offsets in the extracted text and in redacted; in a .docx they are the body split at the page breaks Word recorded, then headers, footers, footnotes and endnotes as one part each. Besides the reports above, include takes source: the extracted text the offsets refer to.
jq -n --rawfile data <(base64 < afgoerelse.pdf | tr -d '\n') \
'{file: {filename: "afgoerelse.pdf", data: $data}, detectors: ["regex", "ner"], include: ["mapping"]}' > file.json
curl https://api.komplai.dk/v1/redact/file \
-H "Authorization: Bearer $KOMPLAI_TOKEN" \
-H "Content-Type: application/json" \
--data @file.json- At most 50 MiB, 2,000 PDF pages and 750,000 UTF-16 code units of extracted text, with the same 300 seconds as POST /v1/redact. Longer files go to POST /v1/redact/file/jobs, which extracts the text while you wait and submits it as a text job; poll, read, restore and delete it with the
/v1/redact/jobs/{id}operations. - No OCR runs. A page without a text layer, such as a scan, refuses the file with scanned_pages_not_supported and the page numbers. With
allowPagesWithoutText: truethe other pages are processed and those pages are reported withhasText: false. - Password-protected PDFs answer encrypted_file. Word
.docfiles are not read; save them as.docx.
Put the originals back
POST /v1/redact/restore puts the originals back into a text with labels, such as an answer from your own model about the pseudonymised text. Send the text and either the list of labels (mapping) exactly as POST /v1/redact returned it, or job with the id of your own finished text job that was submitted with include: ["mapping"] or ["changes"]. With a job, komplai reads the retained result and you do not hold the list yourself.
curl https://api.komplai.dk/v1/redact/restore \
-H "Authorization: Bearer $KOMPLAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Kære [PERSON-1]. Vi har modtaget din ansøgning og svarer på [EMAIL-1].",
"mapping": {
"[PERSON-1]": "Mette Kjærgaard Holm",
"[CPR-1]": "120385-1234",
"[ADRESSE-1]": "Fiktivvej 12, 8000 Aarhus C",
"[TLF-1]": "21 34 56 78",
"[EMAIL-1]": "mette.holm@example.invalid"
}
}'{
"text": "Kære Mette Kjærgaard Holm. Vi har modtaget din ansøgning og svarer på mette.holm@example.invalid.",
"restored": 2,
"unknownPlaceholders": []
}- Labels match regardless of case and of spaces inside the brackets. A label that is not in the list stays in the text and is listed in
unknownPlaceholders. - A fixed replacement from your organisation's rules, such as
[SAGSNR], can stand for several originals, so it is never restored; those in the text are listed infixedTokens. - No model runs and the call has no AI charge. It counts against your request limit.
- The answer contains personal data. Protect it like the source document.
What is processed where, and what is stored
- Rules run on komplai's servers in Germany (EU).
- NER runs in Germany (
eu-de) or on local infrastructure in Denmark (local). - The language model runs on local infrastructure in Denmark, or in komplai's EU cloud in France with
processing: "eu-cloud". Data does not leave the EU. - Files are read in memory on komplai's API servers in Germany and are not stored. POST /v1/redact/file/jobs stores the extracted text encrypted as a text job, with that job's retention.
- Synchronous calls hold the text in memory while the call runs. The result exists only in the response.
- Text jobs keep the text encrypted while the job is queued or running, and the result encrypted until you delete the job or it expires at
expiresAt. Reading the result does not delete it. The retention period comes from your organisation's storage policy and counts from when the job ends. Without such a policy, a job with test data is kept for one hour. - Usage and audit records hold metadata, such as who called what and when, not your text.
Where data is processed describes every tool.
Reference and errors
- Redact reference: every field and response, with code samples and a console to try each call.
- Account reference: GET /v1/access.
- Errors: every code with what to do. Rate limits are on Limits.
- Redact in Word uses the same Redact from inside a Word document.