> Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

---
title: Mask
description: POST /api/v1/mask masks PII and returns an encrypted envelope you can decrypt client-side.
sidebarTitle: POST /mask
---

import { KeyStatusLine } from "../_components/KeyStatusLine";

<KeyStatusLine />

Mask PII in a text payload. Returns the masked text plus an encrypted
envelope you can decrypt client-side to recover the original spans.

```http
POST /api/v1/mask
Authorization: Bearer mk_live_...
Content-Type: application/json
```

## Example

Request:

```json
{
  "text": "Patient John Doe, ID 1012345672, MRN H123456",
  "key": null
}
```

Response (envelope mode):

```json
{
  "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](#legacy-alias-spans-retired).

<Note>
  `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](/reference/pii-types#which-types-are-on-by-default).
</Note>

## Request

| 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](/guides/glossaries) to also mask (as `CUSTOM_TERM`).                                                                                     |
| `glossary_id` | string (UUID) |     —    | A persisted glossary to apply. Enabled glossaries auto-apply without this.                                                                                     |

## Response

| 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.                                                                                                                                                                                                                                                                                                                                                                         |

### 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 `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`                 |

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.

<Note>
  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.
</Note>

## Status codes

| 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](/guides/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.     |

<Note>
  See the [BYOK example](/examples/byok-flow) if you need to pin the
  encryption key to a customer-managed value rather than deriving it
  from the API key.
</Note>

<Note>
  `/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](/proxy/overview), which masks and
  unmasks server-side (the mapping lives only in the proxy request and is
  discarded when it finishes).
</Note>

<Warning>
  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`.
</Warning>
