+, /, =
padding), not base64URL (-, _, no padding).12345# ✓ correct base64.b64decode(resp["ciphertext"]) # ✗ wrong: silently produces malformed bytes for some payloads base64.urlsafe_b64decode(resp["ciphertext"])
cryptography.exceptions.InvalidTag on decrypt, even though
the key and nonces are correct.masker-kek-salt-v1masker-kek-v1InvalidTag. Double-check the strings
character-by-character; master-... vs masker-... is a common typo.InvalidTag.12KEK → unwrap(wrapped_dek, nonce_dek) → DEK DEK → decrypt(ciphertext, nonce_payload) → mappings
wrapped_dek is null you sent key; that's BYOK mode,
which is the one-stage variant. Decrypt the payload directly under
your BYOK key.| Endpoint | Token shape | Regex |
/mask | <MASKED_PERSON_a1b2c3d4> | <MASKED_([A-Z_]+)_([a-f0-9]{8})> |
/redact | PERSON_NAME_01 | ([A-Z_]+)_(\d{2,}) |
/mask-output regex returns zero matches; /redact-output
regex returns garbage matches that span across mask tokens. See
PII types: placeholder shape for the full
breakdown.12345678# ✗ wrong logger.info("calling kindi", api_key=KINDI_API_KEY) # ✗ also wrong: defeats the entire point of masking logger.info("mappings", mappings=decrypted_mappings) # ✓ correct logger.info("calling kindi", key_id=KINDI_API_KEY.split("_")[2])
/mask + decrypt-it-yourself pattern. The
LLM Proxy deliberately does the mask → LLM → unmask
round-trip server-side inside KINDI; there the mapping lives only in
the proxy request and is discarded when it finishes, so it's a different
trust model, not the mistake above. Pick the proxy when you want KINDI to
make the provider call; pick /mask when the provider call must stay in
your own process./unmask endpoint/mask
call.<MASKED_PERSON_a1b2c3d4> → "the patient") or hallucinate a
similar-looking but invalid token. For high-stakes pipelines:123456required = set(mappings.keys()) present = set(re.findall(r"<MASKED_[A-Z_]+_[a-f0-9]{8}>", llm_reply)) missing = required - present if missing: # warn the user; don't silently render an incomplete reply ...
len(spans) as the PII countpii_count is the entity count; len(spans) merely happens to match it
today. Until August 2026, /mask also emitted a legacy-named sibling
span at identical offsets for seven Saudi types (SAUDI_NATIONAL_ID,
IQAMA, SAUDI_PASSPORT, IBAN_SA, CR_NUMBER, ZAKAT_NUMBER,
MRN_MEDICAL), so spans ran up to twice as long as the entity count.
That dual-emit is retired, but stored responses from before the
retirement still carry the siblings, and counting spans instead of
reading pii_count breaks on exactly those.12345678910111213# ✗ fragile: double-counts Saudi entities in pre-retirement responses count = len(resp["spans"]) # ✓ correct count = resp["pii_count"] # ✓ when replaying a stored pre-retirement response, de-dupe the span list: seen, unique = set(), [] for s in resp["spans"]: k = (s["start"], s["end"]) if k not in seen: seen.add(k) unique.append(s)
/mask → legacy alias spans.| Status | Retry? |
429 | yes; sleep Retry-After seconds, retry once |
503 | yes; the request was refunded, so a retry costs the same as the first attempt. Exponential backoff (1s, 2s, 4s, cap at 30s). |
500 | yes; exponential backoff |
400 / 401 / 402 / 403 / 404 / 422 | no; these are caller bugs (or billing, or geography). Retrying just burns quota. |
401 won't fix itself without a new bearer; 402 won't fix itself
without a top-up; 400/422 won't fix themselves at all; and a bare
403 is the geo-block — the API only accepts
traffic from Saudi IP addresses, and no number of retries changes where
your request comes from.request_id response header.