--- 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 — **Connect Sumsub to your AI agent** -> **Build & configure** -> **Generate token**. 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= &levelName= &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 ``` 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
Loading verification…
``` 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 .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 `