examples/eu-bank-friendly.json
{
"name": "EU PoA — bank-friendly (example)",
"desc": "EU-only preset: 6-month default, bank statements valid for 12 months, POI-as-POA accepted for 3 months.",
"includedCountries": ["DEU", "FRA", "ESP", "ITA", "NLD", "BEL", "AUT", "PRT", "IRL", "FIN", "SWE", "DNK", "POL", "CZE", "GBR"],
"settings": {
"acceptMultiplePages": true,
"acceptDocScreenshot": true,
"validMonths": 6,
"addressTypes": ["dwelling", "poBox"],
"acceptableLanguages": ["en", "de", "fr", "es", "it", "nl", "pt"],
"providers": {
"bank": {
"validMonths": 12,
"subTypes": ["bankStatement", "bankLetter", "other"]
},
"utilityProvider": {
"subTypes": ["telecom", "utilityBill", "other"]
},
"governmentOrganization": {
"subTypes": ["statement", "voterRegistration", "taxBill", "other"]
},
"mobileOperator": {},
"other": {
"subTypes": ["lease", "other", "universityLetter"]
}
},
"poiAsPoa": {
"enabled": true,
"sameDoc": false,
"validMonths": 3,
"allowedTypes": ["PASSPORT", "ID_CARD", "RESIDENCE_PERMIT", "DRIVERS"]
},
"crossValidator": {
"nameMode": "weakContainment",
"addressMode": "fuzzy",
"fuzzyThreshold": 0.75,
"ignoreMiddleName": false,
"ignoreFixedInfo": false
},
"requireCountryMatch": false,
"useIssueDateForExpiry": false
}
}
examples/minimal.json
{
"name": "Standard PoA — 6 months (example)",
"desc": "Bare-minimum preset: bank / utility / government / mobile / other, 6-month validity, POI-as-POA off.",
"settings": {
"acceptMultiplePages": true,
"acceptDocScreenshot": true,
"validMonths": 6,
"addressTypes": ["dwelling", "poBox"],
"providers": {
"bank": { "subTypes": ["bankStatement", "bankLetter", "other"] },
"utilityProvider": { "subTypes": ["telecom", "utilityBill", "other"] },
"governmentOrganization": { "subTypes": ["statement", "voterRegistration", "taxBill", "other"] },
"mobileOperator": {},
"other": { "subTypes": ["lease", "other"] }
}
}
}
examples/per-country-tight.json
{
"name": "Global PoA with tighter Brazil rules (example)",
"desc": "6-month global defaults; Brazil-specific override tightens validity to 3 months and disables the 'other' provider category.",
"excludedCountries": ["PRK", "IRN"],
"settings": {
"acceptMultiplePages": true,
"acceptDocScreenshot": true,
"validMonths": 6,
"addressTypes": ["dwelling", "poBox"],
"providers": {
"bank": { "subTypes": ["bankStatement", "bankLetter", "other"] },
"utilityProvider": { "subTypes": ["telecom", "utilityBill", "other"] },
"governmentOrganization": { "subTypes": ["statement", "voterRegistration", "taxBill", "other"] },
"mobileOperator": {},
"other": { "subTypes": ["lease", "other", "universityLetter"] }
},
"poiAsPoa": {
"enabled": true,
"sameDoc": true,
"validMonths": 3,
"allowedTypes": ["PASSPORT", "ID_CARD", "RESIDENCE_PERMIT", "DRIVERS"]
},
"crossValidator": {
"nameMode": "weakContainment",
"addressMode": "fuzzy",
"fuzzyThreshold": 0.75
}
},
"byCountry": {
"BRA": {
"acceptMultiplePages": true,
"acceptDocScreenshot": true,
"validMonths": 3,
"addressTypes": ["dwelling"],
"providers": {
"bank": { "subTypes": ["bankStatement", "bankLetter"] },
"utilityProvider": { "subTypes": ["utilityBill", "telecom"] },
"governmentOrganization": { "subTypes": ["taxBill", "statement"] }
},
"poiAsPoa": {
"enabled": true,
"sameDoc": true,
"validMonths": 3,
"allowedTypes": ["PASSPORT", "ID_CARD", "RESIDENCE_PERMIT", "DRIVERS"]
},
"crossValidator": {
"nameMode": "strict",
"addressMode": "fuzzy",
"fuzzyThreshold": 0.85
}
}
}
}
references/poa-preset-schema.md
# PoaStepSettings — schema reference
Source: Sumsub OpenAPI (`components.schemas.PoaStepSettings`, plus `PoaDocumentSettings`, `PoaTypeSettings`, `PoiAsPoaSettings`, `CrossValidatorSettings`).
## Top-level fields (`PoaStepSettings`)
| Field | Type | Notes |
|---|---|---|
| `name` | string, ≥1 char | **Required**. Shown in the dashboard preset list. Must be a non-empty string; need not be unique. |
| `desc` | string | Optional description. |
| `includedCountries` | string[] ISO-3 | Country **allow-list**. If set, the preset only applies when the applicant is in one of these countries. |
| `excludedCountries` | string[] ISO-3 | Country **block-list**. If set, the preset *does not* apply for these countries (falls back to the level's default behavior). Mutually exclusive with `includedCountries`. |
| `settings` | `PoaDocumentSettings` | **Defaults applied everywhere** (subject to the country scope above). |
| `changedSettingsByCountry` | `Map<ISO-3, PoaDocumentSettings>` | Per-country overrides. Whole settings block per key — Sumsub does NOT merge with `settings` at the field level; whatever you put here replaces the defaults for that country. The skill builder accepts a partial spec and only emits the keys you set, but downstream Sumsub treats present fields as the full per-country answer. |
| `sharedTo` | array | Optional cross-tenant sharing references. Pass-through. |
| `id`, `clientId`, `createdAt`, `createdBy`, `modifiedAt`, `copiedFrom`, `copyOf` | various | Server-populated. Do not send. |
## `PoaDocumentSettings`
| Field | Type | Notes |
|---|---|---|
| `acceptDocScreenshot` | boolean | Allow phone screenshots of e-statements / bills. |
| `acceptMultiplePages` | boolean | Allow a multi-page POA upload. |
| `acceptableLanguages` | string[] | ISO-639-1 language codes — limits which OCR languages the validator accepts. |
| `allowedAddressTypes` | string[] | Which address types pass. Observed values: `dwelling` (residential), `poBox` (P.O. boxes globally), `poBoxSpecialCountries` (P.O. boxes only in countries where they are legally accepted as residence). |
| `allowedTypesSettings` | `Map<PoaCompanyContactType, PoaTypeSettings>` | Per provider-type rules (see below). |
| `crossValidatorSettings` | `CrossValidatorSettings` | Name/address comparison between POI and POA. |
| `poiAsPoaSettings` | `PoiAsPoaSettings` | Whether the identity doc can also count as proof of address. |
| `requirePoiPoaCountryMatch` | boolean | Reject if POI country ≠ POA country. |
| `useOnlyIssueDateForExpiredCheck` | boolean | Use only the document's `issuedDate` (not `expiry`) when checking "still fresh enough" against `validMonths`. |
## `PoaCompanyContactType` (provider-type enum — keys of `allowedTypesSettings`)
`bank`, `utilityProvider`, `governmentOrganization`, `mobileOperator`, `other`.
## `PoaTypeSettings` (one per provider type)
| Field | Type | Notes |
|---|---|---|
| `validMonths` | number | How many months a doc of this type is accepted past its issue date. |
| `acceptUnconventionalProviders` | boolean | Allow non-mainstream providers (e.g. a small local co-op bank). Pairs with `poaUnconventionalProviderSettings`. |
| `allowedSubTypes` | `PoaSubType[]` | Which sub-types this provider may produce. Enum below. |
| `forbiddenDocumentNames` | string[] (≤300) | OCR'd doc names that auto-reject (e.g. `"Welcome packet"`). |
| `forbiddenOrgNames` | string[] (≤300) | Organization names to reject. |
| `forbiddenOrgWebsites` | string[] (≤300) | Org websites to reject. |
| `poaUnconventionalProviderSettings` | object | `{allowedOrgNames[], allowedOrgWebsites[]}`. Only consulted when `acceptUnconventionalProviders=true`. |
## `PoaSubType` (enum — `allowedSubTypes` values)
`statement`, `voterRegistration`, `taxBill`, `telecom`, `utilityBill`, `bankStatement`, `bankLetter`, `lease`, `universityLetter`, `employmentLetter`, `other`.
Typical pairings (observed in real-world configs):
- `bank` → `[bankStatement, bankLetter, other]`
- `utilityProvider` → `[telecom, utilityBill, other]`
- `governmentOrganization` → `[statement, voterRegistration, taxBill, other]`
- `other` → `[lease, other, universityLetter]`
- `mobileOperator` → no sub-types (handled separately)
## `PoiAsPoaSettings`
| Field | Type | Notes |
|---|---|---|
| `acceptPoiAsPoa` | boolean | Master switch — allow an identity document to also serve as proof of address. |
| `acceptSamePoiAsPoa` | boolean | Allow the **same physical document** that was used for POI to be re-used for POA. |
| `validMonths` | number | How recent the POI must be to count as POA. |
| `allowedTypes` | string[] | `IdDocType` values eligible. Production data uses `[PASSPORT, ID_CARD, RESIDENCE_PERMIT, DRIVERS]`. |
## `CrossValidatorSettings`
| Field | Type | Notes |
|---|---|---|
| `nameComparisonMode` | enum | `strict`, `weakContainment`, `def`, `ai`, `fuzzy`, `containment`, `fuzzyContainment`. |
| `addressComparisonMode` | enum | `strict` or `fuzzy`. |
| `fuzzyThreshold` | number 0-1 | Similarity threshold when `*ComparisonMode = fuzzy`. Production data uses ~0.75. |
| `ignoreMiddleNameMismatch` | boolean | Pass when only middle names differ. |
| `ignoreFixedInfo` | boolean | Skip applicant-`fixedInfo` comparison entirely (only check against POI doc data). |
## Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| `POST` | `/resources/api/agent/poaStepSettings` | API (App Token) | Create a new preset. Body must NOT carry `id`. |
| `PATCH` | `/resources/api/agent/poaStepSettings` | API (App Token) | Update an existing preset. Body MUST carry `id`. |
| `GET` | `/resources/api/agent/poaStepSettings/{id}` | API (App Token) | Read one preset (verify what landed; resolve `name` from a known `id`). |
There is no list or DELETE endpoint on the public API — to find a preset
by name you need to remember its `id` from creation. Browsing all presets
or deleting one is done by an operator in the Sumsub dashboard UI.
## Patterns observed in 16 real-world presets
| Pattern | Frequency |
|---|---|
| Global preset (no country scope) | 12 / 16 |
| `excludedCountries` only | 3 / 16 |
| `includedCountries` only | 1 / 16 (Brazil-specific) |
| Has per-country overrides | 4 / 16 (BRA, ALA, AFG, etc.) |
| `poiAsPoaSettings` enabled | 16 / 16 (always present, often with same-doc + 3-month validity) |
| `crossValidatorSettings` enabled | 16 / 16 (typically `weakContainment` + `fuzzy` + `0.75`) |
| Provider keys covered | `bank` + `utilityProvider` + `governmentOrganization` + `mobileOperator` + `other` is the standard "full" set |
## Common gotchas
- **`includedCountries` and `excludedCountries` are mutually exclusive.** Sumsub silently uses whichever is non-empty if both are set; the builder rejects upfront.
- **ISO-3 country codes** (not alpha-2). `USA`, `DEU`, `BRA` — not `US`, `DE`, `BR`.
- **`changedSettingsByCountry[<country>]` replaces, not merges.** When you override a country, include every field you care about — Sumsub uses the per-country block as the answer for that country, not as a delta on `settings`. The skill's `byCountry` shorthand emits what you ask for; if you want to keep `settings`-level fields, restate them in the override.
- **`acceptSamePoiAsPoa: true` requires `acceptPoiAsPoa: true`.** The dashboard greys out same-doc when POI-as-POA is off; Sumsub validates this server-side.
- **Validity-month logic** — by default Sumsub checks "doc issue date is within `validMonths` ago AND not expired." Setting `useOnlyIssueDateForExpiredCheck=true` removes the expiry check (useful for docs without explicit expiry dates).
- **Unconventional providers** are off by default; turning them on without populating `allowedOrgs.names` / `allowedOrgs.websites` will accept many noisy results.
- **Attaching to a level** is a separate step. After creation, edit any `PROOF_OF_RESIDENCE` doc-set on the relevant level: `poaStepSettingsId = "<new preset id>"`. See the `sumsub-create-level` skill.
- **`allowedTypesSettings` map keys are provider categories, not document sub-types.** Keys must be one of `bank | utilityProvider | governmentOrganization | mobileOperator | other`. Sending a sub-type name (e.g. `"utilityBill"` or `"bankStatement"`) as a map key returns 400. Sub-types go inside `allowedSubTypes` of the matching provider entry.
scripts/build_poa_preset.py
#!/usr/bin/env python3
"""
Expand a compact POA preset spec (JSON or YAML on stdin) into a full Sumsub
`PoaStepSettings` payload (JSON on stdout).
YAML support requires PyYAML (`pip install pyyaml`). If PyYAML is not
installed, only JSON input is accepted.
See SKILL.md for the spec format. The builder:
- maps `providers.<kind>` to `allowedTypesSettings[<kind>]` (PoaCompanyContactType)
- maps `poiAsPoa` to `poiAsPoaSettings`
- maps `crossValidator` to `crossValidatorSettings`
- propagates `validMonths` defaults into providers that don't set their own
- validates all enum values upfront
- applies the same expansion to each entry under `byCountry`
"""
import json
import re
import sys
from typing import Any
try:
import yaml # type: ignore
_HAS_YAML = True
except ImportError:
_HAS_YAML = False
def _load_spec(stream):
"""Parse stdin as YAML (which is a JSON superset) if PyYAML is available;
otherwise fall back to JSON. On parse failure, surface a helpful error."""
data = stream.read()
if _HAS_YAML:
try:
return yaml.safe_load(data)
except yaml.YAMLError as e:
print(f"error: failed to parse spec as YAML/JSON: {e}", file=sys.stderr)
sys.exit(2)
try:
return json.loads(data)
except json.JSONDecodeError as e:
print(f"error: failed to parse spec as JSON: {e}", file=sys.stderr)
print("hint: install PyYAML (`pip install pyyaml`) to accept YAML input.", file=sys.stderr)
sys.exit(2)
PROVIDER_TYPES = {"bank", "utilityProvider", "governmentOrganization", "mobileOperator", "other"}
POA_SUB_TYPES = {
"statement", "voterRegistration", "taxBill", "telecom", "utilityBill",
"bankStatement", "bankLetter", "lease", "universityLetter",
"employmentLetter", "other",
}
ADDRESS_TYPES = {"dwelling", "poBox", "poBoxSpecialCountries"}
POI_DOC_TYPES = {
"PASSPORT", "ID_CARD", "RESIDENCE_PERMIT", "DRIVERS",
"VISA", "OTHER",
}
NAME_MODES = {"strict", "weakContainment", "def", "ai", "fuzzy", "containment", "fuzzyContainment"}
ADDRESS_MODES = {"strict", "fuzzy"}
ISO3_RE = re.compile(r"^[A-Z]{3}$")
def _ensure_enum(value, allowed, label):
if value is None:
return None
if value not in allowed:
raise ValueError(f"{label}: {value!r} not in {sorted(allowed)}")
return value
def _ensure_iso3_list(values, label):
if values is None:
return None
out = []
for c in values:
if not isinstance(c, str) or not ISO3_RE.fullmatch(c):
raise ValueError(f"{label}: expected ISO-3166-1 alpha-3 uppercase, got {c!r}")
out.append(c)
return out
def _ensure_list_of(allowed, values, label):
if values is None:
return None
bad = [v for v in values if v not in allowed]
if bad:
raise ValueError(f"{label}: invalid values {bad!r}; allowed: {sorted(allowed)}")
return list(values)
def _build_provider(kind, spec, default_valid_months):
"""Compact provider settings -> PoaTypeSettings."""
if not isinstance(spec, dict):
raise ValueError(f"providers.{kind}: must be an object; got {type(spec).__name__}")
out = {}
valid_months = spec.get("validMonths", default_valid_months)
if valid_months is not None:
out["validMonths"] = float(valid_months)
if "acceptUnconventional" in spec:
out["acceptUnconventionalProviders"] = bool(spec["acceptUnconventional"])
elif "acceptUnconventionalProviders" in spec:
out["acceptUnconventionalProviders"] = bool(spec["acceptUnconventionalProviders"])
if "subTypes" in spec:
out["allowedSubTypes"] = _ensure_list_of(
POA_SUB_TYPES, spec["subTypes"], f"providers.{kind}.subTypes"
)
for k in ("forbiddenDocumentNames", "forbiddenOrgNames", "forbiddenOrgWebsites"):
if spec.get(k) is not None:
out[k] = list(spec[k])
allowed_orgs = spec.get("allowedOrgs") or {}
if allowed_orgs:
pu = {}
if allowed_orgs.get("names"):
pu["allowedOrgNames"] = list(allowed_orgs["names"])
if allowed_orgs.get("websites"):
pu["allowedOrgWebsites"] = list(allowed_orgs["websites"])
if pu:
out["poaUnconventionalProviderSettings"] = pu
# pass-through escape hatch for any other key
handled = {
"validMonths", "acceptUnconventional", "acceptUnconventionalProviders",
"subTypes", "forbiddenDocumentNames", "forbiddenOrgNames",
"forbiddenOrgWebsites", "allowedOrgs",
}
for k, v in spec.items():
if k in handled or v is None:
continue
out[k] = v
return out
def _build_poi_as_poa(spec):
if spec is None:
return None
if not isinstance(spec, dict):
raise ValueError(f"poiAsPoa: must be an object; got {type(spec).__name__}")
out = {}
if "enabled" in spec:
out["acceptPoiAsPoa"] = bool(spec["enabled"])
elif "acceptPoiAsPoa" in spec:
out["acceptPoiAsPoa"] = bool(spec["acceptPoiAsPoa"])
if "sameDoc" in spec:
out["acceptSamePoiAsPoa"] = bool(spec["sameDoc"])
elif "acceptSamePoiAsPoa" in spec:
out["acceptSamePoiAsPoa"] = bool(spec["acceptSamePoiAsPoa"])
if "validMonths" in spec and spec["validMonths"] is not None:
out["validMonths"] = float(spec["validMonths"])
if "allowedTypes" in spec and spec["allowedTypes"] is not None:
out["allowedTypes"] = _ensure_list_of(POI_DOC_TYPES, spec["allowedTypes"], "poiAsPoa.allowedTypes")
return out or None
def _build_cross_validator(spec):
if spec is None:
return None
if not isinstance(spec, dict):
raise ValueError(f"crossValidator: must be an object; got {type(spec).__name__}")
out = {}
if "nameMode" in spec:
out["nameComparisonMode"] = _ensure_enum(spec["nameMode"], NAME_MODES, "crossValidator.nameMode")
elif "nameComparisonMode" in spec:
out["nameComparisonMode"] = _ensure_enum(spec["nameComparisonMode"], NAME_MODES, "crossValidator.nameComparisonMode")
if "addressMode" in spec:
out["addressComparisonMode"] = _ensure_enum(spec["addressMode"], ADDRESS_MODES, "crossValidator.addressMode")
elif "addressComparisonMode" in spec:
out["addressComparisonMode"] = _ensure_enum(spec["addressComparisonMode"], ADDRESS_MODES, "crossValidator.addressComparisonMode")
if "fuzzyThreshold" in spec and spec["fuzzyThreshold"] is not None:
ft = float(spec["fuzzyThreshold"])
if not (0.0 <= ft <= 1.0):
raise ValueError(f"crossValidator.fuzzyThreshold: must be in [0,1]; got {ft}")
out["fuzzyThreshold"] = ft
if "ignoreMiddleName" in spec:
out["ignoreMiddleNameMismatch"] = bool(spec["ignoreMiddleName"])
elif "ignoreMiddleNameMismatch" in spec:
out["ignoreMiddleNameMismatch"] = bool(spec["ignoreMiddleNameMismatch"])
if "ignoreFixedInfo" in spec:
out["ignoreFixedInfo"] = bool(spec["ignoreFixedInfo"])
return out or None
def _build_doc_settings(spec, *, label="settings"):
"""Compact PoaDocumentSettings -> full PoaDocumentSettings."""
if spec is None:
return None
if not isinstance(spec, dict):
raise ValueError(f"{label}: must be an object; got {type(spec).__name__}")
out = {}
default_vm = spec.get("validMonths")
if default_vm is not None:
# not a real PoaDocumentSettings field — drop after using as default
default_vm = float(default_vm)
if "acceptDocScreenshot" in spec:
out["acceptDocScreenshot"] = bool(spec["acceptDocScreenshot"])
if "acceptMultiplePages" in spec:
out["acceptMultiplePages"] = bool(spec["acceptMultiplePages"])
if "acceptableLanguages" in spec and spec["acceptableLanguages"] is not None:
out["acceptableLanguages"] = list(spec["acceptableLanguages"])
addr_types = spec.get("addressTypes")
if addr_types is None:
addr_types = spec.get("allowedAddressTypes")
if addr_types is not None:
out["allowedAddressTypes"] = _ensure_list_of(ADDRESS_TYPES, addr_types, f"{label}.addressTypes")
providers_in = spec.get("providers") or spec.get("allowedTypesSettings") or {}
if providers_in:
bad = [k for k in providers_in if k not in PROVIDER_TYPES]
if bad:
raise ValueError(f"{label}.providers: unknown provider keys {bad!r}; allowed: {sorted(PROVIDER_TYPES)}")
out["allowedTypesSettings"] = {
k: _build_provider(k, providers_in[k], default_vm) for k in providers_in
}
cv = _build_cross_validator(spec.get("crossValidator") or spec.get("crossValidatorSettings"))
if cv is not None:
out["crossValidatorSettings"] = cv
pp = _build_poi_as_poa(spec.get("poiAsPoa") or spec.get("poiAsPoaSettings"))
if pp is not None:
out["poiAsPoaSettings"] = pp
if "requireCountryMatch" in spec:
out["requirePoiPoaCountryMatch"] = bool(spec["requireCountryMatch"])
elif "requirePoiPoaCountryMatch" in spec:
out["requirePoiPoaCountryMatch"] = bool(spec["requirePoiPoaCountryMatch"])
if "useIssueDateForExpiry" in spec:
out["useOnlyIssueDateForExpiredCheck"] = bool(spec["useIssueDateForExpiry"])
elif "useOnlyIssueDateForExpiredCheck" in spec:
out["useOnlyIssueDateForExpiredCheck"] = bool(spec["useOnlyIssueDateForExpiredCheck"])
# pass-through escape hatch
handled = {
"validMonths", "acceptDocScreenshot", "acceptMultiplePages",
"acceptableLanguages", "addressTypes", "allowedAddressTypes",
"providers", "allowedTypesSettings", "crossValidator", "crossValidatorSettings",
"poiAsPoa", "poiAsPoaSettings", "requireCountryMatch",
"requirePoiPoaCountryMatch", "useIssueDateForExpiry",
"useOnlyIssueDateForExpiredCheck",
}
for k, v in spec.items():
if k in handled or v is None:
continue
out[k] = v
return out
def build_poa_preset(spec):
if not isinstance(spec, dict):
raise ValueError(f"preset spec must be an object; got {type(spec).__name__}")
name = (spec.get("name") or "").strip()
if not name:
raise ValueError("preset spec must have a non-empty 'name'")
inc = _ensure_iso3_list(spec.get("includedCountries"), "includedCountries")
exc = _ensure_iso3_list(spec.get("excludedCountries"), "excludedCountries")
if inc and exc:
raise ValueError("set only ONE of includedCountries / excludedCountries, not both")
payload = {"name": name}
if spec.get("desc"):
payload["desc"] = spec["desc"]
if inc:
payload["includedCountries"] = inc
if exc:
payload["excludedCountries"] = exc
settings = _build_doc_settings(spec.get("settings"), label="settings")
if settings is not None:
payload["settings"] = settings
by_country = spec.get("byCountry") or {}
if by_country:
if not isinstance(by_country, dict):
raise ValueError(f"byCountry: must be a map; got {type(by_country).__name__}")
# validate ISO-3 keys
bad = [k for k in by_country if not ISO3_RE.fullmatch(str(k))]
if bad:
raise ValueError(f"byCountry: invalid country keys {bad!r}; expected ISO-3 uppercase")
payload["changedSettingsByCountry"] = {
k: _build_doc_settings(v, label=f"byCountry.{k}") for k, v in by_country.items()
}
# pass-through escape hatch for top-level keys
handled = {
"name", "desc", "includedCountries", "excludedCountries",
"settings", "byCountry", "changedSettingsByCountry",
}
for k, v in spec.items():
if k in handled or v is None:
continue
payload[k] = v
return payload
def main():
spec = _load_spec(sys.stdin)
payload = build_poa_preset(spec)
json.dump(payload, sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
scripts/get_poa_preset.sh
#!/usr/bin/env bash
# GET a POA preset by id 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_poa_preset.sh <presetId>
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
# Override SUMSUB_BASE only for testing; default is https://api.sumsub.com.
#
# 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 <presetId>" >&2
exit 2
fi
ID="$1"
METHOD="GET"
PATH_Q="/resources/api/agent/poaStepSettings/${ID}"
TS="$(date -u +%s)"
SIG="$(
printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}" \
| 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 "Accept: application/json" \
-w '\nHTTP %{http_code}\n' \
"${BASE%/}${PATH_Q}"
scripts/patch_poa_preset.sh
#!/usr/bin/env bash
# PATCH an existing POA preset by id via the Sumsub API.
#
# The payload must include the preset `id` (the server identifies the row
# by `id`, then applies the rest of the body as the updated state).
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# ./patch_poa_preset.sh /path/to/preset-with-id.json
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
# Override SUMSUB_BASE only for testing; default is https://api.sumsub.com.
#
# 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
# Payload comes from $1 (file path), stdin if no arg, or "-" for explicit stdin.
PAYLOAD="${1:--}"
if [[ "${PAYLOAD}" == "-" ]]; then
PAYLOAD="$(mktemp)"
trap 'rm -f "${PAYLOAD}"' EXIT
cat > "${PAYLOAD}"
elif [[ ! -f "${PAYLOAD}" ]]; then
echo "payload file not found: ${PAYLOAD}" >&2
exit 2
fi
if [[ ! -s "${PAYLOAD}" ]]; then
echo "error: payload is empty (no file content, or stdin closed without data)" >&2
exit 2
fi
if ! python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("id") else 1)' "${PAYLOAD}"; then
echo "error: payload is missing required \"id\" field for PATCH" >&2
exit 2
fi
METHOD="PATCH"
PATH_Q="/resources/api/agent/poaStepSettings"
TS="$(date -u +%s)"
SIG="$(
{ printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}"; cat "${PAYLOAD}"; } \
| 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 "Accept: application/json" \
-H "Content-Type: application/json" \
--data-binary "@${PAYLOAD}" \
-w '\nHTTP %{http_code}\n' \
"${BASE%/}${PATH_Q}"
scripts/post_poa_preset.sh
#!/usr/bin/env bash
# POST a built POA preset payload to 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=... \
# ./post_poa_preset.sh /path/to/preset.json
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
# Override SUMSUB_BASE only for testing; default is https://api.sumsub.com.
#
# 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
# Payload comes from $1 (file path), stdin if no arg, or "-" for explicit stdin.
PAYLOAD="${1:--}"
if [[ "${PAYLOAD}" == "-" ]]; then
PAYLOAD="$(mktemp)"
trap 'rm -f "${PAYLOAD}"' EXIT
cat > "${PAYLOAD}"
elif [[ ! -f "${PAYLOAD}" ]]; then
echo "payload file not found: ${PAYLOAD}" >&2
exit 2
fi
if [[ ! -s "${PAYLOAD}" ]]; then
echo "error: payload is empty (no file content, or stdin closed without data)" >&2
exit 2
fi
METHOD="POST"
PATH_Q="/resources/api/agent/poaStepSettings"
TS="$(date -u +%s)"
SIG="$(
{ printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}"; cat "${PAYLOAD}"; } \
| 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: sumsub-skills" \
-H "X-Agent-Source-Ver: 1.4.1" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data-binary "@${PAYLOAD}" \
-w '\nHTTP %{http_code}\n' \
"${BASE%/}${PATH_Q}"
SKILL.md
---
name: sumsub-create-poa-preset
description: Create or update a Sumsub Proof-of-Address (POA) preset. POST `/resources/api/agent/poaStepSettings` to create new, PATCH same path to update (id in body), GET `/resources/api/agent/poaStepSettings/{id}` to read one back. TRIGGER when the user asks to "create / add / build / configure / update / edit a POA preset" or "PoA step settings", configure which proof-of-address document types are accepted (utility bills, bank statements, tax bills, etc.), set validity periods per provider type, enable POI-as-POA (accept identity doc as proof of address), tune the cross-validator (name/address fuzzy match between POI and POA), or add per-country POA overrides. SKIP for attaching a preset to a level (set `poaStepSettingsId` on the level instead, via `sumsub-create-level`), or for non-POA presets (cross-check presets, permission presets, etc.).
allowed-tools: Read, Write, Bash
---
# Sumsub — Create POA Preset
Builds a POA preset JSON payload from a compact spec, POSTs it to the Sumsub API, and reports the resulting preset `id` so it can be attached to one or more levels via `level.requiredIdDocs.docSets[].poaStepSettingsId`.
> **Prerequisite — level must have a PROOF_OF_RESIDENCE step.** A POA preset has no effect unless it is attached to a `PROOF_OF_RESIDENCE` (or `PROOF_OF_RESIDENCE2`/`3`/`4`) docset in at least one level. If the user hasn't created (or described) a level that includes a POA step, surface this before building the preset — there is no point creating it in isolation.
## Endpoints
| Method | Path | When |
|---|---|---|
| `POST` | `/resources/api/agent/poaStepSettings` | Create a new preset. Body MUST NOT include `id` — server assigns it. |
| `PATCH` | `/resources/api/agent/poaStepSettings` | Update an existing preset. Body MUST include `id` (the field, not in the URL). |
| `GET` | `/resources/api/agent/poaStepSettings/{id}` | Read one preset back — used to verify what landed and to resolve `name` from a known `id`. |
All three require permission `manageClientSettings`. Body shape is the
[POA preset schema](references/poa-preset-schema.md) — client-settable
fields only (`clientId`, `createdAt`, `createdBy`, `modifiedAt`, audit
trail are server-managed and echoed back on the response).
After creation, attach by editing a level: `requiredIdDocs.docSets[].poaStepSettingsId = "<the new id>"` on any `PROOF_OF_RESIDENCE` doc-set.
## Auth — App Token + secret (sandbox only)
This skill talks to the public Sumsub API and signs each request per
[the authentication reference](https://docs.sumsub.com/reference/authentication).
The full how-it-works writeup lives in the [`sumsub-api-auth`](../sumsub-api-auth/SKILL.md)
skill — read it if you hit `401 Invalid signature`.
> **⚠️ Sandbox tokens only.** Do **not** accept or use a production App Token
> here. If the user offers one, refuse and ask them to generate a sandbox
> pair at <https://cockpit.sumsub.com/checkus/home?sbx=true> (**Connect
> Sumsub to your AI agent** -> **Build & configure** -> **Generate token**).
> Token + secret are shown once — copy both before closing the dialog. The helper script
> enforces this — it rejects tokens that don't start with `sbx:`.
| 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`. |
If the user has already supplied credentials in conversation, reuse them;
otherwise ask once before running. Never echo the secret back.
## Tenant entitlements
POA preset creation has historically been gated behind the `POA` entitlement, but **in practice the API accepts the write for most tenants regardless** — the entitlement is often baseline, covered by other keys, or simply not surfaced in `allowedChecks`. Don't treat its absence as a blocker.
1. Invoke `sumsub-check-permissions` and inspect `allowedChecks` — informational, for diagnostics.
2. **If `POA` is present** — proceed.
3. **If `POA` is missing** — proceed anyway, with a one-line user-visible note that the documented entitlement isn't listed (so they know what to mention to support if the POST eventually fails). Do NOT pause for confirmation — Sumsub will reject the write itself if the tenant truly lacks the right, and the 4xx from that will be more informative than a pre-emptive halt.
4. **If the POST returns a 4xx that mentions an entitlement** — surface the error body verbatim and suggest contacting CSM / Sumsub support.
## Procedure
0. **Fetch tenant entitlements** — see section above.
1. **Translate the user's intent into the compact spec** (below). Most users describe presets in terms of "what we accept" (bank statements, utility bills) and "for how long" (validity in months) — map that into `providers` keys, `subTypes`, and `validMonths`.
2. **Validate**: name non-empty; `includedCountries` and `excludedCountries` aren't both set; every provider key is a `PoaCompanyContactType`; every `subTypes` entry is a `PoaSubType`; every country code is ISO-3166-1 alpha-3 uppercase; `crossValidator.fuzzyThreshold` in `[0, 1]`.
3. **Generate payload** via `${CLAUDE_SKILL_DIR}/scripts/build_poa_preset.py` (compact spec on stdin → full payload on stdout).
4. **Create vs. update**:
- **New preset** — POST via `${CLAUDE_SKILL_DIR}/scripts/post_poa_preset.sh`. The payload must **not** carry `id`.
- **Update existing** — first GET the current state via `${CLAUDE_SKILL_DIR}/scripts/get_poa_preset.sh` so the user sees the diff, then PATCH via `${CLAUDE_SKILL_DIR}/scripts/patch_poa_preset.sh`. The payload must include the preset's `id`.
5. **Build the dashboard link.** Read `id` and `clientId` from the response body and format:
```
https://cockpit.sumsub.com/checkus/sdkIntegrations/globalSettings/userVerification/proofOfAddress/<id>?clientId=<clientId>&sbx=true
```
The `sbx=true` query param targets the **Sandbox** workspace — it is the canonical sandbox link param shared across all skills.
6. **Report** — lead with the human-readable name; surface the id only at the end as the value to pass into the next API call:
- **Name** and country scope (incl/excl) + per-country override countries.
- A brief summary of what's accepted (provider types covered, default validity).
- **Dashboard link** as a clickable markdown link.
- Final line: `Preset ID for level wiring: <id>`.
## Compact spec format (JSON or YAML on stdin)
```yaml
name: "Standard POA (6 months)"
desc: "Default Proof of Address rules with 6-month validity"
# Country scope — choose AT MOST ONE
# includedCountries: [GBR, DEU, FRA] # allow-list
excludedCountries: [PRK, IRN] # block-list
# Global defaults
settings:
acceptMultiplePages: true
acceptDocScreenshot: true
requireCountryMatch: false # require POI country == POA country
useIssueDateForExpiry: false # use only issueDate (ignore expiry) for "fresh enough" check
validMonths: 6 # shortcut: applied to every provider that omits its own validMonths
addressTypes: [dwelling, poBox] # allowed PoA address types
acceptableLanguages: [en, de, fr]
# Document providers and the doc sub-types each may produce
# Keys (PoaCompanyContactType): bank | utilityProvider | governmentOrganization | mobileOperator | other
providers:
bank:
validMonths: 6
acceptUnconventional: true # accept non-mainstream banks (with allowedOrgNames below)
subTypes: [bankStatement, bankLetter, other]
allowedOrgs: # only used when acceptUnconventional=true
names: ["Some Local Co-op Bank"]
websites: ["coopbank.example"]
forbiddenDocumentNames: []
forbiddenOrgNames: []
forbiddenOrgWebsites: []
utilityProvider:
subTypes: [telecom, utilityBill, other]
governmentOrganization:
subTypes: [statement, voterRegistration, taxBill, other]
mobileOperator: {} # accept defaults
other:
subTypes: [lease, other, universityLetter]
# Accept identity document as proof of address
poiAsPoa:
enabled: true # acceptPoiAsPoa
sameDoc: true # acceptSamePoiAsPoa (same doc for POI + POA)
validMonths: 3
allowedTypes: [PASSPORT, ID_CARD, RESIDENCE_PERMIT, DRIVERS]
# Name/address comparison between POI and POA
crossValidator:
nameMode: weakContainment # strict | weakContainment | def | ai | fuzzy | containment | fuzzyContainment
addressMode: fuzzy # strict | fuzzy
fuzzyThreshold: 0.75
ignoreMiddleName: false
ignoreFixedInfo: false
# Per-country overrides — same shape as `settings`. Only the keys you set are overridden;
# others fall through to the global `settings`.
byCountry:
BRA:
validMonths: 3 # tighter validity for Brazil
providers:
bank:
validMonths: 3
```
### Provider-type values (`providers.<key>`)
`bank`, `utilityProvider`, `governmentOrganization`, `mobileOperator`, `other`.
### Sub-types (`providers.<key>.subTypes[]`)
`statement`, `voterRegistration`, `taxBill`, `telecom`, `utilityBill`, `bankStatement`, `bankLetter`, `lease`, `universityLetter`, `employmentLetter`, `other`.
### Address types (`addressTypes[]`)
`dwelling`, `poBox`, `poBoxSpecialCountries`.
### POI-as-POA `allowedTypes[]`
Standard `IdDocType` values: `PASSPORT`, `ID_CARD`, `RESIDENCE_PERMIT`, `DRIVERS` (others rare).
### Name comparison modes (`crossValidator.nameMode`)
`strict`, `weakContainment`, `def`, `ai`, `fuzzy`, `containment`, `fuzzyContainment`.
### Address comparison modes (`crossValidator.addressMode`)
`strict`, `fuzzy`.
## Outputs
On success, lead with the human-readable info:
- `name`, country scope, list of per-country override countries.
- A brief summary (provider types covered, default validity).
- **Dashboard link**: `https://cockpit.sumsub.com/checkus/sdkIntegrations/globalSettings/userVerification/proofOfAddress/<id>?clientId=<clientId>&sbx=true`. Render as a clickable markdown link. Both `id` and `clientId` come from the POST response body; `sbx=true` targets the Sandbox workspace.
- Finally, on its own line: `Preset ID (for level wiring / future PATCH): <id>`.
On failure: HTTP status + Sumsub's `description`/`errorName`. The builder rejects invalid enums / impossible combinations upfront with precise messages.
### Names, not ids, in user-facing messages
This applies to **every** message about the preset — pre-POST summary, mid-flow status updates, hand-off lines — not only the final report:
- Refer to the preset by `name` ("POA — 60 days"), not by its `id`, in prose.
- The `id` belongs only on the final dedicated line (`Preset ID for level wiring: <id>`) — that line is the one place a raw id is correct, because the user needs to copy it into a level's `poaPresetId`.
- When the caller is the level skill chaining this preset into a `PROOF_OF_RESIDENCE` step, the level skill should ALSO refer to this preset by name in its pre-POST summary — see [`sumsub-create-level`](../sumsub-create-level/SKILL.md#names-not-ids-in-user-facing-messages).
### Hand-off to `sumsub-create-level`
The returned `id` is what you pass to a level's `PROOF_OF_RESIDENCE` doc-set. The level skill exposes it as a friendly `poaPresetId` shortcut (or the canonical `poaStepSettingsId`):
```json
{
"type": "PROOF_OF_RESIDENCE",
"docTypes": ["UTILITY_BILL", "BANK_STATEMENT"],
"poaPresetId": "<id from this skill>"
}
```
See [`sumsub-create-level/examples/with-presets.json`](../sumsub-create-level/examples/with-presets.json).
## Worked examples
- [`examples/minimal.json`](examples/minimal.json) — bare-minimum preset: 6-month bank/utility/gov defaults, POI-as-POA off.
- [`examples/eu-bank-friendly.json`](examples/eu-bank-friendly.json) — EU-only preset, generous bank-statement validity, POI-as-POA allowed for 3 months.
- [`examples/per-country-tight.json`](examples/per-country-tight.json) — global defaults plus a tighter Brazil override.
## See also
- [references/poa-preset-schema.md](references/poa-preset-schema.md) — full `PoaStepSettings` schema, every enum, and gotchas.