
Sumsub Integrate Websdk
- 383 installs
- 4 repo stars
- Updated July 3, 2026
- sumsubstance/agent-skills
Helps with ai & agent building tasks.
About
sumsub-integrate-websdk is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- sumsub-integrate-websdk
- AI & Agent Building
- AI-coding skill
Sumsub Integrate Websdk by the numbers
- 383 all-time installs (skills.sh)
- +52 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,024 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-integrate-websdkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 383 |
|---|---|
| repo stars | ★ 4 |
| Last updated | July 3, 2026 |
| Repository | sumsubstance/agent-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Sumsub — WebSDK integration
Embed Sumsub KYC into a web project end-to-end, from level creation to the "applicantReviewed" webhook that gates user access.
⚠️ Sandbox tokens only
Do not accept or use a production App Token / secret during integration work with this skill. The token generates real SDK sessions tied to real applicants. Insist on a sandbox pair from <https://cockpit.sumsub.com/checkus/devSpace/appTokens> — toggle the workspace to Sandbox first, then Create. Token + secret are revealed once at creation; copy both before closing the dialog. Helper scripts in sibling skills enforce this with an sbx: prefix check; the curl recipes below assume the same.
Deeper auth mechanics: `sumsub-api-auth`.
The lifecycle in one picture
┌─────────────────────────┐
│ 1. Level exists in the │ ← one-time, done in dashboard or
│ workspace │ via sumsub-create-level
└─────────────┬───────────┘
│ levelName
┌─────────────▼───────────┐ ┌──────────────────────────────────┐
│ 2. Server-side token │◀─┤ Browser calls /api/sumsub/token │
│ endpoint (HMAC-signed │ └──────────────────────────────────┘
│ POST /resources/ │
│ accessTokens) │
└─────────────┬───────────┘
│ {token, userId}
┌─────────────▼───────────┐
│ 3. Browser: snsWebSdk │ ← user fills doc capture / selfie / form
│ init → build → launch │ events fire: onApplicantSubmitted, etc.
└─────────────┬───────────┘
│ documents submitted
┌─────────────▼───────────┐ ┌──────────────────────────────────┐
│ 4. Sumsub runs checks │─▶│ Webhook POST → your server │
│ (async, ~seconds–min) │ │ (applicantReviewed = the truth) │
└─────────────┬───────────┘ └──────────────────────────────────┘
│ verdict
┌─────────────▼───────────┐
│ 5. Your app gates access │ ← server checks reviewAnswer, not
│ by reading applicant │ the browser. Browser events are
│ via GET /applicants… │ UX only.
└─────────────────────────┘The split between browser events (UX) and webhooks + server reads (authoritative truth) is the most-missed part of a WebSDK integration. Don't trust onApplicantStatusChanged for entitlement decisions.
Stage 1 — Have a level
Every SDK launch references a levelName that exists in the workspace.
If the user has one (e.g. basic-kyc-level, the Sumsub default), capture it and move to Stage 2.
If the user doesn't yet have a level, brainstorm with them and hand off to `sumsub-create-level`. Don't silently pick defaults — the level encodes who can verify (country / applicant type) and what they must provide (ID, selfie, PoA, questionnaire). A reasonable starter flow when the user is genuinely unsure:
APPLICANT_DATA— name, DOB, country, addresses.IDENTITY—PASSPORT,ID_CARD,DRIVERS(modeany).SELFIE—videoRequired: passiveLiveness.
Add PROOF_OF_RESIDENCE only if regulatory; add QUESTIONNAIRE only if they need structured data (source of funds, occupation). For each addition, ask "what decision does this gate?" before agreeing to include it.
Stage 2 — Server-side access-token endpoint
The SDK needs an access token, generated by your backend with the App Token + secret. The token is short-lived (ttlInSecs, default 1800) and scoped to one (userId, levelName) pair.
Endpoint shape
POST https://api.sumsub.com/resources/accessTokens
?userId=<your-stable-user-id>
&levelName=<level-from-stage-1>
&ttlInSecs=600- Body: empty.
- Auth: App Token + HMAC signature (see `sumsub-api-auth`).
- Response:
{ "token": "_act-sbx-<...>", "userId": "..." }.
userId choice (load-bearing)
This is the `externalUserId` Sumsub stores against the applicant. Make it:
- Stable per real user (don't regenerate on each page load — the SDK looks
up returning applicants by this id).
- Opaque to the user (a UUID or DB row id; not their email).
- Tied to your auth system (so a webhook callback can resolve it back to a
user record).
Wrong userId choice → duplicate applicants, "stuck in submitted" support tickets, and the inability to resume an interrupted verification.
Curl recipe
SUMSUB_APP_TOKEN='sbx:...'
SUMSUB_SECRET_KEY='...'
USER_ID='u-12345'
LEVEL='basic-kyc-level'
PATH_Q="/resources/accessTokens?userId=${USER_ID}&levelName=${LEVEL}&ttlInSecs=600"
TS=$(date -u +%s)
SIG=$(printf '%s%s%s' "$TS" "POST" "$PATH_Q" \
| openssl dgst -sha256 -hmac "$SUMSUB_SECRET_KEY" -hex \
| awk '{print $NF}')
curl -sS -X POST \
-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" \
"https://api.sumsub.com${PATH_Q}"URL-encode userId if it might contain /, ?, or &. The signing string must match the URI on the wire exactly — sign the encoded form.
Wiring it into the user's backend
Frame the endpoint as:
- Path: any (e.g.
POST /api/sumsub/access-token). - Inputs: the authenticated user's id, the levelName (often hardcoded
per page).
- Auth: user must be logged in to your app — anyone hitting this route
can spin up a verification session for that userId.
- Output: forward Sumsub's response body verbatim, or just the
token
field. Don't cache it server-side; the browser asks per launch.
Show the snippet for the user's actual stack (Express, FastAPI, Go, etc.) but the contract is the same in all of them: sign, call Sumsub, return token.
Stage 3 — Frontend SDK init
Load the builder
<script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script>This exposes the global snsWebSdk. For bundler-based projects, an npm package exists but the CDN script is what Sumsub officially documents and what every framework wrapper ends up calling.
Container
<div class="kyc-stage" style="position: relative; min-height: 600px;">
<div id="sumsub-websdk-container"></div>
<!-- Overlay loader covers the empty-iframe window. Hide on idCheck.onReady. -->
<div id="kyc-loader" style="position: absolute; inset: 0; display: grid; place-items: center;">
Loading verification…
</div>
</div>Give the stage a defined min-height (e.g. 600px) so the iframe doesn't collapse before the SDK adapts its height.
Don't skip the overlay loader. Between .launch() returning and the SDK iframe loading content from api.sumsub.com/websdk/websdk.html there is a 1–3s window where the container holds an empty iframe and looks broken — especially inside a modal that the user just opened. Mount a loader that covers the container, then hide it in the idCheck.onReady handler (Stage 4). Treating onReady as informational and leaving the handler empty is the single most common "the widget is blank" report.
Canonical vanilla launch
See `examples/vanilla.html` for a runnable file. Minimal shape:
async function getAccessToken() {
const r = await fetch('/api/sumsub/access-token', { method: 'POST' });
if (!r.ok) throw new Error('failed to mint access token');
return (await r.json()).token;
}
const initialToken = await getAccessToken();
const sdk = snsWebSdk
.init(initialToken, () => getAccessToken()) // refresh callback, returns Promise<string>
.withConf({
lang: 'en',
email: currentUser.email, // optional, prefills
phone: currentUser.phone, // optional, prefills
theme: 'light', // 'light' | 'dark'
})
.withOptions({
addViewportTag: false, // host page already sets it
adaptIframeHeight: true,
})
.on('idCheck.onReady', () => {
// SDK iframe content loaded — hide the overlay loader from the container snippet.
document.getElementById('kyc-loader')?.style.setProperty('display', 'none');
})
.on('idCheck.onApplicantSubmitted', () => {
// user just finished uploading; show "we're reviewing"
})
.on('idCheck.onApplicantStatusChanged', (payload) => {
// status moved; payload.reviewStatus = 'pending' | 'queued' | 'completed' | ...
})
.on('idCheck.onError', (err) => {
console.error('sumsub error', err);
})
.onMessage((type, payload) => {
// catch-all firehose — useful for analytics or debugging
})
.build();
sdk.launch('#sumsub-websdk-container');React recipe
`examples/react-component.tsx` — wraps the same builder in a useEffect with cleanup. Two gotchas it handles:
1. The CDN script must be present before snsWebSdk is read. Either inject it once in the document head, or dynamically load it and await the <script>'s load event. 2. On React 18 strict mode in dev, components mount twice — the cleanup function must remove the iframe / clear the container, otherwise you get two stacked widgets.
Other frameworks
The builder API is framework-agnostic. For Vue/Svelte/Angular, mirror the React pattern: lifecycle hook on mount → fetch token → build → launch into a ref'd element; on unmount → empty the container.
Stage 4 — Client lifecycle events
Wire these handlers on the SDK instance. Treat them as UX signals, not authoritative state.
| Event | When | Use it for |
|---|---|---|
idCheck.onReady | SDK iframe content loaded | Hide your own loader. Required — without this the modal looks empty for 1–3s after launch. |
idCheck.onInitialized | First screen rendered | Analytics: "user saw KYC step" |
idCheck.onStepInitiated | Doc-type screen shown | Telemetry per doc type |
idCheck.onStepCompleted | A step finished | Progress bar |
idCheck.onApplicantSubmitted | Docs submitted, server is processing | Move user to a "waiting" view |
idCheck.onApplicantStatusChanged | Status moved | Live progress hint (still not trusted) |
idCheck.onApplicantResubmitted | Re-upload after a rejection | Re-arm waiting view |
idCheck.onApplicantReviewed (or `onApplicantVerificationCompleted` in 2.0) | Final verdict reached client-side | Show a preliminary result, then verify server-side |
idCheck.onError | SDK error | Surface a friendly retry CTA, log code + reason |
idCheck.onUploadError / onUploadWarning | Doc rejected at upload | Inline guidance ("blurred photo", etc.) |
idCheck.onLivenessCompleted (2.0 only) | Liveness attempt finished | Branch on answer for retry UX |
idCheck.onResize | Frame resized | Adjust surrounding layout |
Full payload fields per event: `references/lifecycle.md`.
Required handlers for a baseline integration
If you wire nothing else, wire these three. Skipping any of them produces a known-bad UX:
idCheck.onReady→ hide the overlay loader from Stage 3. *Without this the
modal looks blank for 1–3s after .launch().*
idCheck.onApplicantSubmitted→ move the user to a "we're reviewing"
state. Without this the user re-uploads or contacts support.
idCheck.onError→ render a retryable error to the UI. *Without this
failures only land in console.error and the user sees a stuck loader.*
Why you can't trust onApplicantReviewed alone
The browser event fires from inside the iframe. A bad actor can spoof it trivially. The only authoritative signal is server-side: either a webhook delivery (Stage 5) or an authenticated GET against /resources/applicants/{userId}/one.
Stage 5 — Server-side source of truth
Webhook receiver
Sumsub POSTs JSON to your URL on every event. Two paths for registering it:
- Sandbox (while building this integration): use the
`sumsub-manage-webhooks` skill. It builds the clientWebhooks payload from a compact spec, POSTs to /resources/clientWebhooks with App Token auth, refuses non-sbx: tokens, rejects localhost / 127.0.0.1 targets up front, and walks the user through exposing their local receiver via ngrok http <port> so Sumsub can actually reach it. Hand off the target, types[], and signatureAlgorithm the user wants and let that skill do the POST.
- Production: do not create the prod webhook from any skill, including
this one. Production webhook setup must be done by a human directly in the Sumsub dashboard (Integrations → Webhooks, workspace toggle on Production). The signing secret authenticates real PII deliveries; the audit trail should attribute setup to a person. Prototype the spec against sandbox here, then hand the final settings (target, event list, signature algorithm, custom headers) to whoever has prod access to recreate manually.
Headers you care about:
x-payload-digest— the signature, hex-encoded.x-payload-digest-alg—HMAC_SHA256_HEX(default),HMAC_SHA512_HEX,
or the legacy HMAC_SHA1_HEX.
The *signing secret is not your App Token secret*. It's a separate webhook secret generated (or supplied) at webhook-creation time in the dashboard. Store it in env (SUMSUB_WEBHOOK_SECRET) alongside the App Token pair.
Verification recipe — note the raw bytes requirement; do NOT JSON-parse before computing the digest, because re-serialising changes whitespace and key order:
// Node/Express — bodyParser.raw() so req.body is a Buffer
import crypto from 'node:crypto';
const ALG = { HMAC_SHA1_HEX: 'sha1', HMAC_SHA256_HEX: 'sha256', HMAC_SHA512_HEX: 'sha512' };
function verifySumsubWebhook(req, secret) {
const alg = ALG[req.header('x-payload-digest-alg') || 'HMAC_SHA256_HEX'];
const expected = req.header('x-payload-digest');
const actual = crypto.createHmac(alg, secret).update(req.body).digest('hex');
return Buffer.from(actual, 'hex').length === Buffer.from(expected, 'hex').length
&& crypto.timingSafeEqual(Buffer.from(actual, 'hex'), Buffer.from(expected, 'hex'));
}See `examples/webhook-verify.js` for a complete handler.
Local testing with ngrok
Sumsub needs a publicly reachable URL — your laptop's localhost won't do. The fastest end-to-end loop for local dev:
# 1. In one shell, start your local receiver (Node, Python, whatever).
node server.js # listens on http://localhost:3000
# 2. In another shell, tunnel that port.
ngrok http 3000 # prints https://<random>.ngrok-free.app -> http://localhost:3000
# 3. Register the webhook against the ngrok URL.
# Either via the dashboard (Integrations → Webhooks) OR via
# sumsub-manage-webhooks `create` with target = the ngrok https URL.
# 4. Trigger an event by running a sandbox WebSDK verification end-to-end.
# Watch the request arrive in your local server logs.
# 5. When you're happy, PATCH the webhook to point at your real server
# hostname (sumsub-manage-webhooks `update` command).webhook.site works too if you just want to see the raw payload without running a local receiver — but it can't echo back a 200 to verify the delivery flow.
Webhook events that matter for a KYC flow
type | What it means | Action |
|---|---|---|
applicantCreated | First time you minted a token for this externalUserId | Log; nothing required |
applicantPending | User finished uploading; Sumsub is checking | Show "in review" |
applicantPrechecked | Primary data processing done, queued for human/AML | Still "in review" |
applicantOnHold | Paused (often AML hit needing analyst) | Surface to ops; tell user "extra checks" |
applicantReviewed | Final verdict — reviewResult.reviewAnswer is GREEN or RED | Gate access here. Mark user verified or rejected. |
applicantPersonalInfoChanged | User edited info after submission | Re-check before granting access |
applicantWorkflowCompleted | Whole workflow (multi-level) done | Same as applicantReviewed for single-level flows |
applicantActionPending / Reviewed | One-off action (separate from the level flow) | Per-action handling |
reviewResult.reviewAnswer:
GREEN— approved.RED— rejected.rejectLabelssays why;reviewRejectTypeisFINAL
(can't retry) or RETRY (user may resubmit).
Full list: Sumsub webhook docs.
Server-side status check (fallback / on-demand)
For pages that need to check status synchronously (e.g. user logs back in between webhook arriving and your DB updating):
PATH_Q="/resources/applicants/${USER_ID}/one"
TS=$(date -u +%s)
SIG=$(printf '%s%s%s' "$TS" "GET" "$PATH_Q" \
| openssl dgst -sha256 -hmac "$SUMSUB_SECRET_KEY" -hex \
| awk '{print $NF}')
curl -sS \
-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" \
"https://api.sumsub.com${PATH_Q}"Reads reviewStatus and reviewResult.reviewAnswer. Cheap; use as a fallback, not as a polling loop — webhooks are the primary signal.
Resumption / returning users
The SDK looks up applicants by externalUserId. If a user starts verification, abandons, and returns 3 days later:
1. Your endpoint mints a new access token for the same userId + same levelName. 2. The SDK opens to wherever the user left off (re-uploads only the missing steps). 3. No duplicate applicant is created.
Don't generate a new userId for returning users — that's the #1 cause of "the user is stuck and customer support sees two applicants".
Token refresh
The first argument to .init(token, refreshCallback) covers expiry mid-session:
- SDK calls your
refreshCallbackwhen the token nears expiry. - It must return
Promise<string>resolving to a fresh token (call your
endpoint again).
- If you return a stale or wrong-
userIdtoken, the SDK hangs.
For most flows a 600-second TTL is plenty. Don't pre-fetch and cache — mint on demand.
Sandbox testing
In sandbox mode:
- Use test documents from the Sumsub docs ("Test documents" page) to
trigger GREEN vs RED outcomes without uploading real PII.
- AML hits are simulated — names like
Greenacrego through;Aikmanand
similar are pre-loaded as positive matches.
- Webhook delivery works the same. For a local receiver, expose it through
ngrok http <port> (or Cloudflare Tunnel / Tailscale Funnel) and register the public URL via the `sumsub-manage-webhooks` skill — it has the full walkthrough and rejects raw localhost targets before they ever reach Sumsub. webhook.site is fine for inspecting payload shapes without a real receiver.
- Selfies in sandbox bypass real biometrics — any face works.
Sandbox tokens (sbx:) only fire against the sandbox workspace. Production tokens (prd:) only fire against production. There's no fall-through.
Going live checklist
When the user says "we're ready to switch to prod":
- [ ] Webhook receiver verifies the signature on raw bytes (replay
Stage 5 test against a real delivery).
- [ ]
externalUserIdis the stable user id, not the email / display name. - [ ] Server is the source of truth for verification state. Browser events
may be ignored entirely.
- [ ] Token endpoint is auth-gated (only logged-in users can mint a token
for themselves).
- [ ]
applicantReviewedtriggers the user-facing state change in your DB,
with idempotency (the same event can arrive twice).
- [ ] Per-user retry handling:
reviewResult.reviewRejectType === 'RETRY'
⇒ let user re-launch the SDK; 'FINAL' ⇒ block.
- [ ] Production App Token + secret + separate webhook secret are all
in the prod secret store. Sandbox values stay only in dev env.
See also
- `references/lifecycle.md` — full event catalog
with payload fields, plus webhook event reference.
- `examples/vanilla.html` — runnable single-file
integration.
- `examples/react-component.tsx` — React
hook + cleanup pattern.
- `examples/webhook-verify.js` — signature
verification with raw-body handling.
- `sumsub-api-auth` — the auth signing
reference, shared with every other Sumsub skill.
- `sumsub-create-level` — for the
Stage-1 hand-off.
- `sumsub-manage-webhooks` — for the
Stage-5 hand-off: create / list / update / disable sandbox webhooks via the public API, with the localhost-rejection and ngrok walkthrough built in. Production webhook setup is dashboard-only and must be done by a human.
- Sumsub docs index — authoritative
source if anything in this skill drifts.
// React wrapper around the Sumsub WebSDK builder.
//
// Assumes:
// - <script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script>
// is loaded once in index.html / _document.tsx. (For dynamic loading, see
// loadSumsubScript() below.)
// - A backend route at /api/sumsub/access-token mints tokens for the
// authenticated user. See SKILL.md "Stage 2".
//
// Sandbox tokens only during integration work.
import { useEffect, useRef } from 'react';
declare global {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Window { snsWebSdk: any }
}
type Props = {
levelName: string;
email?: string;
phone?: string;
lang?: string;
theme?: 'light' | 'dark';
onSubmitted?: () => void;
onFinalClientSide?: (payload: unknown) => void;
onError?: (err: unknown) => void;
};
async function fetchAccessToken(): Promise<string> {
const r = await fetch('/api/sumsub/access-token', { method: 'POST' });
if (!r.ok) throw new Error(`access-token endpoint returned ${r.status}`);
const { token } = (await r.json()) as { token: string };
return token;
}
// Optional: load the CDN script on-demand if it isn't in the document yet.
function loadSumsubScript(): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve();
if (window.snsWebSdk) return Promise.resolve();
const src = 'https://static.sumsub.com/idensic/static/sns-websdk-builder.js';
const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null;
if (existing) {
return new Promise((resolve, reject) => {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('failed to load sumsub script')));
});
}
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error('failed to load sumsub script'));
document.head.appendChild(s);
});
}
export function SumsubWebSdk({
levelName,
email,
phone,
lang = 'en',
theme = 'light',
onSubmitted,
onFinalClientSide,
onError,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sdk: any = null;
(async () => {
try {
await loadSumsubScript();
if (cancelled) return;
const initialToken = await fetchAccessToken();
if (cancelled) return;
sdk = window.snsWebSdk
.init(initialToken, () => fetchAccessToken())
.withConf({ lang, theme, email, phone })
.withOptions({ addViewportTag: false, adaptIframeHeight: true })
.on('idCheck.onApplicantSubmitted', () => onSubmitted?.())
.on('idCheck.onApplicantReviewed', (p: unknown) => onFinalClientSide?.(p))
.on('idCheck.onApplicantVerificationCompleted', (p: unknown) => onFinalClientSide?.(p))
.on('idCheck.onError', (e: unknown) => onError?.(e))
.build();
if (!cancelled && containerRef.current) {
sdk.launch(containerRef.current);
}
} catch (err) {
if (!cancelled) onError?.(err);
}
})();
return () => {
cancelled = true;
// The builder does not expose a public destroy() at time of writing.
// Clearing the container removes the iframe, which is enough for SPA
// navigation. React 18 strict-mode double-mount is handled by this.
if (containerRef.current) containerRef.current.innerHTML = '';
sdk = null;
};
}, [levelName, email, phone, lang, theme, onSubmitted, onFinalClientSide, onError]);
return <div ref={containerRef} id="sumsub-websdk-container" style={{ minHeight: 600 }} />;
}
<!doctype html>
<!--
Runnable single-file Sumsub WebSDK integration (vanilla JS).
Prereqs:
- A backend endpoint at /api/sumsub/access-token that mints a token for
the currently-logged-in user (see SKILL.md "Stage 2"). The endpoint
should accept no body and return { token: "..." }.
- A levelName configured in the Sumsub dashboard (sandbox).
Sandbox tokens only. Do not use production credentials during integration.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Verify your identity</title>
<style>
body { font: 16px/1.4 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; }
.kyc-stage { position: relative; min-height: 600px; }
#sumsub-websdk-container { min-height: 600px; }
/* Overlay loader covers the 1–3s empty-iframe gap after .launch(). */
#kyc-loader {
position: absolute; inset: 0;
display: grid; place-items: center; gap: 12px;
background: #fff; color: #555; font-size: 14px;
}
#kyc-loader.hidden { display: none; }
.spinner {
width: 28px; height: 28px; border-radius: 50%;
border: 3px solid #e4e4e7; border-top-color: #4f46e5;
animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status { margin-top: 1rem; padding: .75rem; background: #f4f4f5; border-radius: 6px; }
</style>
</head>
<body>
<h1>Verify your identity</h1>
<div class="kyc-stage">
<div id="sumsub-websdk-container"></div>
<div id="kyc-loader">
<div class="spinner"></div>
<div>Loading verification…</div>
</div>
</div>
<div id="status" class="status" hidden></div>
<script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script>
<script>
async function getAccessToken() {
const r = await fetch('/api/sumsub/access-token', { method: 'POST' });
if (!r.ok) throw new Error('failed to mint access token: ' + r.status);
const { token } = await r.json();
return token;
}
function setStatus(msg) {
const el = document.getElementById('status');
el.hidden = false;
el.textContent = msg;
}
(async () => {
try {
const initialToken = await getAccessToken();
const sdk = snsWebSdk
.init(initialToken, () => getAccessToken()) // refresh on expiry
.withConf({
lang: 'en',
theme: 'light',
// email / phone optional; pass them to prefill the SDK form
})
.withOptions({
addViewportTag: false,
adaptIframeHeight: true,
})
.on('idCheck.onReady', () => {
// Required: hide the overlay loader so the SDK iframe becomes visible.
document.getElementById('kyc-loader').classList.add('hidden');
setStatus('Ready. Follow the steps in the widget.');
})
.on('idCheck.onApplicantSubmitted', () => {
setStatus('Documents submitted — Sumsub is reviewing. We will email you when done.');
})
.on('idCheck.onApplicantStatusChanged', (p) => {
// p.reviewStatus: 'pending' | 'queued' | 'prechecked' | 'onHold' | 'completed'
// UX hint only — server-side webhook is the source of truth.
console.debug('sumsub status', p.reviewStatus, p);
})
.on('idCheck.onApplicantReviewed', (p) => {
// 2.0 may emit onApplicantVerificationCompleted instead.
// Show a *preliminary* result; confirm server-side before granting access.
console.debug('sumsub final (client-side preliminary)', p);
})
.on('idCheck.onUploadError', (e) => {
console.warn('upload error', e.code, e.msg);
})
.on('idCheck.onUploadWarning', (e) => {
console.warn('upload warning', e.code, e.msg);
})
.on('idCheck.onError', (e) => {
console.error('sumsub error', e);
// Also reveal loader area for an error message in case onReady never fired.
document.getElementById('kyc-loader').classList.add('hidden');
setStatus('Something went wrong — please retry. Error code: ' + (e.code || 'unknown'));
})
.onMessage((type, payload) => {
// Catch-all firehose. Useful for analytics during integration; trim before prod.
console.debug('sumsub event', type, payload);
})
.build();
sdk.launch('#sumsub-websdk-container');
} catch (err) {
console.error(err);
setStatus('Could not load verification widget. Please refresh.');
}
})();
</script>
</body>
</html>
// Sumsub webhook receiver — signature verification on RAW bytes.
//
// Why raw bytes: Sumsub's digest covers the exact wire payload. Re-serialising
// after JSON.parse will reorder keys / collapse whitespace and break the match.
//
// Mount as POST /webhooks/sumsub. Use express.raw() (NOT express.json()).
import crypto from 'node:crypto';
import express from 'express';
const ALG_MAP = {
HMAC_SHA1_HEX: 'sha1',
HMAC_SHA256_HEX: 'sha256',
HMAC_SHA512_HEX: 'sha512',
};
const SUMSUB_WEBHOOK_SECRET = process.env.SUMSUB_WEBHOOK_SECRET;
if (!SUMSUB_WEBHOOK_SECRET) throw new Error('SUMSUB_WEBHOOK_SECRET not set');
export function verifySumsubWebhook(rawBody, headers) {
const algName = headers['x-payload-digest-alg'] || 'HMAC_SHA256_HEX';
const alg = ALG_MAP[algName];
if (!alg) return { ok: false, reason: `unknown algorithm: ${algName}` };
const expectedHex = headers['x-payload-digest'];
if (!expectedHex) return { ok: false, reason: 'missing x-payload-digest header' };
const actualHex = crypto.createHmac(alg, SUMSUB_WEBHOOK_SECRET).update(rawBody).digest('hex');
const actualBuf = Buffer.from(actualHex, 'hex');
const expectedBuf = Buffer.from(expectedHex, 'hex');
if (actualBuf.length !== expectedBuf.length) return { ok: false, reason: 'length mismatch' };
if (!crypto.timingSafeEqual(actualBuf, expectedBuf)) return { ok: false, reason: 'digest mismatch' };
return { ok: true };
}
export const router = express.Router();
router.post(
'/webhooks/sumsub',
express.raw({ type: 'application/json' }), // req.body is a Buffer
(req, res) => {
const result = verifySumsubWebhook(req.body, req.headers);
if (!result.ok) {
console.warn('sumsub webhook rejected:', result.reason);
return res.status(401).send('invalid signature');
}
// Safe to parse now that the digest is verified.
const event = JSON.parse(req.body.toString('utf8'));
// Idempotency: dedupe by applicantId + type + createdAtMs (or whatever
// your DB uses). Webhooks can arrive twice; insert-on-duplicate-ignore.
handleEvent(event).catch((err) => console.error('handler error', err));
// Respond 2xx ASAP so Sumsub does not retry.
res.status(200).end();
},
);
async function handleEvent(event) {
switch (event.type) {
case 'applicantCreated':
// First mint of a token for externalUserId. Nothing required.
break;
case 'applicantPending':
case 'applicantPrechecked':
// Show "in review" in the UI; do not gate access yet.
break;
case 'applicantOnHold':
// Notify ops. Tell user "additional checks in progress".
break;
case 'applicantReviewed': {
// THIS IS THE TRUTH. Update DB.
const { reviewAnswer, reviewRejectType, rejectLabels = [] } = event.reviewResult || {};
if (reviewAnswer === 'GREEN') {
await markUserVerified(event.externalUserId);
} else if (reviewAnswer === 'RED') {
await markUserRejected(event.externalUserId, {
finalNoRetry: reviewRejectType === 'FINAL',
labels: rejectLabels,
});
}
break;
}
case 'applicantPersonalInfoChanged':
// User edited info after submission — re-check before granting access.
break;
case 'applicantWorkflowCompleted':
// Multi-level workflow finished. Same handling as applicantReviewed
// for single-level flows.
break;
default:
// Unhandled event type — log and move on.
console.debug('sumsub unhandled event', event.type, event.applicantId);
}
}
// --- Replace with your DB writes ------------------------------------------
async function markUserVerified(externalUserId) {
// e.g. UPDATE users SET kyc_status = 'verified', kyc_verified_at = now()
// WHERE id = $1;
}
async function markUserRejected(externalUserId, { finalNoRetry, labels }) {
// e.g. UPDATE users SET kyc_status = 'rejected',
// kyc_can_retry = NOT $2,
// kyc_reject_labels = $3
// WHERE id = $1;
}
WebSDK + webhook lifecycle reference
Full event catalogue for both sides of the loop. The SKILL.md has the abridged "use it for" view; this is the field-by-field truth.
Browser events (snsWebSdk .on / .onMessage)
All event names are prefixed with idCheck.. Listen with .on('idCheck.<name>', cb). The .onMessage((type, payload) => …) catch-all receives every event including ones not listed here — useful for analytics.
| Event | Fires when | Payload fields |
|---|---|---|
onReady | Resources loaded into the iframe | (none) |
onInitialized | First screen rendered | (none) |
onStepInitiated | Doc-type screen displayed | idDocSetType, types |
onLivenessCompleted (2.0) | A liveness attempt finished | answer, allowContinuing |
onStepCompleted | A step (e.g. IDENTITY) completed | step or idDocSetType |
onApplicantLoaded | Applicant resolved by externalUserId | applicantId |
onApplicantSubmitted | All required docs submitted | (none) |
onApplicantStatusChanged | Status moved (intermediate or final) | reprocessing, levelName, createDate, expireDate, reviewStatus, reviewResult, autoChecked |
onApplicantResubmitted | User submitted again after rejection | (none) |
onApplicantActionLoaded | Applicant-action loaded | applicantActionId |
onApplicantActionSubmitted | Applicant-action submitted | (none) |
onApplicantActionStatusChanged | Action status moved | reprocessing, levelName, creationDate, expireDate, reviewStatus, autoChecked |
onApplicantActionCompleted | Action completed | action, applicantActionId, answer |
moduleResultPresented | Module-check result shown | answer (GREEN/YELLOW/RED) |
onResize | Iframe resized | height |
onVideoIdentCallStarted | Video-ident call started by user | (none) |
onVideoIdentModeratorJoined | Operator joined the call | (none) |
onVideoIdentCompleted | Video call completed | (none) |
onUploadError | Upload rejected | code, msg |
onUploadWarning | Upload accepted with a warning | code, msg |
onNavigationUiControlsStateChanged (requires `controlledNavigationBack: true`) | Nav controls state changed | previousScreenButton, closeModalButton |
onApplicantLevelChanged | Level swapped mid-flow | levelName |
onApplicantVerificationCompleted (2.0) | Final verdict reached client-side | reprocessing, levelName, createDate, reviewStatus, reviewResult, autoChecked |
reviewStatus values (in browser events and webhooks alike)
init → pending → queued → prechecked → onHold → completed. Not strictly linear — onHold and back-to-pending are common.
reviewResult.reviewAnswer
GREEN (approved) or RED (rejected). YELLOW only appears on per-module result events (moduleResultPresented), never as a final verdict.
Webhook events
Sumsub POSTs JSON to your configured URL. Headers:
x-payload-digest— hex HMAC of the raw body.x-payload-digest-alg—HMAC_SHA1_HEX|HMAC_SHA256_HEX|HMAC_SHA512_HEX.- Signing secret is the webhook secret (set per webhook in the
dashboard), not the App Token secret.
Verify on raw bytes, before JSON parsing.
type | When | Key payload |
|---|---|---|
applicantCreated | First token mint for an externalUserId | applicantId, inspectionId, levelName, externalUserId, reviewStatus: init, clientId |
applicantPending | Docs uploaded, processing begins | applicant ids, levelName, optional reviewMode |
applicantPrechecked | Primary data processing done | reviewStatus: queued, optional reviewMode |
applicantOnHold | Paused (typically AML) | reviewResult, reviewStatus: onHold, optional reviewMode |
applicantReviewed | Final verdict. | reviewResult (with reviewAnswer, rejectLabels[], reviewRejectType, buttonIds[]) |
applicantPersonalInfoChanged | User edited info post-submission | reviewResult, current reviewStatus |
applicantActionPending / applicantActionReviewed | Per-action lifecycle | applicantActionId, externalApplicantActionId, reviewResult |
applicantWorkflowCompleted | Multi-level workflow finished | reviewResult |
reviewRejectType (on RED)
FINAL— verification is over; do not let the user retry.RETRY— user may upload again.rejectLabelstells them why.
rejectLabels you'll see most
BLOCKLIST— user is on a Sumsub block list (often AML).WRONG_USER_REGION— country gating.DOCUMENT_PAGE_MISSING/DOCUMENT_DAMAGED/BAD_PHOTO— UX guidance.SCREENSHOTS— accepted only if level explicitly allows it.FORGERY/INCONSISTENT_PROFILE— fraud signals.
The dashboard's level builder shows the full catalog under "Reject reasons".
Idempotency
Webhooks can arrive twice. Key your DB upserts by (applicantId, type, createdAt) or by applicantId + a version field — never insert blindly.
Order
Sumsub does not guarantee strict ordering. applicantReviewed can arrive before applicantPending in pathological cases. Your state machine should be order-independent: always trust the latest reviewStatus / reviewResult on the applicant, not the sequence of webhooks.