---
name: sumsub-integrate-websdk
description: End-to-end recipe for adding Sumsub KYC to a website or web app via the Sumsub WebSDK. TRIGGER when the user asks to "integrate / embed / add Sumsub", "show the KYC widget", "add WebSDK", "verify users with Sumsub on the frontend", supplies an existing levelName they want to plug into a page, or asks how to wire up access tokens / lifecycle events / webhooks for Sumsub verification in an arbitrary project. Covers the whole loop — level setup, server-side access-token signing, snsWebSdk init (vanilla canonical, React recipe), client lifecycle events, token refresh, source-of-truth via webhooks + applicant GET, sandbox testing, go-live checklist. SKIP for building the level/questionnaire/POA-preset payload itself (use the sibling skills), or for backend-only API calls with no frontend (use `sumsub-api-generic`).
allowed-tools: Read, Write, Bash
---
# 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`](../sumsub-api-auth/SKILL.md).
## 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`](../sumsub-create-level/SKILL.md). 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` (mode `any`).
- `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`](../sumsub-api-auth/SKILL.md)).
- 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
```bash
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" \
"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
```html
<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
```html
<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`](examples/vanilla.html) for a runnable file.
Minimal shape:
```js
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`](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`](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`](../sumsub-manage-webhooks/SKILL.md) skill. It
builds the `clientWebhooks` payload from a compact spec, POSTs to
`/resources/api/agent/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:
```js
// 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`](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:
```bash
# 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. `rejectLabels` says why; `reviewRejectType` is `FINAL`
(can't retry) or `RETRY` (user may resubmit).
Full list: [Sumsub webhook docs](https://docs.sumsub.com/docs/user-verification-webhooks).
### 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):
```bash
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" \
"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 `refreshCallback` when 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-`userId` token, 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 `Greenacre` go through; `Aikman` and
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`](../sumsub-manage-webhooks/SKILL.md)
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).
- [ ] `externalUserId` is 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*).
- [ ] `applicantReviewed` triggers 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`](references/lifecycle.md) — full event catalog
with payload fields, plus webhook event reference.
- [`examples/vanilla.html`](examples/vanilla.html) — runnable single-file
integration.
- [`examples/react-component.tsx`](examples/react-component.tsx) — React
hook + cleanup pattern.
- [`examples/webhook-verify.js`](examples/webhook-verify.js) — signature
verification with raw-body handling.
- [`sumsub-api-auth`](../sumsub-api-auth/SKILL.md) — the auth signing
reference, shared with every other Sumsub skill.
- [`sumsub-create-level`](../sumsub-create-level/SKILL.md) — for the
Stage-1 hand-off.
- [`sumsub-manage-webhooks`](../sumsub-manage-webhooks/SKILL.md) — 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](https://docs.sumsub.com/llms.txt) — authoritative
source if anything in this skill drifts.
Creator's repository · sumsubstance/agent-skills