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

Common mistakes

KINDI's surface is small: two masking endpoints, one envelope shape, one crypto dance. The bugs are correspondingly small: when something breaks, it's almost always one of the items below. This page is sorted by frequency.

1. Base64URL vs standard base64

All KINDI base64 fields use standard base64 (with +, /, = padding), not base64URL (-, _, no padding).
# ✓ correct base64.b64decode(resp["ciphertext"]) # ✗ wrong: silently produces malformed bytes for some payloads base64.urlsafe_b64decode(resp["ciphertext"])
Symptom: cryptography.exceptions.InvalidTag on decrypt, even though the key and nonces are correct.

2. Wrong HKDF salt or info string

The KEK derivation parameters are fixed and ASCII:
  • salt: masker-kek-salt-v1
  • info: masker-kek-v1
Symptom: every decrypt throws InvalidTag. Double-check the strings character-by-character; master-... vs masker-... is a common typo.

3. Decrypting the payload before unwrapping the DEK

Envelope mode is two stages: unwrap DEK with KEK, then decrypt payload with DEK. Skipping the unwrap and trying to decrypt the payload with the KEK directly fails with InvalidTag.
KEK → unwrap(wrapped_dek, nonce_dek) → DEK DEK → decrypt(ciphertext, nonce_payload) → mappings
If 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.

4. Confusing mask and redact token regexes

EndpointToken shapeRegex
/mask<MASKED_PERSON_a1b2c3d4><MASKED_([A-Z_]+)_([a-f0-9]{8})>
/redactPERSON_NAME_01([A-Z_]+)_(\d{2,})
Symptom: /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.

5. Logging or persisting the bearer / decrypted mappings

The bearer derives the KEK, and the KEK unwraps every envelope that bearer ever produced. Logging the bearer at INFO level is functionally equivalent to logging every PII span KINDI ever masked for that user.
# ✗ 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])

6. Decrypting on the server "for convenience"

The whole point of KINDI is that the LLM call happens against masked text. If you decrypt in your API server before passing the result to the LLM, you've put the PII back into the LLM prompt and KINDI did nothing for you.
Decrypt in the same process that finally renders the LLM's reply to the end user: your Next.js Server Component, your iOS app, your CLI. Never between the mask call and the LLM call.
This applies to the /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.

7. Trying to call a /unmask endpoint

There isn't one. There never was. Revoking an API key makes every envelope ever produced under that key permanently unrecoverable, by design. If you need to recover a masked payload you decrypt it client-side, using the same bearer that made the original /mask call.

8. Re-substituting without checking placeholders survived the LLM call

Long contexts can drop placeholders silently. The LLM may paraphrase (<MASKED_PERSON_a1b2c3d4> → "the patient") or hallucinate a similar-looking but invalid token. For high-stakes pipelines:
required = 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 ...

9. Using len(spans) as the PII count

pii_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.
# ✗ 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)
Symptom: your own tally over archived responses disagrees with the usage dashboard, or a highlighter replaying them draws every national ID twice (typically as a doubled, darker box). See /mask → legacy alias spans.

10. Retrying on the wrong status codes

StatusRetry?
429yes; sleep Retry-After seconds, retry once
503yes; the request was refunded, so a retry costs the same as the first attempt. Exponential backoff (1s, 2s, 4s, cap at 30s).
500yes; exponential backoff
400 / 401 / 402 / 403 / 404 / 422no; these are caller bugs (or billing, or geography). Retrying just burns quota.
Agents writing retry middleware reliably retry every 4xx. Don't. 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.

If you've checked every item above and decryption still fails: open the playground and reproduce the failure with the same text and the same bearer. If the playground succeeds and your code fails, the bug is in your decrypt path. If the playground also fails, it's a server issue; please report it with the request_id response header.