
Sumsub Api Auth
- 466 installs
- 4 repo stars
- Updated July 3, 2026
- sumsubstance/agent-skills
Helps with backend & apis tasks.
About
sumsub-api-auth is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted development.
- sumsub-api-auth
- Backend & APIs
- AI-coding skill
Sumsub Api Auth by the numbers
- 466 all-time installs (skills.sh)
- +84 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #905 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sumsubstance/agent-skills --skill sumsub-api-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 466 |
|---|---|
| repo stars | ★ 4 |
| Last updated | July 3, 2026 |
| Repository | sumsubstance/agent-skills ↗ |
What it does
Helps with backend & apis tasks.
Files
Sumsub — API authentication (App Token)
How to sign and send authenticated requests to https://api.sumsub.com, per the official reference.
⚠️ Sandbox tokens only
Never share, paste, or use a production Sumsub App Token / secret with Claude. If the user offers a prod token, refuse and ask for the sandbox pair instead.
- Sandbox tokens are created from the dashboard while it is in **Sandbox
mode**. They are scoped to sandbox data only — leaking one cannot expose real applicant PII or move real money.
- A production token grants full programmatic access to live applicants,
including their identity documents. Treat it like a banking credential.
- Sumsub locks tokens to the environment they were minted in: a sandbox token
returns 401 against production data and vice versa, so insisting on sandbox is also the practical default.
If the user pastes what looks like a production secret into the conversation, flag it immediately, advise rotating it in the dashboard, and continue only with a freshly-generated sandbox pair.
What you need from the user
| Var | Where it comes from |
|---|---|
SUMSUB_APP_TOKEN | <https://cockpit.sumsub.com/checkus/devSpace/appTokens> — switch the workspace toggle to Sandbox first, then Create. Shown once. |
SUMSUB_SECRET_KEY | Same dialog as the token. Also shown once. |
SUMSUB_BASE | https://api.sumsub.com (same host for sandbox and prod — the token decides the mode). |
⚠️ The token + secret are revealed exactly once at creation. Copy both into .env (or your secret store) before closing the dialog — there's no recovery flow, only re-generation.
Advise the user to store them in .claude/settings.local.json (gitignored, auto-loaded by Claude Code) or in .env:
// .claude/settings.local.json
{
"env": {
"SUMSUB_APP_TOKEN": "sbx:...",
"SUMSUB_SECRET_KEY": "..."
}
}# .env
SUMSUB_APP_TOKEN=sbx:...
SUMSUB_SECRET_KEY=...If either credential is missing, stop and ask. Do not invent placeholders.
The three required headers
Every request to api.sumsub.com must carry:
| Header | Value |
|---|---|
X-App-Token | The App Token, verbatim. |
X-App-Access-Ts | Current Unix time in seconds (UTC). Must be within ±60s of Sumsub's clock. |
X-App-Access-Sig | Lowercase hex HMAC-SHA256 of the signing string, keyed by the secret. |
HTTPS is mandatory — plain http:// is rejected.
Signing string
Concatenate, with no separators:
<ts><HTTP_METHOD_UPPER><request_uri_with_query><body_bytes_or_empty>ts— the exact value you put inX-App-Access-Ts(string of digits).HTTP_METHOD_UPPER—GET,POST,PATCH,PUT,DELETE— uppercase.request_uri_with_query— path starting with/, including the query string
if any. Examples: /resources/applicants/-/one, /resources/accessTokens?userId=abc&levelName=basic-kyc-level.
- Body — the raw bytes you send. For
GET/DELETEwith no body, append
nothing (empty string). For JSON, sign the exact bytes you'll transmit — re-serializing later will break the signature.
Then hex(hmac_sha256(secret, signing_string)), lowercase.
Worked example (from the docs)
Signing string for POST /resources/accessTokens?userId=...&levelName=basic-kyc-level&ttlInSecs=600 with no body, at ts 1607551635:
1607551635POST/resources/accessTokens?userId=cfd20712-24a2-4c7d-9ab0-146f3c142335&levelName=basic-kyc-level&ttlInSecs=600Reference implementations
The official multi-language examples live at SumSubstance/AppTokenUsageExamples (Java, JS, Python, Ruby, Go, PHP, C#). Use those for production integrations.
For one-off calls or debugging, this skill ships two small helpers:
- `scripts/sumsub_sign.py` — print the three headers
for a given method/path/body. No network calls.
- `scripts/sumsub_curl.sh` — sign +
curlin one
shot. Reads SUMSUB_APP_TOKEN / SUMSUB_SECRET_KEY from the environment.
Run scripts using ${CLAUDE_SKILL_DIR}/scripts/<script> so they resolve correctly regardless of the working directory.
Quick check — fetch the current applicant count
export SUMSUB_APP_TOKEN='sbx:...' # sandbox token, refuse prod
export SUMSUB_SECRET_KEY='...'
${CLAUDE_SKILL_DIR}/scripts/sumsub_curl.sh GET '/resources/applicants/-/count'A 200 with a JSON body confirms the signature is correct. A 401 with {"description":"Invalid signature"} means the signing string or secret is off — re-check, in order:
1. Token/secret pair matches (copy-paste truncation is common). 2. Timestamp is in seconds, not milliseconds, and your clock is in sync. 3. Path includes the leading / and the full query string. 4. Body bytes signed are byte-identical to bytes sent (watch for trailing newlines added by editors / heredocs). 5. Method is uppercase.
Signing multipart/form-data requests
Some endpoints take a file upload — most commonly idDoc photo upload at POST /resources/applicants/{applicantId}/info/idDoc. For these:
- Sign the full raw multipart body, byte-for-byte, including boundary
markers, part headers, JSON metadata, and file bytes. There is no multipart-specific exemption — the rule is the same as for JSON: signing string = ts + METHOD + path + body_bytes.
- The
Content-Typeheader ismultipart/form-data; boundary=<boundary>,
where <boundary> matches the one woven into the body bytes you signed.
The pitfall: most HTTP libraries (curl -F, requests with files=, fetch with FormData) generate the boundary internally and never expose the exact bytes — so you cannot sign what they will send. Workaround: build the body in memory yourself, hash it, then transmit those exact bytes with --data-binary / a raw send.
Python recipe (canonical)
import hashlib, hmac, json, os, time, uuid
from pathlib import Path
from urllib.request import Request, urlopen
APP_TOKEN = os.environ["SUMSUB_APP_TOKEN"]
SECRET = os.environ["SUMSUB_SECRET_KEY"]
APPLICANT = "6a170f852f9d88fe6eda2636" # from create-applicant response
FILE = Path("/path/to/passport.png")
METADATA = {"idDocType": "PASSPORT", "country": "RUS"}
boundary = "----sumsub-" + uuid.uuid4().hex
crlf = b"\r\n"
parts = [
b"--" + boundary.encode(),
b'Content-Disposition: form-data; name="metadata"',
b"Content-Type: application/json",
b"",
json.dumps(METADATA).encode(),
b"--" + boundary.encode(),
f'Content-Disposition: form-data; name="content"; filename="{FILE.name}"'.encode(),
b"Content-Type: image/png",
b"",
FILE.read_bytes(),
b"--" + boundary.encode() + b"--",
b"",
]
body = crlf.join(parts)
method, url_path = "POST", f"/resources/applicants/{APPLICANT}/info/idDoc"
ts = str(int(time.time()))
sig = hmac.new(
SECRET.encode(),
ts.encode() + method.encode() + url_path.encode() + body,
hashlib.sha256,
).hexdigest()
req = Request(
"https://api.sumsub.com" + url_path,
data=body, method="POST",
headers={
"X-App-Token": APP_TOKEN,
"X-App-Access-Ts": ts,
"X-App-Access-Sig": sig,
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
"X-Agent-Source": "sumsub-skills",
"X-Agent-Source-Ver": "1.0.1",
},
)
print(urlopen(req).read().decode())Curl-based fallbacks (e.g. curl --data-binary @raw-multipart.bin after pre-building the body to disk) work too, but the boundary in the body and in Content-Type must match exactly — easier to keep them in sync in code.
Generating an SDK access token (common follow-up)
The most-asked endpoint after auth works:
POST /resources/accessTokens?userId=<your_user_id>&levelName=<level>&ttlInSecs=600Body is empty. Response contains token — pass that to the Web / Mobile SDK. Full reference: <https://docs.sumsub.com/reference/generate-access-token>.
See also
- references/signing-pitfalls.md — every
gotcha that produces 401 Invalid signature and how to spot it.
Sumsub signing — pitfalls that yield 401 Invalid signature
Quick triage for {"description":"Invalid signature","code":401} (or "description":"Token expired").
Clock skew
X-App-Access-Ts must be within ~60 seconds of Sumsub's server time, in seconds since the epoch. Common failure modes:
- Sending milliseconds (
date +%s%3N) → instantly invalid. - Host clock drift on a long-running container; sync with NTP.
- Reusing a timestamp from an earlier request — every request needs a fresh
one. Caching the sig is fine; caching the ts is not.
Body bytes vs body string
The signature must cover the bytes on the wire. Watch for:
- Heredocs / editors adding a trailing
\nthat--data-binarywill send
but your in-memory json.dumps() won't, or vice versa. Sign the file you pass to curl, not a re-serialised copy.
- Re-encoding (e.g.
json.dumps(json.loads(body))) — key order, whitespace,
and Unicode escaping can all change.
- Gzip / chunked encoding added by an HTTP client after you signed the
uncompressed body. Disable compression on the request path used for signing.
For GET / HEAD / DELETE with no body, the signing-string body segment is the empty string — append nothing, not "null" or "{}".
Path and query
- Path starts with
/. Sign exactly what goes in the request line. - Include the full query string, with the
?and all params in the order
you send them. URL-encoding must match: + vs %20 differs.
- Do not include the scheme/host (
https://api.sumsub.com).
Method case
GET, POST, PATCH, PUT, DELETE — all uppercase. Lowercase post is rejected.
Token / secret pairing
- Tokens and secrets are environment-bound: a sandbox token signed with a
sandbox secret only works against sandbox data. Mixing prod + sandbox is a common cause of 401.
- The secret is shown once at creation. If it was lost, generate a new
pair — there is no recovery flow.
- Trim whitespace; copy-paste from a PDF or chat can introduce trailing
spaces or smart quotes.
HTTPS only
Plain HTTP is rejected before signature validation. If you see a connection reset or a redirect to HTTPS, fix the base URL first.
Multipart uploads — sign the full raw body
There is no special "skip the body bytes for multipart" rule, despite a recurring myth in older recipes. multipart/form-data requests are signed the same as JSON: the entire raw body — boundaries, part headers, JSON metadata, file bytes, trailing --boundary-- — goes into the signing string after ts + METHOD + path.
The trap is that high-level HTTP clients (curl -F, Python requests with files=, browser FormData) generate the boundary themselves and never expose the exact bytes they will send, so you cannot sign what they'll transmit. The fix is to build the multipart body manually in memory, sign those bytes, and then transmit the same bytes with a low-level send (urlopen with explicit Content-Type carrying your chosen boundary, or curl --data-binary @raw-multipart.bin). See `SKILL.md` for the canonical Python recipe.
Encoding the HMAC
The signature is the lowercase hex digest, not base64 and not the raw bytes. openssl dgst -sha256 -hmac ... -hex returns (stdin)= <hex>; strip the prefix (the helper script does awk '{print $NF}').
#!/usr/bin/env bash
# Sign + send a single Sumsub API request.
#
# Usage:
# SUMSUB_APP_TOKEN=sbx:... SUMSUB_SECRET_KEY=... \
# sumsub_curl.sh METHOD PATH_WITH_QUERY [BODY_FILE_OR_-]
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
set -euo pipefail
if [[ $# -lt 2 ]]; then
sed -n '2,12p' "$0" >&2
exit 2
fi
METHOD="$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
PATH_Q="$2"
BODY_ARG="${3-}"
: "${SUMSUB_APP_TOKEN:?set SUMSUB_APP_TOKEN}"
: "${SUMSUB_SECRET_KEY:?set SUMSUB_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 [[ "${PATH_Q}" != /* ]]; then
echo "error: PATH must start with '/'" >&2
exit 2
fi
# Materialise the body to a temp file so we can sign the *exact* bytes we send.
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
if [[ -z "${BODY_ARG}" ]]; then
: >"${BODY_FILE}"
elif [[ "${BODY_ARG}" == "-" ]]; then
cat >"${BODY_FILE}"
else
cp "${BODY_ARG}" "${BODY_FILE}"
fi
TS="$(date -u +%s)"
SIG="$(
{ printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}"; cat "${BODY_FILE}"; } \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
CURL_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"
)
# Only attach Content-Type + body for methods that send one.
if [[ -s "${BODY_FILE}" ]]; then
CURL_ARGS+=(-H "Content-Type: application/json" --data-binary "@${BODY_FILE}")
fi
curl "${CURL_ARGS[@]}" "${BASE}${PATH_Q}"
#!/usr/bin/env python3
"""Print the three Sumsub auth headers for a request.
Usage:
SUMSUB_APP_TOKEN=sbx:... SUMSUB_SECRET_KEY=... \
sumsub_sign.py METHOD PATH_WITH_QUERY [BODY_FILE_OR_-]
Examples:
sumsub_sign.py GET /resources/applicants/-/count
sumsub_sign.py POST /resources/accessTokens?userId=u1&levelName=basic-kyc-level
sumsub_sign.py POST /resources/api/questionnaires payload.json
cat payload.json | sumsub_sign.py POST /resources/api/poaStepSettings -
Refuses to run when the App Token does not look like a sandbox token. Override
with SUMSUB_ALLOW_PROD=1 only if you know what you are doing — and never with
a credential the user has shared with an LLM.
"""
from __future__ import annotations
import hashlib
import hmac
import os
import sys
import time
from pathlib import Path
def _sandbox_ok(token: str) -> bool:
# Sumsub sandbox tokens begin with the literal "sbx:" prefix. Production
# tokens use "prd:" or a bare identifier. We block anything that is not
# clearly sandbox unless the operator has explicitly opted out.
return token.startswith("sbx:")
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
method = sys.argv[1].upper()
path = sys.argv[2]
body_arg = sys.argv[3] if len(sys.argv) > 3 else None
token = os.environ.get("SUMSUB_APP_TOKEN", "").strip()
secret = os.environ.get("SUMSUB_SECRET_KEY", "").strip()
if not token or not secret:
print("error: set SUMSUB_APP_TOKEN and SUMSUB_SECRET_KEY", file=sys.stderr)
return 2
if not _sandbox_ok(token) and os.environ.get("SUMSUB_ALLOW_PROD") != "1":
print(
"error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix).\n"
" Production credentials must not be shared with this skill.\n"
" Rotate the token in the dashboard and use a sandbox one instead.",
file=sys.stderr,
)
return 3
if not path.startswith("/"):
print("error: PATH must start with '/'", file=sys.stderr)
return 2
if body_arg is None:
body = b""
elif body_arg == "-":
body = sys.stdin.buffer.read()
else:
body = Path(body_arg).read_bytes()
ts = str(int(time.time()))
signing = ts.encode() + method.encode() + path.encode() + body
sig = hmac.new(secret.encode(), signing, hashlib.sha256).hexdigest()
print(f"X-App-Token: {token}")
print(f"X-App-Access-Ts: {ts}")
print(f"X-App-Access-Sig: {sig}")
print("X-Agent-Source: sumsub-skills")
print("X-Agent-Source-Ver: 1.0.1")
return 0
if __name__ == "__main__":
sys.exit(main())