PII Pseudonymizer

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.

Request (curl)
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
  }'
Response
{
  "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.

MethodURLPurposeCounts toward the limit
POSTapi.php?action=pseudonymizeReplace personal data with fake valuesYes
POSTapi.php?action=restorePut original values back using a replacement listYes
GETapi.php?action=infoCurrent limits, languages and detector idsNo
GETapi.php?action=openapiOpenAPI 3.0 description for code generators and API toolsNo
GETapi.php?action=usageRequests and limits of your API key, with a 12-month historyNo
GETapi.php?action=healthStatus 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

  1. Pseudonymize the text and keep the returned mapping in your application’s memory.
  2. Check the result if a person will see it, or if it leaves your organisation. Detection is automatic and can miss data.
  3. Use the safe text with the external service, for example an AI model, translator or ticket system.
  4. Restore the original values in the reply, with the restore action or with your own find-and-replace using the mapping.
  5. 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

FieldTypeDefaultDescription
textstring—The text to pseudonymize. Required unless you send texts. Up to 50,000 characters.
textsarray of strings—Up to 20 texts processed together (see several texts). Send either text or texts.
accept_termsboolean—Required. Must be true.
localestringautoLanguage 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.
detectarray of stringsallDetectors to use. Ids are listed below.
disablearray of stringsnoneDetectors to skip. Use either detect or disable.
known_namesarray 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_replacearray 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.
keystringemptyConsistency 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_mappingbooleantrueReturn the replacement list. Needed for restoring.

Detector ids

IdReplaces
nameNames: People’s names after greetings, titles and labels, then every other mention, including other grammatical cases
ml_nameNames without cues: A small machine-learning model finds names with no title or greeting, e.g. “Luka je poklical”
addressStreet addresses: Streets with house numbers and P.O. boxes
postcodePostcodes and cities: Postcode followed by a town, e.g. 1000 Ljubljana or D-10115 Berlin
phonePhone numbers: Local and international numbers; the country code is kept
emailE-mail addresses: Replaced with addresses at example.com
ibanIBANs: Bank accounts in IBAN format; only numbers with a valid checksum
national_idPersonal ID and tax numbers: EMŠO/JMBG, OIB, Slovenian tax number, Steuer-ID, Austrian and German social security numbers, VAT IDs
credit_cardPayment cards: Card numbers with a valid Luhn checksum
date_of_birthDates of birth: Dates after words like born, rojen, rođen, рођен or geboren
accountCustomer, contract and document numbers: Numbers after labels such as customer no., passport, pogodba št., broj računa
secretPINs, passwords and usernames: Values after labels such as PIN, CVV, password, geslo, lozinka
ipIP addresses: IPv4 and IPv6
ssnUS 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.

Request with all options
{
  "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

FieldTypeDescription
okbooleantrue on success.
text / textsstring / arrayThe pseudonymized text(s), in the same form as the request.
locale / localesstring / arrayLanguage used: en, de, sl, hr, sr or bs.
replacementsintegerNumber of entries in the replacement list.
mappingarrayObjects with type, original and pseudonym. Different grammatical forms of a name are separate entries. Contains the real data.
fallbackbooleantrue 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.
warningstring or nullExplanation when fallback is true.
processing_msintegerProcessing 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.

Request
{
  "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
}
Response
{
  "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”.

FieldTypeDescription
text / textsstring / arrayText(s) containing fake values. Same limits as for pseudonymize.
mappingarrayObjects with original and pseudonym; keep type so that names are restored in all grammatical forms. Up to 10,000 entries.
accept_termsbooleanRequired. Must be true.
Request
{
  "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
}
Response
{
  "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.

Requests (curl)
curl -s 'http://pii.kwizmo.eu/api.php?action=info&pretty'
curl -s 'http://pii.kwizmo.eu/api.php?action=openapi' -o openapi.json

API 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:

Header
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 minute10 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 body1,048,576 bytes
Text length50,000 characters in total
Texts per request20
Known names200, each up to 100 characters
Never replace200, each up to 100 characters
Consistency key200 characters
Replacement list for restore10,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.

Error response
{
  "ok": false,
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. The limit is 10 requests per minute per network.",
    "retry_after": 42
  }
}
StatusCodeMeaning and fix
400invalid_jsonThe body is not a JSON object.
400invalid_fieldA field is missing, has the wrong type or an unknown value; see field.
400terms_not_acceptedAdd "accept_terms": true.
400empty_textAll texts are empty.
400too_many_texts, too_many_names, name_too_long, key_too_long, too_many_mapping_entriesA size limit was exceeded; see limits.
403https_requiredCall the API over HTTPS.
404unknown_actionUse info, openapi, pseudonymize or restore.
405method_not_allowedUse POST for pseudonymize and restore, GET for info and openapi.
413payload_too_large, text_too_longSplit the text into smaller requests.
415unsupported_media_typeSend Content-Type: application/json.
401api_key_required, invalid_api_key, api_key_disabledSend a valid key: Authorization: Bearer pii_….
403api_key_scopeThe key may not be used for the API.
429rate_limitedWait retry_after seconds.
429quota_exceededYour organisation's monthly allowance is used up.
500internal_error, restore_failedTry again later or with a smaller request.
503service_unavailable, api_disabledThe 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 7.4+
<?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'];
JavaScript
// 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 });
Python 3 (requests)
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": false if 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 texts or a key when separate texts must refer to the same fake people. Without either, every request gets new fake values.
  • Check fallback on 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