
Sumsub Manage Webhooks
- 382 installs
- 4 repo stars
- Updated July 3, 2026
- sumsubstance/agent-skills
Helps with ai & agent building tasks.
About
sumsub-manage-webhooks is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- sumsub-manage-webhooks
- AI & Agent Building
- AI-coding skill
Sumsub Manage Webhooks by the numbers
- 382 all-time installs (skills.sh)
- +52 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,028 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sumsubstance/agent-skills --skill sumsub-manage-webhooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 382 |
|---|---|
| repo stars | ★ 4 |
| Last updated | July 3, 2026 |
| Repository | sumsubstance/agent-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Sumsub — Manage Client Webhooks
Lists, retrieves, creates, updates, and disables/enables ClientWebhook event subscriptions via /resources/clientWebhooks.
Endpoints
The public API resource (ClientWebhookApiResource) exposes:
| Verb | Path | Purpose |
|---|---|---|
GET | /resources/clientWebhooks | List webhooks on the tenant. Returns EntityResult<ClientWebhook> ({list: {items: [...] }}). Capped at the oldest 50 server-side (getOldest50). |
GET | /resources/clientWebhooks/{id} | Read one webhook by id. Use this to resolve a name from a known id, or to verify what landed after a write. |
POST | /resources/clientWebhooks | Create. Body must NOT include id — server assigns it. (The model layer still does an internal upsert, but the request DTO is ClientWebhookCreateRequest without id.) |
PATCH | /resources/clientWebhooks | Update an existing webhook (by id in body). DTO is ClientWebhookUpdateRequest. |
Permission required: manageClientSettings.
There is no DELETE and no `/stats` endpoint on the public API — use the Sumsub dashboard UI when you need to delete a webhook or view per-webhook delivery stats.
Auth — App Token + secret (sandbox only)
This skill talks to the public Sumsub API and signs each request per the authentication reference. The full how-it-works writeup lives in the `sumsub-api-auth` 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/devSpace/appTokens> (toggle
the workspace to Sandbox first, then Create). 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: unlessSUMSUB_ALLOW_PROD=1 is set.| 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.
Sandbox-only scope — production webhooks must be created by a human
Because this skill only accepts sandbox App Tokens, every webhook it creates, updates, or toggles lives in the sandbox workspace. Sandbox and production are separate tenants on Sumsub's side — there is no "promote to prod" path, and re-running this skill with a production token is not the right way to set up a real webhook.
When the user is ready to wire up a production webhook:
- Do not offer to do it from this skill, even if the user asks.
- Do not ask for or accept a production App Token (the script will refuse
it without SUMSUB_ALLOW_PROD=1, and you should not suggest that override).
- Tell the user that the production webhook — target URL, signing secret,
event subscription, custom headers — should be configured by a human directly in the Sumsub dashboard (Integrations → Webhooks, with the workspace toggle on Production). Setting up a production webhook is a security-sensitive operation (the signing secret authenticates real PII deliveries) and the audit trail should attribute it to a person.
- The right workflow is: use this skill to prototype against sandbox, capture
the final spec the user wants (event list, headers, signature algorithm), then hand that spec off as plain documentation so a human can recreate it in production.
Subcommands
manage_webhooks.sh is the orchestrator:
manage_webhooks.sh list # GET all webhooks (table summary; capped at 50)
manage_webhooks.sh list --json # raw JSON of all webhooks
manage_webhooks.sh get <webhookId> # one webhook (filtered from the list)
manage_webhooks.sh create <spec.json> # POST without id (compact spec → ClientWebhook)
manage_webhooks.sh update <spec.json> # POST with id (spec MUST contain id)
manage_webhooks.sh disable <webhookId> # GET → flip disabled=true → POST
manage_webhooks.sh enable <webhookId> # GET → flip disabled=false → POSTcreate and update both call build_webhook_payload.py to expand the compact spec.
Before submitting: target must be publicly reachable
Sumsub delivers webhooks from its own infrastructure, so the target URL has to resolve and accept connections from the public internet. Common gotcha: users paste http://localhost:3000/webhook (or 127.0.0.1, 0.0.0.0, ::1) while developing locally. Sumsub accepts the URL at creation time but every delivery will fail — and targets like these are rejected by the skill's payload builder up front.
If the user supplies a localhost-ish URL, don't submit it. Instead, walk them through exposing the local server through a public tunnel before creating the webhook:
1. Suggest ngrok (the most common choice). On macOS: brew install ngrok/ngrok/ngrok. Other platforms: download from the link. First-time users need a free ngrok account to grab an auth token, then ngrok config add-authtoken <TOKEN> once. 2. Ask which port their local webhook receiver listens on (typically 3000 / 8080 / 4000). 3. Have them run ngrok http <port> in a separate terminal and keep it open. 4. ngrok prints a Forwarding https://<random>.ngrok-free.app -> http://localhost:<port> line. The https://...ngrok-free.app part is the public URL. 5. Append the receiver's webhook path (e.g. /webhook, /sumsub) and use the full URL as target. Then re-run the create subcommand.
Heads-up to mention: on the free ngrok plan the public URL changes every time ngrok restarts — the webhook will need to be re-updated (POST with the existing id and the new target) each session. A reserved domain (paid) or --domain=<your-subdomain> keeps it stable. Alternatives if the user prefers: Cloudflare Tunnel (cloudflared tunnel), Tailscale Funnel, localtunnel — same idea, same procedure.
Compact spec for create / update
# Identity (omit on create; required on update)
id: 698bfc... # id from a previous list / create response
# Display + addressing
name: "Production webhook" # required (no min length but the dashboard expects something)
description: "Sends KYC events to our backend"
target: "https://example.com/sumsub/webhook" # required — destination URL (or slack / email / telegram address depending on targetType)
targetType: http # http | email | slack | telegram (default: http)
# Subscription
types: # required — event-type strings (see "Event types" below)
- applicantReviewed
- applicantPending
- applicantOnHold
- applicantCreated
applicantType: individual # individual | company (omit to subscribe to both)
sourceKeys: [] # optional — restrict to specific source keys
# Auth + delivery
secretKey: "..." # HMAC secret used to sign payloads
signatureAlgorithm: HMAC_SHA256_HEX # HMAC_SHA1_HEX | HMAC_SHA256_HEX | HMAC_SHA512_HEX (default: SHA256)
headers: # optional extra HTTP headers added to each delivery
- { key: "X-Source", value: "sumsub" }
- { key: "Authorization", value: "Bearer ${MY_TOKEN}" } # caller substitutes before sending
# Lifecycle flags
disabled: false # default false; set true to pause without deleting
notResendFailedWebhooks: false # default false; true = no automatic retries on delivery failureThe builder validates enums (targetType, signatureAlgorithm, applicantType), rejects empty types, and wraps headers so that the key/value shape matches ClientWebhookHeader. Unknown keys pass through (escape hatch).
Event types (types[])
The OpenAPI keeps types as a free-form string[]. The names below cover the commonly-emitted Sumsub events. Unknown event types are silently accepted server-side and the webhook simply never fires — so typos are not caught by the API.
| Group | Event type | When it fires |
|---|---|---|
| Applicant lifecycle | applicantCreated | New applicant created |
applicantPrechecked | Pre-screen complete | |
applicantPending | Submitted for review | |
applicantReviewed | Final review answer (GREEN / RED) reached | |
applicantOnHold | Review held / paused | |
applicantActivated | Applicant activated | |
applicantDeactivated | Applicant deactivated | |
applicantReset | Verification reset (retry) | |
applicantLevelChanged | Level reassigned | |
applicantTagsChanged | Tags added/removed | |
applicantPersonalInfoChanged | Personal info edited | |
applicantDeleted | Applicant deleted | |
applicantPersonalDataDeleted | GDPR personal-data erasure executed | |
| Action workflow | applicantActionPending / applicantActionReviewed / applicantActionOnHold | Action-flow events |
| Workflow | applicantWorkflowCompleted | Workflow run finished (not applicantWorkflowRunCompleted) |
| Video ident | videoIdentStatusChanged | Live status update |
videoIdentCompositionCompleted | Recording assembly finished | |
| KYT (applicant-scoped) | applicantKytTxnApproved / applicantKytTxnRejected / applicantKytTxnReviewed / applicantKytTxnDeleted / applicantKytTxnDataChanged / applicantKytTxnAwaitingUser / applicantKytOnHold | Per-applicant transaction-monitoring events |
| KYT (case-scoped) | kytCaseCreated / kytCaseStatusChanged / kytCaseReviewed | KYT case-management events (note: it's kytCaseStatusChanged, not kytCaseUpdated) |
| AML case | amlCaseApproved / amlCaseRejected / amlCaseOnHold | AML-case disposition events |
| Travel Rule | travelRuleAction | Travel-rule lifecycle events |
| KYB | kybCompanyActivity | KYB ongoing-monitoring events |
The skill forwards whatever the caller writes — no client-side validation, since Sumsub may add events faster than this list updates.
Outputs
- `list` — table with
id,name,target,disabled,types[],applicantType,signatureAlgorithm,createdAt. - `get` — the full single webhook JSON (with
secretKeyredacted in the output as a defensive measure). - `create` / `update` — the persisted
ClientWebhook(with server-assignedidon create) and a one-line summary. - `disable` / `enable` — reports the new
disabledvalue.
Worked examples
- `examples/basic-http.json` — minimal webhook: HTTPS endpoint, four applicant-lifecycle events, SHA-256 signing.
- `examples/with-headers-and-restrictions.json` — broad event set scoped to a specific
sourceKey, with custom HTTP headers. - `examples/legacy-sha1.json` — SHA1-signed webhook, for receivers that already verify SHA1.
- `examples/update-existing.json` — same as basic but with
idset, demonstrating the update path.
See also
- `references/webhook-schema.md` — full
ClientWebhookschema, all enums, gotchas (secret not write-only, signing semantics). - Sumsub docs: Webhook system, Webhook event types, Verify webhook signatures.
{
"name": "Production webhook (example)",
"description": "Receives applicant lifecycle events from Sumsub",
"target": "https://backend.example.com/webhooks/sumsub",
"targetType": "http",
"types": [
"applicantCreated",
"applicantPending",
"applicantReviewed",
"applicantOnHold"
],
"signatureAlgorithm": "HMAC_SHA256_HEX",
"secretKey": "REPLACE_WITH_GENERATED_SECRET",
"disabled": false
}
{
"name": "Legacy SHA1 webhook (example)",
"target": "https://legacy-backend.example.com/sumsub",
"targetType": "http",
"types": [
"applicantCreated",
"applicantPending",
"applicantReviewed"
],
"signatureAlgorithm": "HMAC_SHA1_HEX",
"secretKey": "REPLACE_WITH_GENERATED_SECRET",
"disabled": false
}
{
"id": "REPLACE_WITH_EXISTING_WEBHOOK_ID",
"name": "Updated production webhook (example)",
"target": "https://backend.example.com/webhooks/sumsub/v2",
"targetType": "http",
"types": [
"applicantCreated",
"applicantPending",
"applicantReviewed",
"applicantOnHold",
"applicantTagsChanged"
],
"signatureAlgorithm": "HMAC_SHA256_HEX",
"disabled": false
}
{
"name": "Broad subscription scoped to a source key (example)",
"description": "Receives applicant lifecycle + tag/level changes for the 'kyb-prod-1' source-key stream.",
"target": "https://backend.example.com/sumsub-events",
"targetType": "http",
"types": [
"applicantCreated",
"applicantPending",
"applicantReviewed",
"applicantOnHold",
"applicantActivated",
"applicantDeactivated",
"applicantTagsChanged",
"applicantLevelChanged",
"applicantReset"
],
"sourceKeys": ["kyb-prod-1"],
"signatureAlgorithm": "HMAC_SHA256_HEX",
"secretKey": "REPLACE_WITH_GENERATED_SECRET",
"headers": [
{ "key": "X-Source", "value": "sumsub" },
{ "key": "X-Environment", "value": "production" }
],
"notResendFailedWebhooks": false,
"disabled": false
}
ClientWebhook — schema reference
Sources: Sumsub OpenAPI (components.schemas.ClientWebhook, ClientWebhookHeader, ClientWebhookSendingStats, ClientWebhookSignatureAlgorithm, ClientCallbackDefinitionTargetType).
ClientWebhook
| Field | Type | Notes |
|---|---|---|
id | string | Server-assigned on first POST. Include in the body to update; omit to create. |
name | string | Required for the dashboard UI; the API treats it as freeform. |
description | string | Optional. |
target | string | Required. URL for http, channel id for slack, address for email / telegram. |
targetType | enum | http, email, slack, telegram. Default in practice is http. |
types | string[] | Required, non-empty. Event-type names — see below. |
applicantType | enum | individual or company. Omit to receive both. |
sourceKeys | string[] | Optional restriction to specific source-key streams. Leave empty for "all". |
secretKey | string | HMAC secret used to sign payload bodies. Note: the endpoint returns this value in plaintext on GET for any caller with manageClientSettings permission — it is not masked or write-only. The skill's get subcommand redacts it client-side as a defensive measure, but the raw API response carries the full secret. Treat any token that can read this endpoint as having direct access to all webhook secrets. |
signatureAlgorithm | enum | HMAC_SHA1_HEX, HMAC_SHA256_HEX, HMAC_SHA512_HEX. SHA1 is the legacy default; newer integrations should use SHA256. SHA512 is supported but rarely used. The builder defaults to HMAC_SHA256_HEX. |
headers | ClientWebhookHeader[] | Optional extra HTTP headers sent on every delivery. Each entry is {key, value}. |
disabled | boolean | If true, Sumsub stops delivering to this webhook. Useful for pausing without losing config. |
notResendFailedWebhooks | boolean | If true, no retries on delivery failure (fire-and-forget). Default false (Sumsub retries with backoff). |
clientId | string | Server-populated (the tenant id). Do not send. |
createdAt / createdBy | string | Server-populated audit trail. |
ClientCallbackDefinitionTargetType (enum — targetType values)
telegram, slack, email, http.
ClientWebhookSignatureAlgorithm (enum — signatureAlgorithm values)
HMAC_SHA1_HEX, HMAC_SHA256_HEX, HMAC_SHA512_HEX. The signature is sent as x-payload-digest (alg-dependent). Verify on the receiver by computing the HMAC of the raw body with your secretKey.
ClientWebhookHeader
{key: string, value: string}. Sumsub adds these verbatim to each outbound POST. The receiver sees them mixed with Sumsub's own headers; pick unambiguous names.
Endpoints
The public API resource (ClientWebhookApiResource, App Token auth, manageClientSettings) exposes:
| Verb | Path | Notes |
|---|---|---|
GET | /resources/clientWebhooks | Returns EntityResult<ClientWebhook> — {list: {items: [ClientWebhook]}}. Capped at the oldest 50 server-side (getOldest50). |
GET | /resources/clientWebhooks/{id} | Returns one ClientWebhook by id. Use for confirmation after a write or to resolve name from a known id. |
POST | /resources/clientWebhooks | Create. Body MUST NOT include id (DTO: ClientWebhookCreateRequest). Server assigns it. |
PATCH | /resources/clientWebhooks | Update an existing webhook (by id in body, DTO: ClientWebhookUpdateRequest). |
No public-API DELETE and no /stats endpoint — use the Sumsub dashboard UI for those operations. The skill's disable / enable subcommands work via a PATCH that flips disabled.
Common event-type strings
The OpenAPI keeps types[] as a freeform string[]. The names below cover the events Sumsub commonly emits — but the server doesn't validate them, so a typo silently fails open (the webhook simply never fires).
# Applicant lifecycle
applicantCreated, applicantPrechecked, applicantPending, applicantReviewed,
applicantOnHold, applicantActivated, applicantDeactivated, applicantReset,
applicantLevelChanged, applicantTagsChanged, applicantPersonalInfoChanged,
applicantDeleted, applicantPersonalDataDeleted
# Action workflow
applicantActionPending, applicantActionReviewed, applicantActionOnHold
# Workflow
applicantWorkflowCompleted
# Video ident
videoIdentStatusChanged, videoIdentCompositionCompleted
# KYT — applicant-scoped
applicantKytTxnApproved, applicantKytTxnRejected, applicantKytTxnReviewed,
applicantKytTxnDeleted, applicantKytTxnDataChanged, applicantKytTxnAwaitingUser,
applicantKytOnHold
# KYT — case-scoped
kytCaseCreated, kytCaseStatusChanged, kytCaseReviewed
# AML case
amlCaseApproved, amlCaseRejected, amlCaseOnHold
# Travel Rule / KYB
travelRuleAction, kybCompanyActivityTwo names from older docs that the live dashboard does not emit (don't use these):
- ~~
applicantWorkflowRunCompleted~~ → useapplicantWorkflowCompleted - ~~
kytCaseUpdated~~ → usekytCaseStatusChanged
Unknown event types are silently dropped by Sumsub — they fail open (the webhook just never fires). The skill doesn't validate them either (we don't want to lag the platform's actual support).
Gotchas
- `POST` is upsert. Same endpoint creates and updates — dispatch is by presence of
idin the body. Forgetting to includeidon update creates a new duplicate webhook. - `secretKey` is NOT write-only. The endpoint returns secrets in plaintext on GET to anyone with
manageClientSettingspermission. Plan accordingly: rotate secrets on operator off-boarding, and don't paste GET output into screenshots or logs. To keep the existing secret on update, you can either omit the field (the server preserves the prior value if you don't set it) or echo the value back from a previous GET. - Headers `{key, value}` not `{name, value}`. The header collection uses
key, notname. Easy to swap by reflex. - `disabled: true` doesn't delete deliveries already queued. Anything Sumsub already accepted will still attempt delivery for a short window after disable.
- `notResendFailedWebhooks: true` is rarely the right setting. Disabling retries usually causes silent data loss when your endpoint has a 30-second hiccup. Only use if your receiver does its own retry.
- Webhook idempotency is the receiver's responsibility. Sumsub may deliver the same event more than once (retries on ambiguous failures); de-dupe by event id on your side.
- No bulk endpoints. To replace N webhooks, POST each individually. The skill's
manage_webhooks.shdoesn't do batch operations.
#!/usr/bin/env python3
"""
Expand a compact webhook spec (JSON on stdin) into a full Sumsub `ClientWebhook`
payload (JSON on stdout) suitable for POST /resources/clientWebhooks.
The same payload covers both create (no `id`) and update (with `id`); the
endpoint dispatches by presence/absence of `id`.
"""
import json
import re
import sys
from urllib.parse import urlparse
TARGET_TYPES = {"http", "email", "slack", "telegram"}
SIGNATURE_ALGOS = {"HMAC_SHA1_HEX", "HMAC_SHA256_HEX", "HMAC_SHA512_HEX"}
APPLICANT_TYPES = {"individual", "company"}
# Hostnames Sumsub's infrastructure cannot reach. Webhook delivery would
# silently fail forever, so reject up front and point the caller at a tunnel.
_LOCAL_HOSTNAMES = {"localhost", "0.0.0.0", "::1", "ip6-localhost", "ip6-loopback"}
_LOOPBACK_V4 = re.compile(r"^127\.")
def _is_local_target(url: str) -> bool:
try:
host = (urlparse(url).hostname or "").lower()
except ValueError:
return False
if not host:
return False
host = host.strip("[]")
return host in _LOCAL_HOSTNAMES or bool(_LOOPBACK_V4.match(host))
def _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 build(spec):
if not isinstance(spec, dict):
raise ValueError(f"spec must be an object; got {type(spec).__name__}")
name = (spec.get("name") or "").strip()
if not name:
raise ValueError("name is required (non-empty string)")
target = (spec.get("target") or "").strip()
if not target:
raise ValueError("target is required (URL for http; address for other targetTypes)")
target_type = spec.get("targetType", "http")
if target_type == "http" and _is_local_target(target):
raise ValueError(
f"target {target!r} points at localhost / loopback — Sumsub cannot reach it from "
"its own infrastructure, so every delivery will silently fail. Expose the local "
"receiver through a public tunnel (e.g. `ngrok http <port>`, Cloudflare Tunnel, "
"Tailscale Funnel) and re-run with the public https:// URL as `target`."
)
types = spec.get("types")
if not isinstance(types, list) or not types:
raise ValueError("types must be a non-empty list of event-type strings")
bad = [t for t in types if not isinstance(t, str) or not t.strip()]
if bad:
raise ValueError(f"types contains non-string or empty entries: {bad!r}")
out = {
"name": name,
"target": target,
"targetType": _enum(spec.get("targetType", "http"), TARGET_TYPES, "targetType"),
"types": [t.strip() for t in types],
}
if spec.get("id"):
out["id"] = spec["id"]
if spec.get("description") is not None:
out["description"] = spec["description"]
if spec.get("applicantType") is not None:
out["applicantType"] = _enum(spec["applicantType"], APPLICANT_TYPES, "applicantType")
if spec.get("sourceKeys") is not None:
if not isinstance(spec["sourceKeys"], list):
raise ValueError("sourceKeys must be a list of strings")
out["sourceKeys"] = list(spec["sourceKeys"])
if spec.get("secretKey") is not None:
if not isinstance(spec["secretKey"], str):
raise ValueError("secretKey must be a string")
out["secretKey"] = spec["secretKey"]
sig = spec.get("signatureAlgorithm", "HMAC_SHA256_HEX")
out["signatureAlgorithm"] = _enum(sig, SIGNATURE_ALGOS, "signatureAlgorithm")
headers = spec.get("headers")
if headers is not None:
if not isinstance(headers, list):
raise ValueError("headers must be a list of {key, value} objects")
cleaned = []
for h in headers:
if not isinstance(h, dict) or "key" not in h or "value" not in h:
raise ValueError(f"each header must be {{key, value}}; got {h!r}")
cleaned.append({"key": str(h["key"]), "value": str(h["value"])})
out["headers"] = cleaned
for flag in ("disabled", "notResendFailedWebhooks"):
if flag in spec and spec[flag] is not None:
out[flag] = bool(spec[flag])
# Pass-through escape hatch for any other key (e.g. an obscure field added by Sumsub later).
handled = {
"id", "name", "target", "targetType", "types", "description",
"applicantType", "sourceKeys", "secretKey", "signatureAlgorithm",
"headers", "disabled", "notResendFailedWebhooks",
}
for k, v in spec.items():
if k in handled or v is None:
continue
out[k] = v
return out
def main():
spec = json.load(sys.stdin)
json.dump(build(spec), sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
#!/usr/bin/env bash
# Manage Sumsub clientWebhooks: list / get / create / update / disable / enable.
#
# Talks to the public API resource (ClientWebhookApiResource), which exposes
# only GET (list, oldest 50) and POST (upsert) under /resources/clientWebhooks.
# Delete and per-webhook delivery stats have no public-API equivalent and
# must be handled in the Sumsub dashboard UI; they are not implemented here.
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# manage_webhooks.sh list [--json]
# manage_webhooks.sh get <id>
# manage_webhooks.sh create <spec.json>
# manage_webhooks.sh update <spec.json> # spec must include id
# manage_webhooks.sh disable <id>
# manage_webhooks.sh enable <id>
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
# Override SUMSUB_BASE only for testing; default is https://api.sumsub.com.
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
HERE="$(cd "$(dirname "$0")" && pwd)"
ENDPOINT_PATH="/resources/clientWebhooks"
if [[ $# -lt 1 ]]; then
sed -n '4,24p' "$0" >&2; exit 2
fi
CMD="$1"; shift || true
# sumsub_request METHOD PATH_QUERY [BODY_FILE]
# Signs and sends; echoes response body to stdout. PATH_QUERY must start with '/'.
sumsub_request() {
local method="$1" path_q="$2" body_file="${3-}"
local ts sig
ts="$(date -u +%s)"
if [[ -n "${body_file}" ]]; then
sig="$(
{ printf '%s%s%s' "${ts}" "${method}" "${path_q}"; cat "${body_file}"; } \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
else
sig="$(
printf '%s%s%s' "${ts}" "${method}" "${path_q}" \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
fi
local args=(
-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.0.1"
-H "Accept: application/json"
)
if [[ -n "${body_file}" ]]; then
args+=(-H "Content-Type: application/json" --data-binary "@${body_file}")
fi
curl "${args[@]}" "${BASE%/}${path_q}"
}
fmt_list() {
local body
body=$(cat)
BODY="$body" python3 - <<'PY'
import json, os, sys
raw = os.environ["BODY"]
try:
d = json.loads(raw)
except json.JSONDecodeError as e:
print(f" (response was not JSON: {e})", file=sys.stderr)
print(f" first 200 chars: {raw[:200]!r}", file=sys.stderr)
sys.exit(1)
items = (d.get("list") or {}).get("items") or (d if isinstance(d, list) else [])
if not items:
print("(no webhooks)")
sys.exit(0)
print(f"{'id':24} {'name':28.28} {'target':40.40} {'disabled':8} {'types':20.20} applicantType")
print("-"*150)
for w in items:
types = ", ".join(w.get("types") or [])
print(f"{w.get('id','?'):24} {(w.get('name') or '?'):28.28} {(w.get('target') or '?'):40.40} "
f"{str(w.get('disabled')):8} {types[:20]:20} {w.get('applicantType') or '-'}")
print(f"\ntotal: {len(items)}")
PY
}
case "$CMD" in
list)
json_only="false"
if [[ "${1:-}" == "--json" ]]; then json_only="true"; fi
body="$(sumsub_request GET "${ENDPOINT_PATH}")"
if [[ "$json_only" == "true" ]]; then
echo "$body"
else
echo "$body" | fmt_list
fi
;;
get)
[[ $# -ge 1 ]] || { echo "usage: get <id>" >&2; exit 2; }
id="$1"
sumsub_request GET "${ENDPOINT_PATH}" \
| python3 -c "
import json, sys
target=sys.argv[1]
d=json.load(sys.stdin)
items=(d.get('list') or {}).get('items') or []
for w in items:
if w.get('id')==target:
if 'secretKey' in w and w['secretKey']:
w['secretKey']='<redacted>'
print(json.dumps(w, indent=2))
sys.exit(0)
print(f'no webhook with id={target!r} (checked {len(items)})', file=sys.stderr)
sys.exit(1)
" "$id"
;;
create|update)
[[ $# -ge 1 ]] || { echo "usage: $CMD <spec.json>" >&2; exit 2; }
spec="$1"
[[ -f "$spec" ]] || { echo "spec file not found: $spec" >&2; exit 2; }
payload="$(mktemp)"
trap 'rm -f "$payload"' EXIT
python3 "${HERE}/build_webhook_payload.py" < "$spec" > "$payload"
has_id="$(python3 -c "import json,sys; print('yes' if json.load(open(sys.argv[1])).get('id') else 'no')" "$payload")"
if [[ "$CMD" == "create" && "$has_id" == "yes" ]]; then
echo "warning: spec for 'create' contains id=... — this will UPDATE that webhook instead" >&2
fi
if [[ "$CMD" == "update" && "$has_id" == "no" ]]; then
echo "error: 'update' requires id in the spec" >&2; exit 2
fi
resp="$(sumsub_request POST "${ENDPOINT_PATH}" "$payload")"
echo "$resp" | python3 -c "
import json, sys
w=json.load(sys.stdin)
if w.get('code'):
print('ERROR:', w); sys.exit(1)
print('persisted webhook:')
print(f\" id: {w.get('id')}\")
print(f\" name: {w.get('name')!r}\")
print(f\" target: {w.get('target')!r}\")
print(f\" targetType: {w.get('targetType')}\")
print(f\" applicantType: {w.get('applicantType')}\")
print(f\" signing: {w.get('signatureAlgorithm')}\")
print(f\" disabled: {w.get('disabled')}\")
print(f\" types: {w.get('types')}\")
"
;;
disable|enable)
[[ $# -ge 1 ]] || { echo "usage: $CMD <id>" >&2; exit 2; }
id="$1"
new_state="$([ "$CMD" = "disable" ] && echo true || echo false)"
body="$(sumsub_request GET "${ENDPOINT_PATH}")"
current="$(python3 -c "
import json, sys
d=json.loads(sys.argv[1]); items=(d.get('list') or {}).get('items') or []
for w in items:
if w.get('id')==sys.argv[2]: print(json.dumps(w)); sys.exit(0)
print(''); sys.exit(0)
" "$body" "$id")"
if [[ -z "$current" ]]; then
echo "no webhook with id=$id" >&2; exit 1
fi
new_payload="$(mktemp)"
trap 'rm -f "$new_payload"' EXIT
python3 -c "
import json, sys
w=json.loads(sys.argv[1])
w['disabled']=(sys.argv[2]=='true')
print(json.dumps(w))
" "$current" "$new_state" > "$new_payload"
resp="$(sumsub_request POST "${ENDPOINT_PATH}" "$new_payload")"
echo "$resp" | python3 -c "
import json, sys
w=json.load(sys.stdin)
if w.get('code'):
print('ERROR:', w); sys.exit(1)
print(f\"webhook {w.get('id')} disabled={w.get('disabled')}\")
"
;;
*)
echo "unknown command: $CMD" >&2
sed -n '4,24p' "$0" >&2
exit 2
;;
esac