references/fields-glossary.md
# Country document fields — glossary
Reference for the **editable** fields of a `CountryDocument` (one entry in
`countryMappings[<country>][<docType>]`). Use this to explain a change to the
user on the confirmation step and to build a correct change spec.
Set any of these keys in a change spec — `build_country_mappings.py` writes them
straight into the country/doc-type config (the field-recognition keys
`defaultFields`/`extraFields` get the category handling described below). Keys you
don't set keep their baseline value. Truly internal keys not listed anywhere here
(`shouldBeMaskedSettings`, `documentReferences`, …) are passed through untouched —
don't invent values for them.
## The full "Fields management" screen → spec keys
Every control on the dashboard "Fields management" drawer maps to one field:
| UI control | Spec key | Type / values | Mapping |
|---|---|---|---|
| **Accept digital documents** | `acceptDigitalDoc` | boolean | `true` = also accept documents submitted as **images/files** (scans, PDFs, digital copies). When `false`, file/scan/PDF **uploads** are dropped — this governs the upload axis ONLY, not screenshots (`acceptScreenshots` is separate). Don't paraphrase a `false` value as "only live camera photos" without also stating the screenshot flag. |
| **Accept document screenshots for POI** | `acceptScreenshots` | boolean | `true` = turn OFF screenshot protection for POI (⚠️ fraud risk). A **separate** fraud control — see the disambiguation below. |
### `acceptScreenshots` and `acceptDigitalDoc` are INDEPENDENT checks
They gate different things and neither requires the other:
- **`acceptScreenshots`** — its own check (`DocumentLivenessRejectionReasonProvider`:
a screenshot is rejected only when `acceptScreenshots` is false). So
**`acceptScreenshots: true` alone makes screenshots accepted** — you do NOT also
need `acceptDigitalDoc`. There is no "camera-only" pre-filter that drops a
screenshot before this check.
- **`acceptDigitalDoc`** — separate check for uploaded files/scans/PDFs/digital
copies.
Consequences:
- **"Accept screenshots"** → set `acceptScreenshots: true` only. Don't touch
`acceptDigitalDoc`.
- **"Screenshots are on globally but still rejected"** → most likely the applicant
is on a **Verification Level whose own document settings override global**
(level settings win when the level has `useCustomIdDocSettings: true`). Check that
level's `idDocSettings`, not `acceptDigitalDoc` — and fix it there via the
[`sumsub-create-level`](../../sumsub-create-level/SKILL.md) skill (set
`acceptScreenshots: true` on the level's `idDocSettings.countryMappings` entry).
If a request maps to several of these fields and the intent isn't unambiguous
(e.g. broad phrasings about what kinds of documents to accept), **ask the user
which fields they mean rather than guessing** — these are fraud-sensitive and
easy to over-reach.
| **Custom ID document expiry settings** (dropdown) | `expirationCheckMode` | enum: `allowExpired`, `strict`, `generallyAcceptedRegulations`, `localRegulations` | the dropdown value |
| **Accept documents with no valid expiry date** (checkbox) | `expirationCheckMode` | — | checked ⇒ `localRegulations`; unchecked ⇒ `generallyAcceptedRegulations` (same field as above) |
| **Mode: Default / MRZ document** | `ocrSettings.ocrRuleMode` | enum: `DEFAULT`, `MRZ_DOC` | Default ⇒ `DEFAULT`; MRZ document ⇒ `MRZ_DOC` (see note below) |
| **Preferred characters: Latin / Non-latin** | `ocrSettings.preferNonLatinFromOcr` | boolean | Latin ⇒ `false`; Non-latin ⇒ `true` (see note below) |
| **Subtypes: Accept all** | `allowedOcrDocumentTypes` + `forbiddenOcrDocumentTypes` | string[] | both empty `[]` |
| **Subtypes: Accept only selected** | `allowedOcrDocumentTypes` | string[] | list of allowed OCR subtype ids |
| **Subtypes: Reject only selected** | `forbiddenOcrDocumentTypes` | string[] | list of rejected OCR subtype ids |
| **Hide document subtypes for applicants in WebSDK** | `disableDocumentReferences` | boolean | `true` = hide subtypes in WebSDK |
| **Default Fields** (checkboxes) | `defaultFields` | string[] | see below — free recognition |
| **Extra Fields** (checkboxes) | `extraFields` → `availableFields` (wire) | string[] | see below — PAID recognition |
| **Document sides** (one/two/smart) | `sidesSelectionMode` | enum (`disabled`,`smartMode`,`oneSide`,`twoSides`) | see note below |
**Deprecated — read-only, never write.** `supported`, `doubleSided`,
`doubleSidedCanBeChanged`, `acceptedAsPoa`, `acceptSameDocAsPoa` are deprecated
on the backend and `build_country_mappings.py` rejects them. They still show up
in GET responses (catalogue and current settings) — read them for context only.
Replacements: "accept this doc type at all" and sides → `sidesSelectionMode`
(`disabled` = don't accept); the POA flags have no replacement here — decline
such an edit.
**Gated — read-only, never write.** `shouldBeSigned` (require a signature — in
the dashboard the checkbox exists only behind the
`showRejectByMissingSignatureSetting` feature flag; enabling the field makes
autochecks reject unsigned documents) and `shouldBeMasked` (see the Masking
section below) are rejected by `build_country_mappings.py`. Explain the gate and
point the user to the dashboard / Sumsub support instead.
> When setting `ocrSettings`, pass the whole nested object, e.g.
> `ocrSettings: {ocrRuleMode: MRZ_DOC, preferNonLatinFromOcr: false}`.
> The OCR subtype ids for `allowedOcrDocumentTypes`/`forbiddenOcrDocumentTypes`
> are the `documentReferences[].ocrDocumentTypes` values from the catalogue /
> current settings (e.g. `bra.id.type1`).
### Custom ID document expiry settings (`expirationCheckMode`)
Controls which documents are accepted based on their expiry date. From strictest
to most lenient (the exact UI dropdown wording):
| Value | UI label | Accepts |
|---|---|---|
| `strict` | "Accept only valid documents" | only non-expired, currently-valid documents |
| `generallyAcceptedRegulations` | "Accept valid and officially extended documents" | valid + officially extended documents |
| `localRegulations` | "Accept valid and officially extended documents and accept document with no valid expiry date" | the above **plus** documents with no valid expiry date |
| `allowExpired` | "Accept valid and expired documents" | valid **and** expired documents (most lenient) |
The **"Accept documents with no valid expiry date"** checkbox is a shortcut on top
of this: ticking it switches `generallyAcceptedRegulations` → `localRegulations`
(the difference between those two is exactly "also accept no-expiry-date docs").
When to use which: `strict` for the tightest control; `generallyAcceptedRegulations`
is the common default; `localRegulations` when documents legitimately lack an expiry
date in that country; `allowExpired` only when expired documents must be accepted.
**What "officially extended" means** — the modes that accept extended documents
(`generallyAcceptedRegulations` / `localRegulations`) rely on Sumsub's built-in
**expiry-extension rules** (e.g. "Brazilian ID — owner 60+ valid indefinitely; new
type valid until 01.03.2032"). These are read-only reference data baked into Sumsub
(not a client setting, can't be edited). Read them with `get_extension_rules.sh`
(`GET /resources/api/agent/globalSettings/extensionRules`) — `general` rules apply
broadly, `local` rules apply under `localRegulations`. The dashboard shows the
matching rule text in the blue info box under "Custom ID document expiry settings".
### Mode: Default vs MRZ document
MRZ = Machine Readable Zone — the `<<<` lines at the bottom of passports and many
ID cards that machines read directly.
- **`DEFAULT`** — normal mode. OCR reads the document's visual fields; the document
is processed even if a field required by the settings is absent from the subtype.
An MRZ is not required.
- **`MRZ_DOC`** — the document is **rejected if it has no MRZ line**. The backend
requires at least one MRZ field to be read (the first MRZ line is mandatory; the
others are required only if present on the document).
When to use which: pick `MRZ_DOC` for documents where an MRZ must be present
(passports, MRZ-bearing IDs) — it's stricter and more reliable, since the MRZ is
harder to forge and parses more accurately. Use `DEFAULT` for documents without a
mandatory MRZ.
### Preferred characters: Latin vs Non-latin
Controls which alphabet OCR extracts the data in.
- **Latin** (`preferNonLatinFromOcr: false`, default) — OCR prefers Latin
characters, transliterating the data into the Latin alphabet (as in the MRZ).
- **Non-latin** (`preferNonLatinFromOcr: true`) — OCR prefers the document's
**native non-Latin script** (Arabic, Cyrillic, CJK, Thai, …) rather than a
transliteration.
When to use which: set **Non-latin** for documents from countries with a
non-Latin writing system when you want names/fields captured in the original
script instead of a Latin transliteration; otherwise leave it Latin.
### Document sides
How many sides the applicant must submit. On the main **"Supported ID Documents"**
screen each country/doc-type cell is a dropdown; its options map to
`sidesSelectionMode` like this:
| Dropdown option (UI) | `sidesSelectionMode` | Meaning |
|---|---|---|
| **One Side** | `oneSide` | always ask for a single side |
| **Two Sides** | `twoSides` | always ask for both sides |
| **Smart mode** | `smartMode` | auto-detect how many sides are needed |
| **Any Side** | *(not set — left unset)* | accept any one side of a two-sided doc (only offered for doc types in the any-sides catalogue) |
| **Don't accept** | `disabled` | this doc type is not accepted for this country |
So "Don't accept" = `sidesSelectionMode: disabled` (equivalent to disabling the
doc type), and the three positive options set `oneSide` / `twoSides` /
`smartMode`. "Any Side" is a special case — the UI leaves `sidesSelectionMode`
unset; only use it when the user explicitly wants "any single side" and the doc
type supports it.
`doubleSided` (both sides required) and `doubleSidedCanBeChanged` (whether the
client may flip it) are **deprecated** — replaced by `sidesSelectionMode`. Read
them for context (the catalogue/current settings still carry them), but they are
not settable: `build_country_mappings.py` rejects them — always set
`sidesSelectionMode` instead.
> **Sides-loss note.** Historically a sparse override created without sides (e.g.
> by only setting `extraFields`) degraded a two-sided document to "Any Side" —
> the backend read-merge did not backfill the deprecated `doubleSided` from the
> catalogue default. Fixed on the backend behind the temp feature flag
> `backfillDoubleSidedInCountryMappings`. If a user reports a document showing
> "Any Side" after a fields edit, check that flag is enabled for the client; the
> immediate manual fix is setting an explicit `sidesSelectionMode` on the pair.
**Priority — `sidesSelectionMode` wins over `doubleSided`.** The backend resolves
sides as: if `sidesSelectionMode` is set, use it (`twoSides` ⇒ double-sided);
otherwise fall back to the legacy `doubleSided` boolean. So if a doc's current
settings show "uses the legacy doubleSided", it just means `sidesSelectionMode`
isn't set there — sides come from `doubleSided`, which still works. Setting
`sidesSelectionMode` via this skill's edit flow is safe: it takes effect immediately and overrides
`doubleSided` (the old field stays in the data but is ignored). Don't try to keep
both in sync — just set `sidesSelectionMode`.
### Masking (`shouldBeMasked` / `shouldBeMaskedSettings`) — NOT editable here
`shouldBeMasked` (boolean) turns on **masking of sensitive data** on the document
(e.g. covering parts of a number); `shouldBeMaskedSettings` (`MaskingSettings`)
holds the detailed masking rules. The catalogue defines it only for legally
protected national identifiers (JPN My Number, KOR RRN, NLD BSN, NGA NIN, SGP
NRIC — all default `true`), and for NGA ID_CARD even the dashboard lets only
Sumsub staff change it. Turning masking off may violate local law, so this skill
does not edit either key (`build_country_mappings.py` rejects `shouldBeMasked`;
`shouldBeMaskedSettings` is internal pass-through). Report their values freely;
for changes, point the user to the dashboard OCR constructor or Sumsub support.
## Recognition fields (Default Fields / Extra Fields)
| Field | Type | Meaning |
|---|---|---|
| `defaultFields` | string[] | The **"Default Fields"** column — fields checked here are recognised, free of charge. Checked by default; omit one to uncheck it (stop recognising). |
| `extraFields` (spec) → `availableFields` (wire) | string[] | The **"Extra Fields"** column — fields checked here are recognised but ⚠️ **billed** (see below). Unchecked by default. The spec uses `extraFields`; the script maps it to the API's `availableFields`. |
> **You can't empty the whole Default Fields column.** Sending `defaultFields: []`
> (or omitting the list on a sparse override) does **NOT** disable all default fields —
> the backend keeps the catalogue defaults whenever the client's list is empty/absent
> (`OcrFieldsInfoModel#enrichWithClientCountryDocument` only overrides on a NON-EMPTY
> list). At least one default field always stays on. To stop recognising a specific
> field, send the remaining fields you DO want (the column is replaced wholesale) — not
> an empty list, which silently no-ops. `build_country_mappings.py` rejects
> `defaultFields: []` for this reason. The Extra Fields column is the opposite: its base
> is empty and your `extraFields` list only adds.
> **`dob` (date of birth) can have a downstream effect — but only where an age-based
> rule exists.** Some **expiry-extension rules** depend on the holder's age (e.g. BRA
> ID "owner 60+ ⇒ valid indefinitely") and can only be evaluated when `dob` is
> recognised. Before warning that disabling `dob` breaks expiry checks, **check
> whether such a rule actually applies to this `(country, docType)`** by reading the
> extension rules — run [`../scripts/get_extension_rules.sh`](../scripts/get_extension_rules.sh)
> and filter to the pair (same call as SKILL.md procedure **C**). Most doc types
> (e.g. passports) have no age-based rule, so turning `dob` off there is harmless and
> needs no warning. Only flag it when an age-based extension rule exists for that pair.
## The two columns mirror the dashboard "Fields management" screen
The spec keys map 1:1 to the UI:
| Spec key | UI column | Cost | Default state |
|---|---|---|---|
| `defaultFields` | Default Fields | free | checked (recognised) |
| `extraFields` | Extra Fields | PAID (needs `ADVANCED_OCR`) | unchecked |
You just list which fields are **checked** in each column — exactly what you'd
tick in the UI. The script prints a `Field recognition result:` summary in the
same language so the user can cross-check against the screen.
## A field's column is FIXED by the catalogue — you can't move it
A field belongs to exactly one column — **Default Fields** or **Extra Fields** —
and that assignment is baked into the catalogue (`documents-by-countries.json`)
per country and doc type. You **cannot** put e.g. `placeOfBirth` under
`defaultFields` for a doc type where it's an Extra Field — the backend validates
against the fixed column and **silently drops** the misplaced field (PUT still
returns 200).
`build_country_mappings.py` guards against this: it auto-routes each field to its
real column and prints a `warning:` for every correction. Trust those warnings —
if it says a field was moved, that's the only column the backend accepts it in. To
see which column a field lives in, read the catalogue first (a `{country, docType}`
query returns the canonical Default / Extra split).
## ⚠️ Extra Fields require the ADVANCED_OCR entitlement (extra cost)
"Extra Fields" (`extraFields` in the spec, `availableFields` on the wire) are a
**paid add-on**, gated behind the client's `ADVANCED_OCR` entitlement. If the
client does **not** have `ADVANCED_OCR`, the backend **silently clears** them when
reading the settings (`GlobalSettingsHelper.mergeCountryDocumentWithDefault`) —
there is no error.
Consequences for an edit:
- Before checking any `extraFields` for a client, confirm they have `ADVANCED_OCR`
in their entitlements (a key of `allowedChecks` from `sumsub-check-permissions`).
- If they don't, warn the user that extra fields incur additional cost and that
the change will be dropped server-side until `ADVANCED_OCR` is enabled — do
not report success.
- Default Fields are NOT gated and carry no extra charge.
## Field-name values are per country/doc type
The valid field names for each column are **not** a fixed global list — they vary
by country and document type. Don't guess them. Read the catalogue first to learn
what's valid for the target country/doc type:
```bash
echo '{"countries":["DEU"],"docTypes":["ID_CARD"]}' \
| query_supported_docs.py --data-file /tmp/supported-docs.json
```
The returned `defaultFields` (Default Fields column) + `availableFields` (Extra
Fields column) are exactly the names you may use in a change spec — `defaultFields`
under `defaultFields`, `availableFields` under `extraFields`.
scripts/build_country_mappings.py
#!/usr/bin/env python3
"""
Build the FULL countryMappings payload for the supported-documents PUT endpoint
(`PUT /resources/api/agent/globalSettings/idDocSettings/countryMappings`) from a
compact change spec.
PUT replaces the whole map server-side, so this builder does the merge locally
(read-modify-write): it starts from the client's CURRENT map (--current-file),
deep-merges the changed fields per (country, docType), and emits the complete
map. Entries and fields you don't mention are carried over unchanged — never
hand-craft a partial map for PUT. (To remove an override entirely — "Restore
default settings" — use remove_country_mapping.py; this builder refuses
`remove`.)
Usage:
get_supported_docs.sh > /tmp/catalogue.json
get_global_settings.sh > /tmp/current.json # fetch FRESH right before building
echo '<spec>' | build_country_mappings.py \
--current-file /tmp/current.json --catalog-file /tmp/catalogue.json
Input spec (stdin, YAML if PyYAML present, else JSON):
changes:
- country: BRA
docType: ID_CARD
expirationCheckMode: strict # scalar fields: merged into the entry
ocrSettings: {ocrRuleMode: MRZ_DOC}
defaultFields: [firstName, dob] # list fields: REPLACE the column wholesale
extraFields: [placeOfBirth] # -> availableFields on the wire (PAID)
Output (stdout): full { "countryMappings": { ... } } ready to PUT.
NOTE on field lists: a column list is replaced wholesale. To ADD one field to a
column without dropping the others, read the current enabled set first
(recognition_status.py) and send the full intended list.
NOTE on new overrides: a BRAND-NEW (country, docType) entry is seeded with the
catalogue defaults for supported, doubleSided, doubleSidedCanBeChanged and
shouldBeSigned (spec values win), so it keeps behaving like the default entry it
shadows — else e.g. doubleSided=null would read as "any side" and silently drop
the catalogue's two-sides requirement. Existing overrides are never touched this
way — their null may be a deliberate "any side" choice.
NOTE on sides dual-write: when the spec sets sidesSelectionMode, the deprecated
supported/doubleSided pair is synced to the matching legacy values (dashboard
parity — see LEGACY_PAIR_BY_MODE), on new AND existing entries, so readers that
predate sidesSelectionMode see the same behavior.
See SKILL.md and references/fields-glossary.md.
"""
import argparse
import copy
import json
import sys
try:
import yaml # type: ignore
_HAS_YAML = True
except ImportError:
_HAS_YAML = False
# Spec-control keys consumed by the builder, not written verbatim into the doc.
CONTROL_KEYS = {"country", "docType", "defaultFields", "extraFields"}
# Deprecated CountryDocument fields — refused outright. They still appear in
# GET responses (read them for context), but new writes must use the
# replacement; sending the legacy key would create stale overrides.
DEPRECATED_KEYS = {
"supported": "use sidesSelectionMode: 'disabled' to stop accepting the doc type "
"(or a positive mode to accept it)",
"doubleSided": "use sidesSelectionMode: oneSide/twoSides/smartMode",
"doubleSidedCanBeChanged": "use sidesSelectionMode",
"acceptedAsPoa": "deprecated on the backend; not editable via this skill",
"acceptSameDocAsPoa": "deprecated on the backend; not editable via this skill",
}
# Fields deliberately NOT editable via this skill — the dashboard gates them
# (feature flag / Sumsub-only) and the agent must not bypass that.
RESTRICTED_KEYS = {
"shouldBeSigned": "editable only in the dashboard behind the "
"'showRejectByMissingSignatureSetting' feature flag; enabling it makes "
"autochecks reject unsigned documents. Direct the user to the dashboard "
"or Sumsub support",
"shouldBeMasked": "masking of legally protected national IDs (e.g. NGA NIN is "
"Sumsub-only even in the dashboard); changing it may violate local law. "
"Direct the user to the dashboard or Sumsub support",
}
EXPIRATION_CHECK_MODES = {"allowExpired", "strict",
"generallyAcceptedRegulations", "localRegulations"}
OCR_RULE_MODES = {"DEFAULT", "MRZ_DOC"}
def _die(msg, code=2):
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def _load_spec(stream):
data = stream.read()
if not data.strip():
_die("empty change spec on stdin")
if _HAS_YAML:
try:
return yaml.safe_load(data)
except yaml.YAMLError as e:
_die(f"failed to parse spec as YAML/JSON: {e}")
try:
return json.loads(data)
except json.JSONDecodeError as e:
_die(f"failed to parse spec as JSON: {e}; install PyYAML to accept YAML.")
def _read_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except OSError as e:
_die(f"cannot read {path}: {e}", code=3)
except json.JSONDecodeError as e:
_die(f"{path} is not valid JSON: {e}", code=3)
def _country_mappings(doc, source):
if isinstance(doc.get("countryMappings"), dict):
return doc["countryMappings"]
id_doc = doc.get("idDocSettings")
if isinstance(id_doc, dict) and isinstance(id_doc.get("countryMappings"), dict):
return id_doc["countryMappings"]
_die(f"{source} has no countryMappings")
def _validate_enums(doc, country, doc_type):
ecm = doc.get("expirationCheckMode")
if ecm is not None and ecm not in EXPIRATION_CHECK_MODES:
_die(f"{country}/{doc_type}: invalid expirationCheckMode '{ecm}'; "
f"allowed: {sorted(EXPIRATION_CHECK_MODES)}")
ocr = doc.get("ocrSettings")
if isinstance(ocr, dict):
mode = ocr.get("ocrRuleMode")
if mode is not None and mode not in OCR_RULE_MODES:
_die(f"{country}/{doc_type}: invalid ocrSettings.ocrRuleMode '{mode}'; "
f"allowed: {sorted(OCR_RULE_MODES)}")
def _field_category(catalogue, country, doc_type, field):
cat = (catalogue.get(country, {}) or {}).get(doc_type)
if not cat:
return None
if field in cat.get("defaultFields", []):
return "default"
if field in cat.get("availableFields", []):
return "available"
return None
def _apply_field_columns(doc, country, doc_type, requested_default, requested_extra,
catalogue, summary):
"""Set the Default/Extra field columns the caller specified. Each column is a
WHOLESALE replace: we set exactly what the caller passed (after validating each
field belongs to that column). We do NOT auto-move a field to the other column:
that would silently replace the other column's list and could wipe fields the
caller never meant to touch. A misplaced field is a hard error telling the
caller the correct column."""
for field in (requested_default or []):
if _field_category(catalogue, country, doc_type, field) == "available":
_die(f"{country}/{doc_type}: '{field}' is an Extra Field — put it under "
"'extraFields', not 'defaultFields'.")
for field in (requested_extra or []):
if _field_category(catalogue, country, doc_type, field) == "default":
_die(f"{country}/{doc_type}: '{field}' is a Default Field — put it under "
"'defaultFields', not 'extraFields'.")
if requested_default is not None and not requested_default:
_die(f"{country}/{doc_type}: 'defaultFields: []' cannot disable the whole Default "
"column — the backend keeps the catalogue defaults when the list is empty "
"(at least one default field stays on). Omit 'defaultFields' to leave it "
"unchanged, or list the fields that should remain.")
parts = []
if requested_default is not None:
doc["defaultFields"] = list(requested_default)
parts.append(f"Default Fields → {', '.join(requested_default) or '(none)'}")
if requested_extra is not None:
doc["availableFields"] = list(requested_extra)
tag = "PAID, requires ADVANCED_OCR" if requested_extra else "PAID"
parts.append(f"Extra Fields ({tag}) → {', '.join(requested_extra) or '(none)'}")
if parts:
summary.append(f"{country}/{doc_type}: " + "; ".join(parts)
+ " [replaces the whole column — include all fields that should stay on]")
# Catalogue defaults copied onto a BRAND-NEW override so it keeps behaving like
# the default entry it shadows. Without this a sparse override would persist
# these as null — e.g. doubleSided=null reads as "any side", silently dropping
# the catalogue's two-sides requirement.
SEED_KEYS = ("supported", "doubleSided", "doubleSidedCanBeChanged", "shouldBeSigned")
# Dashboard dual-write parity (supportedDocumentMapping): when the spec sets
# sidesSelectionMode, the deprecated supported/doubleSided pair is synced to the
# matching legacy values so pre-sidesSelectionMode readers see the same behavior.
LEGACY_PAIR_BY_MODE = {
"disabled": {"supported": False, "doubleSided": False},
"oneSide": {"supported": True, "doubleSided": False},
"smartMode": {"supported": True, "doubleSided": False},
"twoSides": {"supported": True, "doubleSided": True},
}
def _seed_new_override_from_catalogue(country, doc_type, catalogue, summary):
"""Return the catalogue defaults a new (country, docType) override starts from.
Only keys present (non-null) in the catalogue are copied; a null there (e.g.
doubleSided for genuine "any side" docs) stays absent. Spec values are applied
on top and win."""
cat = (catalogue.get(country, {}) or {}).get(doc_type) or {}
seed = {key: cat[key] for key in SEED_KEYS if cat.get(key) is not None}
if seed:
seeded = ", ".join(f"{k}={json.dumps(v)}" for k, v in seed.items())
summary.append(f"{country}/{doc_type}: new override seeded with catalogue "
f"defaults ({seeded})")
return seed
def build(spec, current, catalogue):
if not isinstance(spec, dict) or "changes" not in spec:
_die("spec must be an object with a 'changes' list")
changes = spec["changes"]
if not isinstance(changes, list) or not changes:
_die("'changes' must be a non-empty list")
result = copy.deepcopy(current)
warnings, summary = [], []
for i, change in enumerate(changes):
if not isinstance(change, dict):
_die(f"changes[{i}] must be an object; got {change!r}")
if change.get("remove"):
_die(f"changes[{i}] uses remove — this builder only edits. "
"Use remove_country_mapping.py to restore defaults.")
country = change.get("country")
doc_type = change.get("docType")
if not country or not doc_type:
_die(f"changes[{i}] requires both 'country' and 'docType'")
delta = {}
for key, value in change.items():
if key in CONTROL_KEYS:
continue
if key in DEPRECATED_KEYS:
_die(f"{country}/{doc_type}: '{key}' is deprecated and not settable "
f"via this skill — {DEPRECATED_KEYS[key]}.")
if key in RESTRICTED_KEYS:
_die(f"{country}/{doc_type}: '{key}' is not editable via this skill — "
f"{RESTRICTED_KEYS[key]}.")
delta[key] = value
_validate_enums(delta, country, doc_type)
if delta.get("acceptScreenshots") is True:
warnings.append(
f"{country}/{doc_type}: acceptScreenshots=true is a CRITICAL fraud setting — "
"it DISABLES screenshot protection. Fake applications may be approved and "
"approval rates may look higher only because fraud is not blocked. May expose "
"you to penalties and chargebacks. Confirm with the user before applying.")
if delta.get("acceptDigitalDoc") is True:
warnings.append(
f"{country}/{doc_type}: acceptDigitalDoc=true accepts uploaded files/scans/PDFs — "
"digital documents are easy to modify to pass verification; consider extra checks. "
"Confirm with the user.")
if "defaultFields" in change or "extraFields" in change:
_apply_field_columns(delta, country, doc_type,
change.get("defaultFields"), change.get("extraFields"),
catalogue, summary)
mode = delta.get("sidesSelectionMode")
if mode is not None:
legacy_pair = LEGACY_PAIR_BY_MODE.get(mode)
if legacy_pair is None:
_die(f"{country}/{doc_type}: invalid sidesSelectionMode '{mode}'; "
f"allowed: {sorted(LEGACY_PAIR_BY_MODE)}")
delta.update(legacy_pair)
summary.append(f"{country}/{doc_type}: sidesSelectionMode={mode} also syncs the "
f"legacy pair (supported={json.dumps(legacy_pair['supported'])}, "
f"doubleSided={json.dumps(legacy_pair['doubleSided'])}) — dashboard parity")
if not delta:
_die(f"changes[{i}] for {country}/{doc_type} has nothing to change")
existing = result.get(country, {}).get(doc_type)
if existing is None:
entry = _seed_new_override_from_catalogue(country, doc_type, catalogue, summary)
entry.update(delta)
result.setdefault(country, {})[doc_type] = entry
else:
existing.update(delta)
if summary:
print("Field changes:", file=sys.stderr)
for s in summary:
print(f" {s}", file=sys.stderr)
for w in warnings:
print(f"warning: {w}", file=sys.stderr)
return {"countryMappings": result}
def main():
ap = argparse.ArgumentParser(
description="Build the full countryMappings PUT payload (current settings + changes).")
ap.add_argument("--current-file", required=True,
help="client's current settings JSON (from get_global_settings.sh); "
"fetch FRESH right before building — PUT replaces the whole map, "
"so a stale snapshot would revert edits made in between")
ap.add_argument("--catalog-file", required=True,
help="default catalogue JSON (from get_supported_docs.sh); used to route "
"Default/Extra field lists and to backfill sides for new overrides")
args = ap.parse_args()
spec = _load_spec(sys.stdin)
current = _country_mappings(_read_json(args.current_file), "--current-file")
catalogue = _country_mappings(_read_json(args.catalog_file), "--catalog-file")
payload = build(spec, current, catalogue)
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
scripts/diff_country_mappings.py
#!/usr/bin/env python3
"""
Show a human-readable diff between the client's CURRENT supported-documents
settings and a built full-map PUT payload — for the confirmation step before PUT.
Removals show as REMOVED; entries the builder carried over unchanged are silent.
Use this instead of hand-writing an inline `python3 -c "..."` diff: it's a fixed,
pre-approved script (no per-call permission prompt) and prints a clean per-field
diff grouped by country / doc type.
Usage:
diff_country_mappings.py --current-file /tmp/current.json --payload-file /tmp/payload.json
Both files may be either the raw global-settings response (countryMappings nested
under idDocSettings) or a {countryMappings: ...} payload — the script finds the
map either way. Output is human-readable on stdout; exit 0 always (it's a report).
"""
import argparse
import json
import sys
def _die(msg, code=2):
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def _read_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except OSError as e:
_die(f"cannot read {path}: {e}", code=3)
except json.JSONDecodeError as e:
_die(f"{path} is not valid JSON: {e}", code=3)
def _country_mappings(doc, source):
if isinstance(doc.get("countryMappings"), dict):
return doc["countryMappings"]
id_doc = doc.get("idDocSettings")
if isinstance(id_doc, dict) and isinstance(id_doc.get("countryMappings"), dict):
return id_doc["countryMappings"]
_die(f"{source} has no countryMappings")
def _fmt(value):
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return str(value)
def diff(current, payload):
"""PUT replace-all semantics: a doc present in current but absent from the
payload is REMOVED, and fields absent from the payload doc become null.
Entries carried over unchanged by the builder produce no lines."""
lines = []
for country in sorted(set(current) | set(payload)):
cur_docs = current.get(country, {})
new_docs = payload.get(country, {})
for doc_type in sorted(set(cur_docs) | set(new_docs)):
cur = cur_docs.get(doc_type)
new = new_docs.get(doc_type)
if cur is None and new is not None:
lines.append(f"+ {country}/{doc_type}: ADDED")
continue
if cur is not None and new is None:
lines.append(f"- {country}/{doc_type}: REMOVED (restored to catalogue default)")
continue
field_lines = []
for key in sorted(set(cur) | set(new)):
cv, nv = cur.get(key), new.get(key)
if cv != nv:
field_lines.append(f" {key}: {_fmt(cv)} → {_fmt(nv)}")
if field_lines:
lines.append(f"~ {country}/{doc_type}: CHANGED")
lines.extend(field_lines)
return lines
def main():
ap = argparse.ArgumentParser(description="Diff current settings vs a built payload.")
ap.add_argument("--current-file", required=True)
ap.add_argument("--payload-file", required=True)
args = ap.parse_args()
current = _country_mappings(_read_json(args.current_file), "--current-file")
payload = _country_mappings(_read_json(args.payload_file), "--payload-file")
lines = diff(current, payload)
if not lines:
print("No changes — payload matches current settings.")
else:
print("Changes to apply (current → new):")
for line in lines:
print(f" {line}")
if __name__ == "__main__":
main()
scripts/get_entitlements.sh
#!/usr/bin/env bash
# GET the client's allowed background-check targets (entitlements) from the
# Sumsub API. Used to check whether paid features like ADVANCED_OCR (required for
# Extra Fields) are enabled before editing.
#
# GET /resources/api/agent/settings/bgCheckTargets
# → { "allowedChecks": { "ADVANCED_OCR": "...", "POA": "...", ... } }
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# ./get_entitlements.sh # prints the JSON
# ./get_entitlements.sh ADVANCED_OCR # exits 0 if entitlement present, 1 if not
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
set -euo pipefail
: "${SUMSUB_APP_TOKEN:?SUMSUB_APP_TOKEN is required (sandbox App Token, 'sbx:' prefix)}"
: "${SUMSUB_SECRET_KEY:?SUMSUB_SECRET_KEY is required (paired secret key)}"
BASE="${SUMSUB_BASE:-https://api.sumsub.com}"
if [[ "${SUMSUB_APP_TOKEN}" != sbx:* && "${SUMSUB_ALLOW_PROD:-0}" != "1" ]]; then
echo "error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix)." >&2
echo " Production credentials must not be shared with this skill." >&2
exit 3
fi
METHOD="GET"
PATH_Q="/resources/api/agent/settings/bgCheckTargets"
TS="$(date -u +%s)"
SIG="$(
printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}" \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
HTTP_CODE="$(
curl -sS -X "${METHOD}" \
-H "X-App-Token: ${SUMSUB_APP_TOKEN}" \
-H "X-App-Access-Ts: ${TS}" \
-H "X-App-Access-Sig: ${SIG}" \
-H "X-Agent-Source-Ver: 1.4.1" \
-H "Accept: application/json" \
-o "${BODY_FILE}" \
-w '%{http_code}' \
"${BASE%/}${PATH_Q}"
)"
if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -ge 300 ]]; then
echo "error: GET ${PATH_Q} returned HTTP ${HTTP_CODE}" >&2
cat "${BODY_FILE}" >&2
exit 4
fi
# Membership-check mode: ./get_entitlements.sh <ENTITLEMENT>
if [[ $# -ge 1 ]]; then
ENTITLEMENT="$1"
if python3 -c "import json,sys; d=json.load(open('${BODY_FILE}')); sys.exit(0 if '${ENTITLEMENT}' in (d.get('allowedChecks') or {}) else 1)"; then
echo "${ENTITLEMENT}: ENABLED"
exit 0
else
echo "${ENTITLEMENT}: NOT enabled"
exit 1
fi
fi
cat "${BODY_FILE}"
scripts/get_extension_rules.sh
#!/usr/bin/env bash
# GET Sumsub's built-in document expiry-EXTENSION rules (read-only reference).
# These describe how a document's validity is officially extended/interpreted per
# country/doc type (e.g. "owner 60+ valid indefinitely"). Not a client setting —
# you can't edit them; they back the 'officially extended documents' expiry modes.
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# ./get_extension_rules.sh
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
set -euo pipefail
: "${SUMSUB_APP_TOKEN:?SUMSUB_APP_TOKEN is required (sandbox App Token, 'sbx:' prefix)}"
: "${SUMSUB_SECRET_KEY:?SUMSUB_SECRET_KEY is required (paired secret key)}"
BASE="${SUMSUB_BASE:-https://api.sumsub.com}"
if [[ "${SUMSUB_APP_TOKEN}" != sbx:* && "${SUMSUB_ALLOW_PROD:-0}" != "1" ]]; then
echo "error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix)." >&2
echo " Production credentials must not be shared with this skill." >&2
exit 3
fi
METHOD="GET"
PATH_Q="/resources/api/agent/globalSettings/extensionRules"
TS="$(date -u +%s)"
SIG="$(
printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}" \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
HTTP_CODE="$(
curl -sS -X "${METHOD}" \
-H "X-App-Token: ${SUMSUB_APP_TOKEN}" \
-H "X-App-Access-Ts: ${TS}" \
-H "X-App-Access-Sig: ${SIG}" \
-H "X-Agent-Source-Ver: 1.4.1" \
-H "Accept: application/json" \
-o "${BODY_FILE}" \
-w '%{http_code}' \
"${BASE%/}${PATH_Q}"
)"
if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -ge 300 ]]; then
echo "error: GET ${PATH_Q} returned HTTP ${HTTP_CODE}" >&2
cat "${BODY_FILE}" >&2
exit 4
fi
cat "${BODY_FILE}"
scripts/get_global_settings.sh
#!/usr/bin/env bash
# GET the client's current global settings (incl. idDocSettings.countryMappings)
# from the Sumsub API. Used as the read step of the read-modify-write edit flow.
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# ./get_global_settings.sh > /tmp/current.json
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
set -euo pipefail
: "${SUMSUB_APP_TOKEN:?SUMSUB_APP_TOKEN is required (sandbox App Token, 'sbx:' prefix)}"
: "${SUMSUB_SECRET_KEY:?SUMSUB_SECRET_KEY is required (paired secret key)}"
BASE="${SUMSUB_BASE:-https://api.sumsub.com}"
if [[ "${SUMSUB_APP_TOKEN}" != sbx:* && "${SUMSUB_ALLOW_PROD:-0}" != "1" ]]; then
echo "error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix)." >&2
echo " Production credentials must not be shared with this skill." >&2
exit 3
fi
METHOD="GET"
PATH_Q="/resources/api/agent/globalSettings"
TS="$(date -u +%s)"
SIG="$(
printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}" \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
HTTP_CODE="$(
curl -sS -X "${METHOD}" \
-H "X-App-Token: ${SUMSUB_APP_TOKEN}" \
-H "X-App-Access-Ts: ${TS}" \
-H "X-App-Access-Sig: ${SIG}" \
-H "X-Agent-Source-Ver: 1.4.1" \
-H "Accept: application/json" \
-o "${BODY_FILE}" \
-w '%{http_code}' \
"${BASE%/}${PATH_Q}"
)"
if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -ge 300 ]]; then
echo "error: GET ${PATH_Q} returned HTTP ${HTTP_CODE}" >&2
cat "${BODY_FILE}" >&2
exit 4
fi
cat "${BODY_FILE}"
scripts/get_supported_docs.sh
#!/usr/bin/env bash
# GET the supported-documents catalogue (documentsByCountries) from the Sumsub API.
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# ./get_supported_docs.sh > /tmp/docs.json
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
#
# Prints the response body to stdout. On a non-2xx status, prints the body to
# stderr and exits non-zero so callers don't pipe an error page into the query.
set -euo pipefail
: "${SUMSUB_APP_TOKEN:?SUMSUB_APP_TOKEN is required (sandbox App Token, 'sbx:' prefix)}"
: "${SUMSUB_SECRET_KEY:?SUMSUB_SECRET_KEY is required (paired secret key)}"
BASE="${SUMSUB_BASE:-https://api.sumsub.com}"
if [[ "${SUMSUB_APP_TOKEN}" != sbx:* && "${SUMSUB_ALLOW_PROD:-0}" != "1" ]]; then
echo "error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix)." >&2
echo " Production credentials must not be shared with this skill." >&2
exit 3
fi
METHOD="GET"
PATH_Q="/resources/api/agent/supportedDocs/documentsByCountries"
TS="$(date -u +%s)"
SIG="$(
printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}" \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
HTTP_CODE="$(
curl -sS -X "${METHOD}" \
-H "X-App-Token: ${SUMSUB_APP_TOKEN}" \
-H "X-App-Access-Ts: ${TS}" \
-H "X-App-Access-Sig: ${SIG}" \
-H "X-Agent-Source-Ver: 1.4.1" \
-H "Accept: application/json" \
-o "${BODY_FILE}" \
-w '%{http_code}' \
"${BASE%/}${PATH_Q}"
)"
if [[ "${HTTP_CODE}" -lt 200 || "${HTTP_CODE}" -ge 300 ]]; then
echo "error: GET ${PATH_Q} returned HTTP ${HTTP_CODE}" >&2
cat "${BODY_FILE}" >&2
exit 4
fi
cat "${BODY_FILE}"
scripts/put_country_mappings.sh
#!/usr/bin/env bash
# PUT the FULL countryMappings payload — REPLACES the whole map.
#
# PUT /resources/api/agent/globalSettings/idDocSettings/countryMappings
#
# The single write path of this skill: edits send the complete map built by
# build_country_mappings.py (current settings + merged changes), removals send
# the map built by remove_country_mapping.py. Anything omitted is wiped, so
# never send a hand-crafted partial map here.
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# ./put_country_mappings.sh <payload.json>
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
#
# Prints the response body followed by a final line: HTTP <code>
set -euo pipefail
: "${SUMSUB_APP_TOKEN:?SUMSUB_APP_TOKEN is required (sandbox App Token, 'sbx:' prefix)}"
: "${SUMSUB_SECRET_KEY:?SUMSUB_SECRET_KEY is required (paired secret key)}"
BASE="${SUMSUB_BASE:-https://api.sumsub.com}"
if [[ "${SUMSUB_APP_TOKEN}" != sbx:* && "${SUMSUB_ALLOW_PROD:-0}" != "1" ]]; then
echo "error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix)." >&2
echo " Production credentials must not be shared with this skill." >&2
exit 3
fi
if [[ $# -lt 1 ]]; then
echo "usage: $0 <payload.json>" >&2
exit 2
fi
PAYLOAD_FILE="$1"
if [[ ! -f "${PAYLOAD_FILE}" ]]; then
echo "error: payload file not found: ${PAYLOAD_FILE}" >&2
exit 2
fi
METHOD="PUT"
PATH_Q="/resources/api/agent/globalSettings/idDocSettings/countryMappings"
TS="$(date -u +%s)"
BODY="$(cat "${PAYLOAD_FILE}")"
# Signature covers ts + method + path + raw body, per Sumsub HMAC spec.
SIG="$(
printf '%s%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}" "${BODY}" \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
curl -sS -X "${METHOD}" \
-H "X-App-Token: ${SUMSUB_APP_TOKEN}" \
-H "X-App-Access-Ts: ${TS}" \
-H "X-App-Access-Sig: ${SIG}" \
-H "X-Agent-Source-Ver: 1.4.1" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-binary "${BODY}" \
-w '\nHTTP %{http_code}\n' \
"${BASE%/}${PATH_Q}"
scripts/query_supported_docs.py
#!/usr/bin/env python3
"""
Query the Sumsub supported-documents reference (the `documentsByCountries`
catalogue) with a compact filter spec.
The catalogue is large (~250 countries × several doc types). Dumping it raw
into an LLM context is wasteful, and a naive value filter does not help when
the value is near-universal (e.g. `dob` is a default field for 100% of pairs).
This script keeps the full catalogue in-process and returns only the relevant
slice — or, when a slice would be uselessly large, an aggregate summary instead.
Usage:
get_supported_docs.sh > /tmp/docs.json
echo '{"countries": ["DEU"]}' | query_supported_docs.py --data-file /tmp/docs.json
Input: a compact spec on stdin (YAML if PyYAML is installed, else JSON).
Data: the full catalogue JSON via --data-file (as returned by the API).
Output: filtered JSON slice or an aggregate summary on stdout.
See SKILL.md for the spec format and the list-vs-summary decision rules.
"""
import argparse
import json
import sys
try:
import yaml # type: ignore
_HAS_YAML = True
except ImportError:
_HAS_YAML = False
# A field present in more than this share of matched pairs makes a per-pair
# list useless ("it's everywhere"); the script collapses to a summary instead.
UNIVERSAL_THRESHOLD = 0.80
# Even below the coverage threshold, a field-presence result this large is not
# worth listing pair-by-pair — collapse to a distribution summary instead.
MAX_LIST_BEFORE_SUMMARY = 200
# When a near-universal field is ABSENT from only a short list, that complement
# is more useful than the long presence list — include it up to this size.
COMPLEMENT_MAX = 20
# How many top countries / docTypes to show in a distribution summary.
TOP_N = 10
# Default cap on returned list items before truncation kicks in.
DEFAULT_LIMIT = 100
FIELD_KINDS = {"default", "available", "any"}
OUTPUT_MODES = {"auto", "list", "summary"}
def _load_spec(stream):
data = stream.read()
if not data.strip():
return {}
if _HAS_YAML:
try:
return yaml.safe_load(data) or {}
except yaml.YAMLError as e:
_die(f"failed to parse spec as YAML/JSON: {e}")
try:
return json.loads(data)
except json.JSONDecodeError as e:
_die(f"failed to parse spec as JSON: {e}; install PyYAML to accept YAML.")
def _die(msg, code=2):
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def _flatten(catalogue):
"""Expand {countryMappings: {COUNTRY: {DOC_TYPE: {...}}}} into a flat list of
pair records, each carrying its country, docType and the per-doc config."""
mappings = catalogue.get("countryMappings")
if not isinstance(mappings, dict):
_die("data-file has no 'countryMappings' object — is this the documentsByCountries catalogue?")
pairs = []
for country, docs in mappings.items():
if not isinstance(docs, dict):
continue
for doc_type, cfg in docs.items():
if not isinstance(cfg, dict):
continue
pairs.append({"country": country, "docType": doc_type, "cfg": cfg})
return pairs
def _spec_get_list(spec, key):
val = spec.get(key)
if val is None:
return None
if isinstance(val, str):
return [val]
if isinstance(val, list):
return [str(v) for v in val]
_die(f"'{key}' must be a string or list of strings; got {val!r}")
def _matches_flags(cfg, flags):
for flag_name, want in flags.items():
if bool(cfg.get(flag_name, False)) != bool(want):
return False
return True
def _field_in(cfg, field, kind):
if kind == "default":
return field in cfg.get("defaultFields", [])
if kind == "available":
return field in cfg.get("availableFields", [])
return field in cfg.get("defaultFields", []) or field in cfg.get("availableFields", [])
def _apply_filters(pairs, countries, doc_types, flags):
"""Apply the structural filters (country / docType / flags). The `field`
filter is applied separately so we can measure its coverage for auto-mode."""
out = []
for p in pairs:
if countries and p["country"] not in countries:
continue
if doc_types and p["docType"] not in doc_types:
continue
if flags and not _matches_flags(p["cfg"], flags):
continue
out.append(p)
return out
def _top_counts(pairs, key, top_n=None):
counts = {}
for p in pairs:
counts[p[key]] = counts.get(p[key], 0) + 1
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
if top_n is not None:
ordered = ordered[:top_n]
return {k: v for k, v in ordered}
def _item_view(p):
cfg = p["cfg"]
view = {"country": p["country"], "docType": p["docType"]}
for k in ("supported", "doubleSided", "doubleSidedCanBeChanged", "shouldBeSigned"):
if k in cfg:
view[k] = cfg[k]
if "defaultFields" in cfg:
view["defaultFields"] = cfg["defaultFields"]
if "availableFields" in cfg:
view["availableFields"] = cfg["availableFields"]
return view
def query(spec, catalogue):
countries = _spec_get_list(spec, "countries")
doc_types = _spec_get_list(spec, "docTypes")
field = spec.get("field")
field_kind = spec.get("fieldKind", "any")
if field_kind not in FIELD_KINDS:
_die(f"fieldKind must be one of {sorted(FIELD_KINDS)}; got {field_kind!r}")
flags = spec.get("flags") or {}
if not isinstance(flags, dict):
_die(f"flags must be an object; got {flags!r}")
# Unsupported documents can't be configured, so they're excluded by default.
# The caller must opt in explicitly (flags.supported: false) to see them.
if "supported" not in flags:
flags = {**flags, "supported": True}
output = spec.get("output", "auto")
if output not in OUTPUT_MODES:
_die(f"output must be one of {sorted(OUTPUT_MODES)}; got {output!r}")
limit = spec.get("limit", DEFAULT_LIMIT)
pairs = _flatten(catalogue)
base = _apply_filters(pairs, countries, doc_types, flags)
if field:
present = [p for p in base if _field_in(p["cfg"], field, field_kind)]
coverage = len(present) / len(base) if base else 0.0
near_universal = coverage >= UNIVERSAL_THRESHOLD
too_many = len(present) > MAX_LIST_BEFORE_SUMMARY
force_summary = output == "summary" or (
output == "auto" and (near_universal or too_many)
)
if force_summary:
absent = [p for p in base if not _field_in(p["cfg"], field, field_kind)]
result = {
"mode": "summary",
"field": field,
"fieldKind": field_kind,
"presentIn": len(present),
"totalPairs": len(base),
"coverage": f"{round(100 * coverage)}%",
"distinctCountries": len({p["country"] for p in present}),
"byDocType": _top_counts(present, "docType"),
}
if near_universal:
result["note"] = (
"near-universal field — filtering by it barely narrows the set"
)
if 0 < len(absent) <= COMPLEMENT_MAX:
result["absentIn"] = [
{"country": p["country"], "docType": p["docType"]} for p in absent
]
else:
result["note"] = (
f"{len(present)} matches — too many to list; add countries/docTypes "
"filters to narrow, or set output:list with a higher limit to force a list."
)
result["topCountries"] = _top_counts(present, "country", TOP_N)
return result
matched = present
else:
matched = base
truncated = output != "summary" and len(matched) > limit
items = matched[:limit] if truncated else matched
result = {
"mode": "list",
"totalMatches": len(matched),
"returned": len(items),
"truncated": truncated,
"items": [_item_view(p) for p in items],
}
if truncated:
result["hint"] = (
f"{len(matched)} matches exceed limit {limit}; add countries/docTypes "
"filters to narrow, or set a higher 'limit'."
)
return result
def main():
ap = argparse.ArgumentParser(description="Filter the supported-documents catalogue.")
ap.add_argument("--data-file", required=True,
help="path to the documentsByCountries catalogue JSON (from get_supported_docs.sh)")
args = ap.parse_args()
spec = _load_spec(sys.stdin)
if not isinstance(spec, dict):
_die(f"spec must be an object; got {type(spec).__name__}")
try:
with open(args.data_file, encoding="utf-8") as f:
catalogue = json.load(f)
except OSError as e:
_die(f"cannot read --data-file: {e}", code=3)
except json.JSONDecodeError as e:
_die(f"--data-file is not valid JSON: {e}", code=3)
result = query(spec, catalogue)
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
scripts/recognition_status.py
#!/usr/bin/env python3
"""
Show the EFFECTIVE recognition status of every OCR field for a given country and
document type — i.e. what is actually recognised for THIS client right now, not
just what the catalogue allows.
It combines three inputs:
1. the client's current settings (--current-file, from get_global_settings.sh)
2. the default catalogue (--catalog-file, from get_supported_docs.sh)
3. whether ADVANCED_OCR is enabled (--advanced-ocr, from get_entitlements.sh)
Why all three: a field is only really recognised if (a) it's checked in the
client's effective settings AND (b) — for Extra Fields — the client has the
ADVANCED_OCR entitlement (otherwise the backend drops it silently).
Usage:
recognition_status.py BRA PASSPORT \
--current-file /tmp/current.json \
--catalog-file /tmp/catalogue.json \
--advanced-ocr true|false
Output (stdout, JSON): per-field status grouped into recognised / not recognised.
A human-readable summary is printed to stderr.
"""
import argparse
import json
import sys
def _die(msg, code=2):
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def _read_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except OSError as e:
_die(f"cannot read {path}: {e}", code=3)
except json.JSONDecodeError as e:
_die(f"{path} is not valid JSON: {e}", code=3)
def _country_mappings(doc, source):
if isinstance(doc.get("countryMappings"), dict):
return doc["countryMappings"]
id_doc = doc.get("idDocSettings")
if isinstance(id_doc, dict) and isinstance(id_doc.get("countryMappings"), dict):
return id_doc["countryMappings"]
_die(f"{source} has no countryMappings")
def status(country, doc_type, current, catalogue, advanced_ocr):
cat = catalogue.get(country, {}).get(doc_type)
if not cat:
_die(f"{country}/{doc_type} is not in the catalogue")
cat_default = cat.get("defaultFields", [])
cat_available = cat.get("availableFields", [])
# Effective config, resolved per column to match the backend:
# Default column — the catalogue defaults stay recognised unless the client's
# override supplies a NON-EMPTY defaultFields list. An empty/absent list does
# NOT disable the column (see OcrFieldsInfoModel#enrichWithClientCountryDocument),
# so a sparse scalar-only override must fall back to the catalogue here.
# Extra column — base is empty; the client's availableFields only ADD extras.
override = current.get(country, {}).get(doc_type)
configured = override is not None
override_default = (override or {}).get("defaultFields")
on_default = set(override_default) if override_default else set(cat_default)
on_available = set((override or {}).get("availableFields") or [])
recognised, not_recognised = [], []
for field in cat_default:
if field in on_default:
recognised.append({"field": field, "column": "default", "paid": False})
else:
not_recognised.append({"field": field, "column": "default",
"reason": "turned off (default field unchecked)"})
for field in cat_available:
if field in on_available and advanced_ocr:
recognised.append({"field": field, "column": "extra", "paid": True})
elif field in on_available and not advanced_ocr:
not_recognised.append({"field": field, "column": "extra",
"reason": "enabled but ADVANCED_OCR is OFF — backend drops it"})
else:
not_recognised.append({"field": field, "column": "extra",
"reason": "extra field, not enabled"})
return {
"country": country,
"docType": doc_type,
"advancedOcr": advanced_ocr,
"source": "client override" if configured else "catalogue default (client has no override)",
"recognised": recognised,
"notRecognised": not_recognised,
}
def _print_summary(result):
print(f"Recognised fields for {result['country']} / {result['docType']} "
f"(source: {result['source']}):", file=sys.stderr)
if result["recognised"]:
for f in result["recognised"]:
tag = "PAID — ADVANCED_OCR" if f["paid"] else "free"
print(f" ✅ {f['field']} ({tag})", file=sys.stderr)
else:
print(" (none)", file=sys.stderr)
print("Not recognised:", file=sys.stderr)
if result["notRecognised"]:
for f in result["notRecognised"]:
print(f" ⬜ {f['field']} — {f['reason']}", file=sys.stderr)
else:
print(" (none)", file=sys.stderr)
def main():
ap = argparse.ArgumentParser(description="Effective OCR recognition status for a country/doc type.")
ap.add_argument("country", help="ISO-3 country code, e.g. BRA")
ap.add_argument("docType", nargs="?",
help="document type, e.g. PASSPORT. Omit to report ALL doc types for the country.")
ap.add_argument("--current-file", required=True, help="client's global settings JSON")
ap.add_argument("--catalog-file", required=True, help="default catalogue JSON")
ap.add_argument("--advanced-ocr", required=True, choices=["true", "false"],
help="whether the client has the ADVANCED_OCR entitlement")
args = ap.parse_args()
current = _country_mappings(_read_json(args.current_file), "--current-file")
catalogue = _country_mappings(_read_json(args.catalog_file), "--catalog-file")
advanced_ocr = args.advanced_ocr == "true"
if args.docType:
doc_types = [args.docType]
else:
doc_types = sorted(catalogue.get(args.country, {}).keys())
if not doc_types:
_die(f"{args.country} is not in the catalogue")
results = []
for dt in doc_types:
result = status(args.country, dt, current, catalogue, advanced_ocr)
_print_summary(result)
results.append(result)
out = results[0] if args.docType else {"country": args.country, "docTypes": results}
json.dump(out, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
scripts/remove_country_mapping.py
#!/usr/bin/env python3
"""
Build a full countryMappings payload that REMOVES one or more (country, docType)
overrides — i.e. "Restore default settings" for those entries. The removed entries
fall back to the catalogue default on read.
The edit builder (build_country_mappings.py) only merges and never deletes, so
removal has its own builder: this produces the client's full current map MINUS
the named entries, to be sent with the PUT (replace-all) endpoint via
put_country_mappings.sh.
Usage:
get_global_settings.sh > /tmp/current.json
remove_country_mapping.py --current-file /tmp/current.json BRA:ID_CARD FRA:PASSPORT > /tmp/payload.json
put_country_mappings.sh /tmp/payload.json # PUT (replace-all)
Each positional arg is COUNTRY:DOCTYPE. Output (stdout): { "countryMappings": ... }.
"""
import argparse
import json
import sys
def _die(msg, code=2):
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def _read_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except OSError as e:
_die(f"cannot read {path}: {e}", code=3)
except json.JSONDecodeError as e:
_die(f"{path} is not valid JSON: {e}", code=3)
def _country_mappings(doc):
if isinstance(doc.get("countryMappings"), dict):
return doc["countryMappings"]
id_doc = doc.get("idDocSettings")
if isinstance(id_doc, dict) and isinstance(id_doc.get("countryMappings"), dict):
return id_doc["countryMappings"]
_die("--current-file has no countryMappings")
def main():
ap = argparse.ArgumentParser(description="Build a PUT payload that removes (restores) overrides.")
ap.add_argument("--current-file", required=True, help="client's current global settings JSON")
ap.add_argument("targets", nargs="+", metavar="COUNTRY:DOCTYPE",
help="entries to remove, e.g. BRA:ID_CARD")
args = ap.parse_args()
mappings = json.loads(json.dumps(_country_mappings(_read_json(args.current_file)))) # deep copy
removed, missing = [], []
for target in args.targets:
if ":" not in target:
_die(f"target '{target}' must be COUNTRY:DOCTYPE")
country, doc_type = target.split(":", 1)
docs = mappings.get(country)
if docs and doc_type in docs:
docs.pop(doc_type)
removed.append(target)
if not docs:
mappings.pop(country)
else:
missing.append(target)
for t in removed:
print(f"will remove (restore default): {t}", file=sys.stderr)
for t in missing:
print(f"warning: {t} is not in the client's overrides — nothing to remove", file=sys.stderr)
if not removed:
_die("none of the requested entries exist in the current overrides")
json.dump({"countryMappings": mappings}, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
SKILL.md
---
name: sumsub-supported-id-documents
description: Query AND edit Sumsub's supported identity documents. READ the built-in catalogue (documentsByCountries) — which document types are supported per country, whether they're double-sided, which OCR fields are default vs. available. EDIT a client's own supported-document overrides (idDocSettings.countryMappings) via the global-settings API. TRIGGER when the user asks "which countries / document types support field X", "what fields can I collect for a passport in DEU", "is the ID card double-sided in France", OR wants to change supported documents / toggle a document type / edit collected fields for a country. SKIP for creating levels or questionnaires (use the dedicated skills).
allowed-tools: Read, Bash
---
# Sumsub — Supported Documents Reference
Answers questions about Sumsub's built-in supported-documents catalogue
(`documentsByCountries`): per country and document type, whether the type is
supported, whether it's double-sided, and which OCR fields are extracted by
default vs. optionally available.
The catalogue is large (~250 countries, ~1350 country/doc-type pairs). This
skill never dumps it into the conversation — a local Python script keeps the
full catalogue in-process and returns only the relevant slice, collapsing to an
aggregate summary when a flat list would be too large to be useful.
## Endpoints
| Method | Path | When |
|---|---|---|
| `GET` | `/resources/api/agent/supportedDocs/documentsByCountries` | Read the built-in supported-documents catalogue (defaults for every country). |
| `GET` | `/resources/api/agent/globalSettings` | Read the client's CURRENT settings, incl. their `idDocSettings.countryMappings` overrides. |
| `GET` | `/resources/api/agent/globalSettings/extensionRules` | Read Sumsub's built-in expiry-extension rules (read-only reference; backs the "officially extended documents" expiry modes). |
| `PUT` | `/resources/api/agent/globalSettings/idDocSettings/countryMappings` | **Replace-all — the only write path.** Always send the FULL map built by the scripts (read-modify-write for edits, minus-one-entry for removals). |
All use App Token auth with `manageClientSettings`.
The catalogue GET returns `{ "countryMappings": { "<ISO3>": { "<DOC_TYPE>": { supported, doubleSided, doubleSidedCanBeChanged, shouldBeSigned, acceptDigitalDoc, acceptScreenshots, expirationCheckMode, sidesSelectionMode, defaultFields[], availableFields[] } } } }`.
> ⚠️ **Two different shapes — mind the path.** The catalogue GET puts
> `countryMappings` at the **top level**. GET globalSettings nests it one level
> deeper, under **`idDocSettings.countryMappings`** (the root also has `minAge`,
> `poaCheckSettings`, `poiCheckSettings` — the key-wide expiry mode — etc.). When reading `/tmp/current.json` (globalSettings) in an
> inline `python3 -c`, use `['idDocSettings']['countryMappings']` — the top-level
> path raises `KeyError: 'countryMappings'`.
> Fields are serialised `NON_NULL`: a flag that is **absent** from the JSON is at
> its default (not set). For acceptance flags, absent ⇒ `false` (e.g. no
> `acceptScreenshots` key ⇒ screenshots are **not** accepted). Never report a flag
> as enabled just because it's missing; report the default and say it's the default.
> **PUT is replace-all:** the backend swaps the whole `countryMappings` map for
> whatever you send — anything omitted is wiped (reverts to catalogue defaults).
> That's why every write goes through the scripts: `build_country_mappings.py`
> merges your changes into a FRESH copy of the current map, and
> `remove_country_mapping.py` builds the map minus one entry. Never hand-craft a
> partial payload, and never reuse a stale `/tmp/current.json` — re-fetch it right
> before building or a concurrent dashboard edit gets silently reverted.
### Key-wide root blocks in GET globalSettings (read them, don't miss them)
Besides `idDocSettings.countryMappings`, the globalSettings root carries
**key-wide** blocks that change document answers. They are read-only context for
this skill (edits here go through PUT countryMappings only), but skipping them
gives wrong answers — a per-country entry that looks "not configured" may be
governed by a root block:
| Root path | What it holds | When it changes the answer |
|---|---|---|
| `poiCheckSettings.expirationCheckMode` | key-wide expiry mode | fallback in the expiry cascade (**C**) |
| `poiCheckSettings.minimumResidualValidityInMonths` | doc must stay valid ≥ N more months; **`0` = no requirement** (see **C**) | part of any validity answer (**C**) |
| `ongoingMonitoringSettings` (`enabled`, `idDocExpireInDays`) | expiry monitoring AFTER approval (licence-gated) | "what happens when the doc expires" |
| `minAge` / `maxAge` | key-wide age limits (checked against DOB); dashboard auto-fills on save (min 16 — 18 for RUS-licensed keys — max 110), so usually present | "why was the doc/applicant rejected by age" |
| `idDocSettings.ekycSourceMappings` | eKYC sources per country — sibling of `countryMappings`, edited on the SAME dashboard "Supported ID documents" screen but saved as a separate payload; PUT countryMappings preserves it server-side (the endpoint only replaces `countryMappings`) | eKYC questions; don't confuse with countryMappings |
| `enableAutoCompleteShortDates` | OCR auto-completes 2-digit years in dates (dashboard: "Birth date" checkbox) | recognised date values |
| `imageConstraints` (`minFileSize`, `maxFileSize`) | upload size limits (dashboard: General → Applicant settings) | "why was the file not accepted" |
| `poaCheckSettings` (deprecated) | POA acceptance (`validMonths`, `acceptIdAsPoa`, `acceptSameDocAsPoa`) | POA questions — report as read-only context |
| `crossValidatorSettings` | doc-vs-profile comparison (`nameComparisonMode`, `fuzzyThreshold`, …); API-only, no dashboard UI | mismatch/cross-check rejections |
Everything else on the root (`watchListCheckSettings`, `cryptoCheckSettings`,
`bankCardCheckSettings`, `kybIntegrationSettings`, `uiSettings`,
`duplicateSettings`, `sourceKeysSettings`, `applicantTags`, `supportEmail`,
`disableSumsubId`, `idDocServiceSettings`) is outside this skill's domain — don't
answer document questions from those blocks or offer to edit them here.
## Auth — App Token + secret (sandbox only)
Same model as the other write skills. See [`sumsub-api-auth`](../sumsub-api-auth/SKILL.md).
| Var | Example |
|---|---|
| `SUMSUB_APP_TOKEN` | `sbx:...` — sandbox App Token from the dashboard. |
| `SUMSUB_SECRET_KEY` | The paired secret shown once at token creation. |
| `SUMSUB_BASE` | Optional. Defaults to `https://api.sumsub.com`. |
**DEFAULT to effective recognition status (A).** Any question about what a country
/ doc type recognises, collects, or has available **right now** — including
phrasings like "what fields are currently available / recognised / collected",
"what fields are available for BRA", "what does the passport collect" — is about
the CLIENT'S effective state, so use **recognition status (A)**. The bare
catalogue is defaults only and will give a misleading answer ("available" in the
catalogue ≠ enabled for this client).
Only use the **catalogue query (B)** when the user explicitly asks what is
*possible* in the abstract or across countries — "which countries support field
X", "is gender ever extractable for passports", "what doc types exist for BRA".
A question about **expiry / validity checks** ("what validity checks apply",
"is an expired doc accepted") → use **(C)** below: report the `expirationCheckMode`
AND the concrete extension rule that applies (the 60+ / extension text), not just
the mode.
A broad question about how a document is handled ("acceptance rules", "what
settings apply", "what's configured for X") → use **(D)** below: read the
whole effective `(country, docType)` entry from GET globalSettings and report
**every** setting on it, on or off — don't curate or guess a subset, and don't omit
flags left at their default.
If unsure which the user means, pick **A** (effective) — it's the honest answer
and it also tells you what the catalogue allows.
> ⚠️ **Always go through these scripts — never read the Paler source.** If you
> happen to have the `paler` repo open, do NOT answer from its files
> (`documents-by-countries.json`, Java sources, etc.). Those contain only the
> built-in catalogue defaults — the **client's effective state**
> (`countryMappings` overrides + `ADVANCED_OCR`) is not in the code at all, only
> behind the API. Reading source can only give defaults and will silently answer
> the wrong question. The data must come from `get_global_settings.sh` /
> `get_supported_docs.sh` / `get_entitlements.sh`.
### A. Effective recognition status (what's really recognised)
```bash
S=${CLAUDE_SKILL_DIR}/scripts
$S/get_supported_docs.sh > /tmp/catalogue.json
$S/get_global_settings.sh > /tmp/current.json
ADV=$($S/get_entitlements.sh ADVANCED_OCR >/dev/null 2>&1 && echo true || echo false)
$S/recognition_status.py BRA PASSPORT \
--current-file /tmp/current.json \
--catalog-file /tmp/catalogue.json \
--advanced-ocr "$ADV"
# Omit the doc type to report ALL doc types for the country in one go
# (e.g. "fields for documents from Brazil"):
$S/recognition_status.py BRA \
--current-file /tmp/current.json --catalog-file /tmp/catalogue.json --advanced-ocr "$ADV"
```
Reports each field as ✅ recognised (free / PAID) or ⬜ not recognised (with the
reason: default turned off / extra not enabled / ADVANCED_OCR off). Present this
to the user — it's the honest "what's actually recognised" answer.
### B. Catalogue query (what's possible)
1. **Fetch the catalogue once**: `get_supported_docs.sh > /tmp/catalogue.json`
(reuse it for follow-ups — large and static within a session).
2. **Translate the question into a compact filter spec** (see below).
3. **Run the query** — spec on stdin, catalogue via `--data-file`:
```bash
echo '{"countries": ["DEU"]}' \
| ${CLAUDE_SKILL_DIR}/scripts/query_supported_docs.py --data-file /tmp/catalogue.json
```
4. **Report** the slice. If it's a `summary` (see decision rules), explain the
coverage/distribution rather than inventing a list.
### C. Expiry / validity checks (mode + actual extension rule)
For any question about a document's **expiry / validity checks** ("what validity
checks apply", "is an expired X accepted", "validity rules for BRA ID") give
BOTH parts — don't stop at the mode:
1. **The mode** — resolve it through the full cascade, not just the per-country
entry (see the four modes in
[references/fields-glossary.md](references/fields-glossary.md)):
`idDocSettings.countryMappings.<country>.<docType>.expirationCheckMode`
→ if absent, the **key-wide** `poiCheckSettings.expirationCheckMode` at the
globalSettings **root** → if absent, the catalogue default for the pair.
Both client values come from the same `get_global_settings.sh` response —
don't stop at the `(country, docType)` entry: a missing per-country mode with
`poiCheckSettings.expirationCheckMode` set means that key-wide mode IS active
for the pair.
2. **The actual extension rule** — if the mode accepts extended documents
(`generallyAcceptedRegulations` / `localRegulations`), pull the concrete rule
that applies and show it. **Don't just offer to** — include it:
```bash
$S/get_extension_rules.sh > /tmp/ext.json
# then filter to the country/doc type, e.g.:
python3 -c "import json;[print(r['docType']['idDocType'],'—',r.get('defaultDescription'))
for r in json.load(open('/tmp/ext.json'))['rules']
if r['docType']['country']=='BRA' and r['docType']['idDocType']=='ID_CARD']"
```
e.g. for BRA/ID_CARD this surfaces "owner 60+ ⇒ valid indefinitely". That rule
is the real answer to "what expiry checks apply" — the mode alone is incomplete.
3. **The other validity knobs** — a complete validity answer also reports, when
set on the globalSettings root:
- `poiCheckSettings.minimumResidualValidityInMonths` — ⚠️ **`0` means "accept
any validity period" (no requirement)**, it is what the dashboard writes for
the default radio — never report it as "must be valid ≥ 0 months". A
non-zero N = the doc must remain valid at least N more months at check time
(stricter than "not expired"; the dashboard's custom option defaults to 3).
- `ongoingMonitoringSettings.idDocExpireInDays` (with `enabled`) — expiry
monitoring after approval: the doc is flagged N days before it expires
(dashboard default 7). Absent/0 = no expiry alerting. The whole section is
licence-gated (`ONGOING_MONITORING_EXPIRED_DOCS` background-check target).
Both are key-wide (no per-country variant) — read them from the same
`get_global_settings.sh` response.
**Where the key-wide mode is edited:** dashboard → Global Settings → User
Verification → ID verification → "ID doc expiry settings". That screen only
offers `strict` ("respect validity period"), `generallyAcceptedRegulations`
and, via its extra checkbox, `localRegulations` — key-wide `allowExpired` is
not settable from the dashboard (per-country entries can still have it via
the Supported Documents drawer or the API).
### D. Full settings dump (everything that's on/off)
For a broad "how is this document handled / what's configured" question, don't
hand-pick a subset — read the **whole** effective `(country, docType)` entry and
report every setting on it.
```bash
$S/get_global_settings.sh > /tmp/current.json # client's effective settings
$S/get_supported_docs.sh > /tmp/catalogue.json # catalogue, for the default baseline
# Pull the entry the client actually has (override), e.g. BRA/ID_CARD:
# NOTE: in GET globalSettings the map is nested under idDocSettings.countryMappings
# (NOT top-level — that's only the catalogue GET). Using the wrong path → KeyError.
python3 -c "import json;print(json.dumps(
json.load(open('/tmp/current.json'))['idDocSettings']['countryMappings'].get('BRA',{}).get('ID_CARD',{}),
indent=2, ensure_ascii=False))"
```
Then present every key on that entry as a setting — `supported`,
`sidesSelectionMode` / `doubleSided`, `shouldBeSigned`, `acceptDigitalDoc`,
`acceptScreenshots`, `expirationCheckMode`, `ocrSettings`, sub-type flags,
`defaultFields` / `availableFields`, and anything else present — translating each to
its UI meaning via [references/fields-glossary.md](references/fields-glossary.md).
Rules for an honest dump:
- **Don't curate.** Walk the keys that are actually on the entry; don't decide some
are "not relevant". A setting you skip reads as "not configured".
- **Defaults are absent (`NON_NULL`).** A missing key is at its default, not off-
screen. If the client has no override for the pair at all, the whole entry is
absent — fall back to the catalogue entry (`/tmp/catalogue.json`) for the baseline
and say these are Sumsub defaults, not client settings.
- **Report independent flags independently.** In particular `acceptDigitalDoc`
(uploaded files/scans/PDFs) and `acceptScreenshots` (screenshots) are separate
checks — state each on its own; never merge them or say "only live camera photos".
- **Expiry:** `expirationCheckMode` missing on the entry does NOT mean catalogue
default — check the key-wide `poiCheckSettings.expirationCheckMode` at the
globalSettings root first (cascade in **C**). When the effective mode accepts
extended docs, also surface the concrete extension rule (see **C**) — the mode
alone is incomplete.
## Procedure (edit a client's supported documents) — read-modify-write + PUT
The endpoint is replace-all, so edits are read-modify-write: fetch the current
map, let the builder merge your changes into it locally, PUT the full result.
Both input files are required by the builder.
1. **Read the catalogue and the CURRENT settings** (fetch `current.json` fresh —
a stale snapshot would revert edits made in between):
```bash
${CLAUDE_SKILL_DIR}/scripts/get_supported_docs.sh > /tmp/catalogue.json
${CLAUDE_SKILL_DIR}/scripts/get_global_settings.sh > /tmp/current.json
```
2. **Translate the request into a compact change spec** (see below) — only the
fields you want to change, per `(country, docType)`.
3. **Build the full payload** (current map + merged changes):
```bash
echo '<change spec>' | ${CLAUDE_SKILL_DIR}/scripts/build_country_mappings.py \
--current-file /tmp/current.json --catalog-file /tmp/catalogue.json > /tmp/payload.json
```
The script validates enums and that each field is in the right column, and
prints a `Field changes:` summary. It refuses to auto-move a field to the other
column (that would silently replace the other column's list) — fix the spec if
it errors. A brand-new `(country, docType)` override is seeded with the
catalogue defaults for `supported`, `doubleSided`, `doubleSidedCanBeChanged`
and `shouldBeSigned` (spec values win; a null catalogue value stays absent), so
the new entry keeps behaving like the default it shadows — e.g. `doubleSided`
null would otherwise read as "any side", dropping the catalogue's two-sides
requirement. And whenever the spec sets `sidesSelectionMode`, the deprecated
`supported`/`doubleSided` pair is synced to the matching legacy values (same
dual-write the dashboard does), so pre-`sidesSelectionMode` readers see the
same behavior. Both are reported in the summary.
4. **If the change sets any `extraFields`, verify the ADVANCED_OCR entitlement**
(billed add-on; without it the backend silently drops them):
```bash
${CLAUDE_SKILL_DIR}/scripts/get_entitlements.sh ADVANCED_OCR
```
If not enabled, warn the user and don't proceed with extra fields. Default
Fields are not gated.
4b. **Critical fraud settings need an explicit warning.** If the change sets
`acceptScreenshots: true` (disables screenshot protection) or
`acceptDigitalDoc: true` (accepts easily-modified uploaded files), the build
script prints a `warning:` — relay it to the user verbatim and get a clear
"yes" before applying. Enabling screenshots especially may approve fake
applications and expose the client to penalties/chargebacks. (Turning these
OFF is safe and needs no special warning.)
5. **Show the diff and WAIT for explicit confirmation.** Diff the payload against
the same `current.json` it was built from (don't hand-write an inline
`python3 -c` diff):
```bash
${CLAUDE_SKILL_DIR}/scripts/diff_country_mappings.py \
--current-file /tmp/current.json --payload-file /tmp/payload.json
```
Entries the builder carried over unchanged are silent, so the diff shows only
your changes. Anything showing as REMOVED that you didn't intend means the
payload was built from the wrong/stale snapshot — rebuild, don't PUT. This is a
workspace-wide change — never apply without an explicit "yes".
6. **PUT** the confirmed payload:
```bash
${CLAUDE_SKILL_DIR}/scripts/put_country_mappings.sh /tmp/payload.json
```
7. **Report** the HTTP status and what changed. On a 4xx, surface the body
verbatim. If you set `extraFields` without ADVANCED_OCR, re-read the settings
and note the extra fields were dropped server-side.
> **Changing one field in a column without losing the others:** a column list in
> the spec replaces that column wholesale. To turn ON one more extra field while
> keeping the existing ones, first read the current enabled set
> (`recognition_status.py`) and pass the FULL intended `extraFields` list.
## Procedure (remove / restore default) — PUT
To restore a `(country, docType)` to the catalogue default, remove its override
from the full map and PUT the result:
```bash
${CLAUDE_SKILL_DIR}/scripts/get_global_settings.sh > /tmp/current.json
${CLAUDE_SKILL_DIR}/scripts/remove_country_mapping.py \
--current-file /tmp/current.json BRA:ID_CARD > /tmp/payload.json # full map MINUS the entry
# show the diff (the removal shows as REMOVED), confirm, then PUT:
${CLAUDE_SKILL_DIR}/scripts/diff_country_mappings.py \
--current-file /tmp/current.json --payload-file /tmp/payload.json
${CLAUDE_SKILL_DIR}/scripts/put_country_mappings.sh /tmp/payload.json # PUT (replace-all)
```
### Change spec
A **sparse** spec — list only the fields you want to change per `(country,
docType)`. Field keys mirror the dashboard "Fields management" screen (two
checkbox columns: **Default Fields** / **Extra Fields**).
```yaml
changes:
- country: DEU # ISO-3
docType: ID_CARD # IdDocType
expirationCheckMode: strict # scalar settings — merged locally, others preserved
ocrSettings: {ocrRuleMode: MRZ_DOC}
- country: BRA
docType: PASSPORT
extraFields: [placeOfBirth, gender] # REPLACES the Extra Fields column (PAID)
```
This covers the main "Supported ID Documents" screen buttons:
- **Manage documents** — set fields / modes / subtypes (keys below)
- **Change documents sides** — set `sidesSelectionMode`
(`oneSide`/`twoSides`/`smartMode`; `disabled` = the "Don't accept" option)
- **Restore default settings** — NOT done here; it's a removal → use the
separate removal procedure (`remove_country_mapping.py`) above; the builder
refuses `remove`.
How it maps to recognition and cost (the script reports this):
- **Default Fields** — free; checked fields are recognised. Omit a field to
uncheck it (stop recognising). Default fields are checked by default.
- **Extra Fields** — recognised only when checked, and that requires the
`ADVANCED_OCR` entitlement (step 4). These are the paid fields.
- A field's column is **fixed by the catalogue** — you can't list a field under
the wrong column. If you do, the script **errors out** (it will not auto-move the
field, because that would silently replace the other column's list) and tells you
the correct column — fix the spec. Read the catalogue (`{country, docType}` query)
to see which fields live in which column.
- The script prints a `Field recognition result:` summary per doc listing what's
recognised free vs. PAID. **Show it to the user on the confirmation step** — it
speaks the same language as the UI ("Default Fields" / "Extra Fields").
On the wire `extraFields` becomes the API's `availableFields` — the script maps
it for you; you only deal in UI terms.
**Every other control on the "Fields management" screen is settable too** — set
the matching key in the change spec. The full UI-control → spec-key table (with
enum values and mappings) is in
[references/fields-glossary.md](references/fields-glossary.md). Highlights:
| User asks | Spec key | Value |
|---|---|---|
| accept docs sent as images/files (scans, PDFs) — not just live camera photos | `acceptDigitalDoc` | `true` |
| accept screenshots specifically | `acceptScreenshots` | `true` |
| MRZ mode | `ocrSettings: {ocrRuleMode: MRZ_DOC}` | |
| prefer non-latin recognition | `ocrSettings: {preferNonLatinFromOcr: true}` | |
| expiry policy | `expirationCheckMode` | `allowExpired`/`strict`/`generallyAcceptedRegulations`/`localRegulations` |
| accept only/reject some subtypes | `allowedOcrDocumentTypes` / `forbiddenOcrDocumentTypes` | subtype id list |
| hide subtypes in WebSDK | `disableDocumentReferences` | `true` |
| accept this doc type at all | `sidesSelectionMode` | a positive mode to accept; `disabled` to stop accepting |
| how many sides required | `sidesSelectionMode` | `smartMode`/`oneSide`/`twoSides`/`disabled` |
The script validates enum values (`expirationCheckMode`, `ocrRuleMode`) and fails
on typos. Truly internal keys not in the glossary (`shouldBeMaskedSettings`,
`documentReferences`, …) are passed through from the baseline untouched — don't
invent values for them.
**Deprecated keys are NOT settable** — `supported`, `doubleSided`,
`doubleSidedCanBeChanged`, `acceptedAsPoa`, `acceptSameDocAsPoa` are deprecated
on the backend; `build_country_mappings.py` rejects them with the replacement to
use (sides/acceptance → `sidesSelectionMode`; the POA flags have none — decline
the edit). You'll still see them in GET responses — read them for context, never
put them in a change spec. The ONE place they're still written is the builder's
own seeding of a brand-new override, which copies their defaults from
`GET /supportedDocs/documentsByCountries` so the new entry keeps behaving like
the catalogue default for legacy readers — that happens automatically, not via
the spec.
**Gated keys are NOT settable either** — `shouldBeSigned` (dashboard-editable
only behind the `showRejectByMissingSignatureSetting` feature flag; turns on
auto-rejection of unsigned documents) and `shouldBeMasked` (masking of legally
protected national IDs — JPN/KOR/NLD/NGA/SGP; for NGA ID_CARD even the dashboard
allows only Sumsub staff). `build_country_mappings.py` rejects both. If asked to
change them, explain the gate and direct the user to the dashboard (with the
flag enabled) or Sumsub support. Reading/reporting them is fine.
The valid entries for `defaultFields` / `availableFields` are **per country and
doc type** — not a fixed global list. Read the catalogue first (a `field`-level
or `{country, docType}` query) to learn the valid field names before building a
change spec.
## Filter spec
All keys optional. Different keys combine with AND; lists within a key are OR.
```yaml
countries: [DEU, FRA] # ISO-3 codes
docTypes: [ID_CARD, DRIVERS] # document types
field: placeOfBirth # find where this OCR field exists (inverse query)
fieldKind: available | default | any # which list to search field in (default: any)
flags: # match per-doc boolean flags
supported: true # IMPLICIT DEFAULT — unsupported docs are excluded
doubleSided: false # unless you set supported:false explicitly
output: auto | list | summary # default: auto
limit: 100 # max items in a list before truncation
```
### Examples
| Question | Spec |
|---|---|
| What can I collect for a German ID card? | `{"countries":["DEU"],"docTypes":["ID_CARD"]}` |
| Which countries/types expose the `category` field? | `{"field":"category","fieldKind":"available"}` |
| Where is `parentName1` collectable at all? | `{"field":"parentName1"}` |
| Which docs are double-sided in France? | `{"countries":["FRA"],"flags":{"doubleSided":true}}` |
| Include unsupported docs too | `{"countries":["DEU"],"flags":{"supported":false}}` |
## List vs. summary (auto mode)
The script returns `mode: "list"` for compact results and `mode: "summary"`
when a flat list would be useless:
- **Near-universal field** (present in ≥ 80% of matched pairs, e.g. `dob`,
`firstName`): returns coverage + `byDocType`; if the field is absent from only
a short list, that complement is returned as `absentIn`.
- **Too many matches** (a field-presence result over 200 pairs): returns
coverage + `byDocType` + `topCountries` distribution instead of a truncated
list.
To force a flat list anyway, set `output: list` and raise `limit`.
## Gotchas
- **Unsupported documents are hidden by default.** A doc with `supported: false`
can't be configured, so it's excluded unless you pass `flags.supported: false`.
This keeps coverage math honest (otherwise empty-field unsupported pairs
dilute every percentage).
- **This is the built-in catalogue, not the client's overrides.** What a specific
client actually has enabled lives in their global settings
(`idDocSettings.countryMappings`) — a different endpoint. Use this skill to
learn what's *possible*, not what a given account has *configured*.
- **`PROFILE_SCREENSHOT` and a long tail of non-POI types** (`OTHER`, `VISA`,
`UTILITY_BILL`, …) live in this catalogue too. Filter by `docTypes` if the
user only cares about passports / ID cards / driver's licences.
- **Verification Level settings override Global Settings.** This skill edits the
client's *global* supported-documents config. A specific Verification Level can
carry its own supported-documents overrides that take **higher priority** for
applicants on that level. The level opts into this with `useCustomIdDocSettings:
true` — when set, the level's own `idDocSettings.countryMappings` fully replaces
global for that level (a flag absent there is at its default, ignoring global).
So a global change may not take effect for a level that overrides it — if a user
reports "my change didn't apply", read the level and check `useCustomIdDocSettings`
and its `idDocSettings`. To actually edit a level's document settings, use the
[`sumsub-create-level`](../sumsub-create-level/SKILL.md) skill (GET the level,
change the flag in its `idDocSettings`, PATCH the **full** level back).