Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.
Mask PII in a text payload. Returns the masked text plus an encrypted envelope you can decrypt client-side to recover the original spans.
POST /api/v1/mask Authorization: Bearer mk_live_... Content-Type: application/json

Example

Request:
{ "text": "Patient John Doe, ID 1012345672, MRN H123456", "key": null }
Response (envelope mode):
{ "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.

Request

FieldTypeRequiredDescription
textstringyesThe text to mask. Min length 1.
keystringOptional 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_judgebooleanOpt 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).
glossarystring[]Inline custom terms to also mask (as CUSTOM_TERM).
glossary_idstring (UUID)A persisted glossary to apply. Enabled glossaries auto-apply without this.

Response

FieldTypeDescription
masked_textstringText 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.
ciphertextstring (base64)AES-GCM ciphertext of the JSON token-mappings object.
nonce_payloadstring (base64)12-byte nonce for the payload AES-GCM op.
wrapped_dekstring (base64) | nullDEK wrapped under the KEK derived from your bearer (envelope mode). Null when key (BYOK) was sent.
nonce_dekstring (base64) | nullNonce for the wrapped DEK. Null when key (BYOK) was sent.
pii_countintegerNumber of canonical PII entities detected, post-deduplication. This, not len(spans), is the entity count.
spansarrayEach 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_idstringStable per-request id (mask_<8hex>).
processing_time_msintegerServer-side wall-clock for the request.
judge_usedbooleanWhether the LLM judge actually ran.

Legacy alias spans (retired)

Until August 2026, seven Saudi-specific entity types were emitted twice in 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 typeRetired legacy sibling type
NATIONAL_IDSAUDI_NATIONAL_ID
RESIDENCE_PERMITIQAMA
PASSPORTSAUDI_PASSPORT
IBANIBAN_SA
BUSINESS_IDCR_NUMBER
TAX_IDZAKAT_NUMBER
MRNMRN_MEDICAL
Nothing else changed: the canonical span always drove masked_text, pii_count, billing, and the usage-stats PII breakdown, so those values are identical before and after the retirement.
If your integration greps 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 codes

StatusDescription
200Mask succeeded; envelope returned.
400Invalid request body, e.g. "key must be valid base64", "key must decode to 32 bytes (256 bits), got {n}", "unknown_judge_model".
401Missing or invalid bearer key.
402insufficient_tokens: free quota and paid balance both exhausted. Body carries needed_tokens / available_tokens / topup_url.
403Bare 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.
422Schema validation failed (wrong types, text empty).
429Rate-limit exceeded; check Retry-After header.
503masker_unavailable: the detection service is unreachable or overloaded. The request is refunded (best-effort); carries Retry-After on overload.
See the BYOK example if you need to pin the encryption key to a customer-managed value rather than deriving it from the API key.
/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).
Returns 402 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.