API documentation
The API does the same as the web page: it replaces names, addresses, phone numbers, ID, tax and bank numbers and other personal data with realistic fake values, and can put the original values back afterwards. It speaks JSON over HTTPS and stores nothing except the time and IP address of each request. You can use it without an account; business API keys give higher limits.
Building on OpenAI-style chat models? The LLM gateway does the whole round trip for you. Want the engine in the browser? See assets/js/pii-core.js on a self-hosted installation.
Quick start
Send a POST request with a JSON body. Every request that processes text must contain "accept_terms": true to confirm that you accept the terms of service.
curl -s -X POST 'http://pii.kwizmo.eu/api.php?action=pseudonymize' \
-H 'Content-Type: application/json' \
-d '{
"text": "Spoštovani gospod Novak, moje ime je Maja Kovačič. Tel: 041 123 456",
"accept_terms": true
}'{
"ok": true,
"text": "Spoštovani gospod Zajc, moje ime je Irena Božič. Tel: 078 369 169",
"locale": "sl",
"replacements": 4,
"mapping": [
{ "type": "phone", "original": "041 123 456", "pseudonym": "078 369 169" },
{ "type": "name", "original": "Novak", "pseudonym": "Zajc" },
{ "type": "name", "original": "Maja", "pseudonym": "Irena" },
{ "type": "name", "original": "Kovačič", "pseudonym": "Božič" }
],
"fallback": false,
"warning": null,
"processing_ms": 14
}Endpoints
Base URL: http://pii.kwizmo.eu/api.php. The action is chosen with the action query parameter. Add &pretty to any URL for indented JSON.
| Method | URL | Purpose | Counts toward the limit |
|---|---|---|---|
| POST | api.php?action=pseudonymize | Replace personal data with fake values | Yes |
| POST | api.php?action=restore | Put original values back using a replacement list | Yes |
| GET | api.php?action=info | Current limits, languages and detector ids | No |
| GET | api.php?action=openapi | OpenAPI 3.0 description for code generators and API tools | No |
| GET | api.php?action=usage | Requests and limits of your API key, with a 12-month history | No |
| GET | api.php?action=health | Status for uptime monitoring (200 or 503) | No |
POST bodies must be sent with Content-Type: application/json and UTF-8 encoding. Cross-origin requests from browsers are allowed.
Recommended workflow
- Pseudonymize the text and keep the returned
mappingin your application’s memory. - Check the result if a person will see it, or if it leaves your organisation. Detection is automatic and can miss data.
- Use the safe text with the external service, for example an AI model, translator or ticket system.
- Restore the original values in the reply, with the
restoreaction or with your own find-and-replace using the mapping. - Discard the mapping when you no longer need it. It contains the real personal data.
Pseudonymize
POST http://pii.kwizmo.eu/api.php?action=pseudonymize
Request fields
| Field | Type | Default | Description |
|---|---|---|---|
text | string | — | The text to pseudonymize. Required unless you send texts. Up to 50,000 characters. |
texts | array of strings | — | Up to 20 texts processed together (see several texts). Send either text or texts. |
accept_terms | boolean | — | Required. Must be true. |
locale | string | auto | Language of the text. Fake names, streets and postcodes are chosen for it. One of: auto, en, de, at, ch, sl, hr, sr, bs, me, da, no, sv, fi, is. Set it for short texts; bs and me are never detected automatically. |
detect | array of strings | all | Detectors to use. Ids are listed below. |
disable | array of strings | none | Detectors to skip. Use either detect or disable. |
known_names | array of strings | [] | Full names to replace everywhere, first name first, including other grammatical cases (Janez, Janeza, Janezu). Names are otherwise found only after cues such as “Dear”, “gospod” or “Lep pozdrav”. Up to 200 names of 100 characters. |
never_replace | array of strings | [] | Words and phrases that must be left alone: your own company, your products, a colleague who is not a customer. Matching ignores case, takes whole words only, and keeps the case endings of the language (Kwizmo, Kwizmom, Kwizmolle). These words are hidden from every detector, so an address or an e-mail that contains one is left alone too. Up to 200 entries. |
key | string | empty | Consistency key. Empty: each request gets new fake values. The same key always produces the same fake values, also on the web page. Anyone who knows the key gets the same results, so use a long random value. |
include_mapping | boolean | true | Return the replacement list. Needed for restoring. |
Detector ids
| Id | Replaces |
|---|---|
name | Names: People’s names after greetings, titles and labels, then every other mention, including other grammatical cases |
ml_name | Names without cues: A small machine-learning model finds names with no title or greeting, e.g. “Luka je poklical” |
address | Street addresses: Streets with house numbers and P.O. boxes |
postcode | Postcodes and cities: Postcode followed by a town, e.g. 1000 Ljubljana or D-10115 Berlin |
phone | Phone numbers: Local and international numbers; the country code is kept |
email | E-mail addresses: Replaced with addresses at example.com |
iban | IBANs: Bank accounts in IBAN format; only numbers with a valid checksum |
national_id | Personal ID and tax numbers: EMŠO/JMBG, OIB, Slovenian tax number, Steuer-ID, Austrian and German social security numbers, VAT IDs |
credit_card | Payment cards: Card numbers with a valid Luhn checksum |
date_of_birth | Dates of birth: Dates after words like born, rojen, rođen, рођен or geboren |
account | Customer, contract and document numbers: Numbers after labels such as customer no., passport, pogodba št., broj računa |
secret | PINs, passwords and usernames: Values after labels such as PIN, CVV, password, geslo, lozinka |
ip | IP addresses: IPv4 and IPv6 |
ssn | US Social Security numbers: Format 123-45-6789 |
A value excluded from one detector can still be caught by a more general one. For example, a labelled card number can be replaced as a document number when credit_card is disabled.
{
"text": "Poštovani gospodine Kovačeviću, šaljem ugovor br. 45/2023.",
"locale": "hr",
"disable": ["ip", "email"],
"known_names": ["Ivana Marić", "Petar Babić"],
"never_replace": ["Kwizmo d.o.o."],
"key": "b6Jq0v7yZ3-our-team-key",
"include_mapping": true,
"accept_terms": true
}Response fields
| Field | Type | Description |
|---|---|---|
ok | boolean | true on success. |
text / texts | string / array | The pseudonymized text(s), in the same form as the request. |
locale / locales | string / array | Language used: en, de, sl, hr, sr or bs. |
replacements | integer | Number of entries in the replacement list. |
mapping | array | Objects with type, original and pseudonym. Different grammatical forms of a name are separate entries. Contains the real data. |
fallback | boolean | true if detailed detection failed and a coarse redaction was returned instead: every capitalized word becomes [redacted], every digit #, every e-mail [email]. The original text is never returned. |
warning | string or null | Explanation when fallback is true. |
processing_ms | integer | Processing time on the server. |
Several texts at once
Send texts instead of text to process related texts, such as the messages of one conversation, in one request. The same person then gets the same fake name in every text, in every grammatical case, even without a key. The total length of all texts must stay within 50,000 characters, and the request counts once toward the rate limit.
{
"texts": [
"Poštovani gospodine Kovačeviću, šaljem ugovor.",
"Razgovarao sam s gospodinom Kovačevićem. Kovačević se slaže."
],
"locale": "hr",
"accept_terms": true
}{
"ok": true,
"texts": [
"Poštovani gospodine Blaževiću, šaljem ugovor.",
"Razgovarao sam s gospodinom Blaževićem. Blažević se slaže."
],
"locales": ["hr", "hr"],
"replacements": 3,
"mapping": [
{ "type": "name", "original": "Kovačević", "pseudonym": "Blažević" },
{ "type": "name", "original": "Kovačeviću", "pseudonym": "Blaževiću" },
{ "type": "name", "original": "Kovačevićem", "pseudonym": "Blaževićem" }
],
"fallback": false,
"warning": null,
"processing_ms": 9
}Restore
POST http://pii.kwizmo.eu/api.php?action=restore replaces fake values with the originals, using a replacement list you send along. The server keeps no replacement lists, so you must always provide one; the mapping from a pseudonymize response can be sent unchanged. Only whole words are replaced, longest fake values first, and every position is replaced once. Fake names are also recognised in grammatical forms that are not in the list: if an AI answer says “Mlakarju” and the list only has “Novak → Mlakar”, the result is “Novaku”.
| Field | Type | Description |
|---|---|---|
text / texts | string / array | Text(s) containing fake values. Same limits as for pseudonymize. |
mapping | array | Objects with original and pseudonym; keep type so that names are restored in all grammatical forms. Up to 10,000 entries. |
accept_terms | boolean | Required. Must be true. |
{
"text": "Hvala, gospodine Blaževiću. Blažević će dobiti odgovor danas.",
"mapping": [
{ "original": "Kovačević", "pseudonym": "Blažević" },
{ "original": "Kovačeviću", "pseudonym": "Blaževiću" }
],
"accept_terms": true
}{
"ok": true,
"text": "Hvala, gospodine Kovačeviću. Kovačević će dobiti odgovor danas.",
"processing_ms": 1
}You can also restore in your own code and never send the real data back to the service: PiiPseudonymizer::restoreWithMapping() in PHP or PiiPseudonymizer.restoreWithMapping() in pii-core.js do exactly what this endpoint does.
Info and OpenAPI
GET ?action=info returns the current limits, the language and detector ids, and data retention details. GET ?action=openapi returns an OpenAPI 3.0 document for tools such as Postman, Insomnia or client generators. Neither request counts toward the rate limit.
curl -s 'http://pii.kwizmo.eu/api.php?action=info&pretty'
curl -s 'http://pii.kwizmo.eu/api.php?action=openapi' -o openapi.jsonAPI keys, usage and health
Business customers receive API keys with higher limits and a monthly allowance (plans). The allowance belongs to your organisation, so you may hold several keys — production, staging, one per product — that share it at no extra cost. Send the key with every request:
Authorization: Bearer pii_…
# or
X-PII-Key: pii_…With a key, the per-minute limit applies to the key instead of your network, and responses include X-Quota-Limit and X-Quota-Remaining. GET ?action=usage shows what your organisation has used this month, what this particular key used (this_key), how many requests are left, when the allowance resets, and the last twelve months; add &month=2026-09 for an earlier month. A missing or wrong key gives status 401 (invalid_api_key, api_key_required, api_key_disabled); a used-up allowance gives 429 with quota_exceeded. We count requests for billing; their content is not stored.
GET ?action=health needs no key and answers 200 with "status": "operational" when the engine, configuration and storage work, otherwise 503. Point your uptime monitor at it.
Rate limits and sizes
| Requests per minute | 10 per IP address (IPv6: per /64 network), shared between the web page and the API; with an API key: the key's own limit |
|---|---|
| Request body | 1,048,576 bytes |
| Text length | 50,000 characters in total |
| Texts per request | 20 |
| Known names | 200, each up to 100 characters |
| Never replace | 200, each up to 100 characters |
| Consistency key | 200 characters |
| Replacement list for restore | 10,000 entries |
The window is a sliding 60 seconds. Successful pseudonymize and restore responses include X-RateLimit-Limit and X-RateLimit-Remaining. When the limit is reached the API answers with status 429, a Retry-After header and retry_after in the body; wait that many seconds before trying again. Need more? Contact info@kwizmo.eu.
Errors
Errors return "ok": false and an error object with a stable code, a readable message and, where it helps, the field that caused it. Messages may change; check the code.
{
"ok": false,
"error": {
"code": "rate_limited",
"message": "Too many requests. The limit is 10 requests per minute per network.",
"retry_after": 42
}
}| Status | Code | Meaning and fix |
|---|---|---|
| 400 | invalid_json | The body is not a JSON object. |
| 400 | invalid_field | A field is missing, has the wrong type or an unknown value; see field. |
| 400 | terms_not_accepted | Add "accept_terms": true. |
| 400 | empty_text | All texts are empty. |
| 400 | too_many_texts, too_many_names, name_too_long, key_too_long, too_many_mapping_entries | A size limit was exceeded; see limits. |
| 403 | https_required | Call the API over HTTPS. |
| 404 | unknown_action | Use info, openapi, pseudonymize or restore. |
| 405 | method_not_allowed | Use POST for pseudonymize and restore, GET for info and openapi. |
| 413 | payload_too_large, text_too_long | Split the text into smaller requests. |
| 415 | unsupported_media_type | Send Content-Type: application/json. |
| 401 | api_key_required, invalid_api_key, api_key_disabled | Send a valid key: Authorization: Bearer pii_…. |
| 403 | api_key_scope | The key may not be used for the API. |
| 429 | rate_limited | Wait retry_after seconds. |
| 429 | quota_exceeded | Your organisation's monthly allowance is used up. |
| 500 | internal_error, restore_failed | Try again later or with a smaller request. |
| 503 | service_unavailable, api_disabled | The service is temporarily unavailable or the API is switched off. |
A 200 response with "fallback": true is not an error, but the text was only coarsely redacted. Treat it as a signal to retry with a shorter text.
Examples in PHP, JavaScript and Python
All examples pseudonymize a text, pass it on, and restore the reply. They retry once the rate limit allows it.
<?php
// PHP 7.4+, no Composer or extensions needed.
function piiApi(string $action, array $payload): array
{
$payload['accept_terms'] = true;
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE),
'timeout' => 30,
'ignore_errors' => true, // read the JSON body of 4xx/5xx responses too
],
]);
for ($attempt = 1; $attempt <= 3; $attempt++) {
$body = file_get_contents('http://pii.kwizmo.eu/api.php?action=' . urlencode($action), false, $context);
$data = json_decode((string) $body, true);
if (!is_array($data)) {
throw new RuntimeException('The PII service did not return JSON.');
}
if ($data['ok'] ?? false) {
return $data;
}
if (($data['error']['code'] ?? '') === 'rate_limited' && $attempt < 3) {
sleep((int) ($data['error']['retry_after'] ?? 60)); // wait as instructed, then retry
continue;
}
throw new RuntimeException($data['error']['message'] ?? 'PII service error');
}
throw new RuntimeException('The PII service stayed rate limited.');
}
// 1. Pseudonymize before sending the text anywhere
$result = piiApi('pseudonymize', [
'text' => $ticketText,
'locale' => 'sl',
'known_names' => [$customer['full_name']],
]);
$safeText = $result['text'];
$mapping = $result['mapping']; // contains real data: keep it in memory only
// 2. Use the safe text with an external service
$reply = $externalService->answer($safeText);
// 3. Put the real values back into the reply
$restored = piiApi('restore', ['text' => $reply, 'mapping' => $mapping]);
echo $restored['text'];// Browser or Node.js 18+
async function piiApi(action, payload) {
const response = await fetch('http://pii.kwizmo.eu/api.php?action=' + action, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, accept_terms: true }),
});
const data = await response.json();
if (!data.ok) {
const err = new Error(data.error.message);
err.code = data.error.code;
err.retryAfter = data.error.retry_after;
throw err;
}
return data;
}
const { text: safeText, mapping } = await piiApi('pseudonymize', {
text: 'Sehr geehrte Frau Dr. Müller, meine Adresse ist Karl-Marx-Straße 12, 10115 Berlin.',
locale: 'de',
});
const reply = await askSomeService(safeText);
const { text: realReply } = await piiApi('restore', { text: reply, mapping });import time
import requests
API = "http://pii.kwizmo.eu/api.php"
def pii_api(action, payload, retries=3):
payload = {**payload, "accept_terms": True}
for attempt in range(retries):
r = requests.post(API, params={"action": action}, json=payload, timeout=30)
data = r.json()
if data.get("ok"):
return data
if data["error"]["code"] == "rate_limited" and attempt < retries - 1:
time.sleep(int(r.headers.get("Retry-After", 60)))
continue
raise RuntimeError(f'{data["error"]["code"]}: {data["error"]["message"]}')
result = pii_api("pseudonymize", {
"texts": ["Зовем се Јелена Јовановић, ЈМБГ 2505905710011.", "Јелена ће доћи сутра."],
"locale": "sr",
"key": "a-long-random-key-for-this-project",
})
safe_texts = result["texts"]
restored = pii_api("restore", {"texts": safe_texts, "mapping": result["mapping"]})Good practice
- Treat the mapping as personal data. Keep it in memory, or encrypted if you must store it, and delete it when the conversation is over. Set
"include_mapping": falseif you never need to restore. - Pass the names you know. Customer and employee names from your own database make detection much more reliable, especially for names without a greeting or title nearby.
- Set the language when you know it. Short texts are hard to detect; Bosnian and Montenegrin must always be set explicitly, and Danish and Norwegian are easily confused with each other.
- Use
textsor akeywhen separate texts must refer to the same fake people. Without either, every request gets new fake values. - Check
fallbackon every response and log it. - Review before publishing. Detection is heuristic: it can miss data and can replace ordinary words. The result is pseudonymized, not anonymized, and may still identify a person through its context.
- Only process data you are allowed to process, and respect the rate limit instead of spreading requests across many addresses.
Privacy and terms
For each pseudonymize or restore request, the service stores only the request time and the IP address, for up to 30 days, to enforce rate limits and prevent abuse; for requests with an API key also the key id and a monthly request counter. Texts, results, replacement lists, names, keys and options are processed in memory and never stored or logged. Use of the API is subject to the terms of service; Kwizmo d.o.o. is not responsible for misuse or for personal data that is missed, wrongly detected or wrongly replaced.
Questions: info@kwizmo.eu
