123POST /api/v1/mask Authorization: Bearer mk_live_... Content-Type: application/json
1234{ "text": "Patient John Doe, ID 1012345672, MRN H123456", "key": null }
12345678910111213141516{ "masked_text": "Patient <MASKED_PERSON_a1b2c3d4>, ID <MASKED_NATIONAL_ID_e5f6a7b8>, MRN <MASKED_MRN_c9d0e1f2>", "ciphertext": "k9HnBq8…", "nonce_payload": "tEpQrSt…", "wrapped_dek": "uVwXyZa…", "nonce_dek": "bCdEfGh…", "pii_count": 3, "spans": [ {"start": 8, "end": 16, "type": "PERSON"}, {"start": 21, "end": 31, "type": "NATIONAL_ID"}, {"start": 37, "end": 44, "type": "MRN"} ], "request_id": "mask_346fc866", "processing_time_ms": 41, "judge_used": false }
spans carries exactly one entry per detected entity, under its
canonical name, so pii_count equals len(spans). Responses produced
before August 2026 also carried legacy-named sibling spans for seven
Saudi types; see Legacy alias spans.MRN is an extended entity type, off by default on new accounts.
Enable it under Settings → Entities (or PUT /me/entities) or the
example above detects only PERSON and NATIONAL_ID. See
PII types.| Field | Type | Required | Description |
text | string | yes | The text to mask. Min length 1. |
key | string | — | Optional 32-byte AES key (base64) for BYOK mode. When set, the response omits wrapped_dek/nonce_dek and the mappings are encrypted directly with this key. |
use_judge | boolean | — | Opt into the LLM judge for this request (subject to the per-key judge_allowed gate). Also settable via the X-Use-Judge: true header (header wins). |
glossary | string[] | — | Inline custom terms to also mask (as CUSTOM_TERM). |
glossary_id | string (UUID) | — | A persisted glossary to apply. Enabled glossaries auto-apply without this. |
| Field | Type | Description |
masked_text | string | Text with each detected entity replaced by an opaque token like <MASKED_PERSON_a1b2c3d4>. Type can contain underscores; suffix is 8 hex chars. One token is minted per distinct (entity type, surface text) pair and reused for every repeat — if "John Doe" appears three times, all three occurrences carry the same token and the mappings object has one entry for it. |
ciphertext | string (base64) | AES-GCM ciphertext of the JSON token-mappings object. |
nonce_payload | string (base64) | 12-byte nonce for the payload AES-GCM op. |
wrapped_dek | string (base64) | null | DEK wrapped under the KEK derived from your bearer (envelope mode). Null when key (BYOK) was sent. |
nonce_dek | string (base64) | null | Nonce for the wrapped DEK. Null when key (BYOK) was sent. |
pii_count | integer | Number of canonical PII entities detected, post-deduplication. This, not len(spans), is the entity count. |
spans | array | Each detected span: {start, end, type} (character offsets into the original text, right-half-open, so text[start:end] is the span). One entry per detected entity, canonical names only, so len(spans) equals pii_count; still prefer pii_count as the entity count. (Before August 2026, seven Saudi types also emitted a legacy-named sibling span at identical offsets. See the note below.) |
request_id | string | Stable per-request id (mask_<8hex>). |
processing_time_ms | integer | Server-side wall-clock for the request. |
judge_used | boolean | Whether the LLM judge actually ran. |
spans for backwards compatibility: once under the
canonical name and once under the older legacy name, at exactly the same
start/end. That dual-emit was retired; spans now carries canonical
names only, and the legacy names below no longer appear in responses:Canonical type | Retired legacy sibling type |
NATIONAL_ID | SAUDI_NATIONAL_ID |
RESIDENCE_PERMIT | IQAMA |
PASSPORT | SAUDI_PASSPORT |
IBAN | IBAN_SA |
BUSINESS_ID | CR_NUMBER |
TAX_ID | ZAKAT_NUMBER |
MRN | MRN_MEDICAL |
masked_text,
pii_count, billing, and the usage-stats PII breakdown, so those values
are identical before and after the retirement.spans for a legacy name in the right-hand
column, switch to the canonical name. If you replay stored responses
from before the retirement, de-duplicate spans by (start, end) and
keep the first entry. A server-side compatibility switch exists to
temporarily restore dual-emit during the transition; contact support if
you need it.| Status | Description |
| 200 | Mask succeeded; envelope returned. |
| 400 | Invalid request body, e.g. "key must be valid base64", "key must decode to 32 bytes (256 bits), got {n}", "unknown_judge_model". |
| 401 | Missing or invalid bearer key. |
| 402 | insufficient_tokens: free quota and paid balance both exhausted. Body carries needed_tokens / available_tokens / topup_url. |
| 403 | Bare 403, no JSON body — the request reached api.kindi.me from outside Saudi Arabia. Not an auth failure; see Error codes. |
| 404 | "Glossary not found" — the glossary_id you sent doesn't belong to you (or doesn't exist). Not billed. |
| 422 | Schema validation failed (wrong types, text empty). |
| 429 | Rate-limit exceeded; check Retry-After header. |
| 503 | masker_unavailable: the detection service is unreachable or overloaded. The request is refunded (best-effort); carries Retry-After on overload. |
/mask returns an envelope you decrypt client-side; the mapping
never leaves your process. If you want KINDI to call OpenAI/Anthropic for
you instead, see the LLM Proxy, which masks and
unmasks server-side (the mapping lives only in the proxy request and is
discarded when it finishes).insufficient_tokens when the user's free quota and paid
balance are both exhausted. The response body includes needed_tokens,
available_tokens, and topup_url.