Loading...
Loading...
End-to-end recipe for integrating Sumsub ID Connect — the OIDC-based "Verify with Sumsub ID" flow that lets a user share previously-verified identity claims with your app. TRIGGER when the user asks to "integrate Sumsub ID Connect", "add Verify-with-Sumsub-ID button", "reuse Sumsub KYC via OIDC", "exchange the Sumsub authorization code", "mint a Sumsub ID share token", or asks how to wire up `@sumsub/id-connect`, the OIDC `code → access_token` exchange (`/api/snsId/oauth/token`), the share-token / partner-consent flow, or the share-link redirect. Covers preconditions checklist, automated preflight, frontend button (library button + raw redirect alternative), backend code-for-token exchange, share-token minting, partner-consent shareLink flow, and applicant materialisation through `/resources/api/reusableIdentity/reuse`. SKIP for the standalone WebSDK / KYC widget (use `sumsub-integrate-websdk`), or for plain Reusable-KYC-via-API where the donor and recipient are both your own tenants without a user-mediated OIDC step.
npx skill4agent add sumsub/agent-skills sumsub-integrate-id-connectsbx:sumsub-api-authscripts/preflight.sh ┌──────────────────────────────┐
│ Stage 0a Preconditions check │ manual setup on Sumsub side
│ Stage 0b scripts/preflight.sh │ automated validation
└──────────────┬───────────────┘
│ clientId, levelName, allowlist
┌──────────────▼───────────────┐
│ Stage 1: Frontend "Verify…" │ authorize redirect with PKCE
│ button │
└──────────────┬───────────────┘
│ onSuccess({ code, state })
┌──────────────▼───────────────┐
│ Stage 2: code → access_token │ POST id.sumsub.com/api/snsId/oauth/token
│ (client_secret_basic) │
└──────────────┬───────────────┘
│ access_token
┌──────────────▼───────────────┐
│ Stage 3a: → share token │ POST /resources/accessTokens/sumsubIdShareToken
└──────────────┬───────────────┘
│ sharingAllowed?
┌───────┴────────┐
│ false │
▼ ▼ true
┌──────────────────┐ ┌──────────────────────────────┐
│ Stage 3b: │ │ Stage 4: reuse/preview + │
│ shareLink │ │ reuse/commit │
│ redirect + retry │ │ → applicant in your workspace │
└────────┬─────────┘ └──────────────────────────────┘
│
└──────► back to Stage 3a (retry — now sharingAllowed: true)| METHOD | URL | Auth | Stage |
|---|---|---|---|
| (browser redirect) | | none | 1 |
| GET | | none | 0b |
| GET | | none | (id_token validation) |
| POST | | | 2 |
| POST | | App Token + HMAC | 3a |
| POST | | App Token + HMAC + Bearer | 3b |
| GET | | App Token + HMAC | 4 |
| POST | | App Token + HMAC | 4 |
connect_token: ID Connect not enabled for this workspacesupport@sumsub.com```
Subject: Enable Sumsub ID Connect for clientId <YOUR_CLIENT_ID> (sandbox)
Hi Sumsub team,
Please enable Sumsub ID Connect for our workspace.
Environment: sandbox
clientId: <YOUR_CLIENT_ID> (Dashboard top-left)
Intended scopes: openid, share, name (adjust as needed)
Intended recipients: self (or list partner clientIds)
Use case: <one-line description of where the button will live>
Once enabled we'll register the OIDC client + redirect URIs in
Dev Space → OIDC Settings.
Thanks
```SUMSUB_APP_TOKENSUMSUB_SECRET_KEYclient_idforClientIdclient_secretredirect_urihttps://yourapp.com/auth/callbackhttps://yourapp.com/share-completehttp://localhost:3000levelNamesumsub-create-level10521 reusable-kyc-inactive-sumsub-id-accountscripts/preflight.shSUMSUB_APP_TOKEN=sbx:... SUMSUB_SECRET_KEY=... \
bash scripts/preflight.sh| Check | What it proves |
|---|---|
| App-Token HMAC signing is accepted and ID Connect is enabled for the workspace — probes the endpoint with a known-bad code and expects |
| |
| At least one verification level exists in this workspace |
010http://localhostredirect_uriredirect_urilocalhostPORT=3000PORT=3000 node examples/express-callback.jsngrok http 3000 --domain=your-reserved-name.ngrok-free.app
# or, ephemeral URL (changes each restart): ngrok http 3000https://your-reserved-name.ngrok-free.apphttps://your-reserved-name.ngrok-free.app/
https://your-reserved-name.ngrok-free.app/share-completeredirect_uriredirectUriPUBLIC_BASE_URLPUBLIC_BASE_URL=https://your-reserved-name.ngrok-free.app \
PORT=3000 node examples/express-callback.jslocalhost:3000redirect_uri⚠️ Re-register on every URL change. If you use an ephemeral ngrok URL, the allowlist entry (and) must be updated each time ngrok restarts. A reserved domain avoids this churn.PUBLIC_BASE_URLngrok's free interstitial ("You are about to visit…") only affects API/XHR calls, not top-level browser navigation, so it doesn't block the OIDC redirects. A reserved domain or paid plan removes it entirely.
⛔ Gate. Do not start this stage until Stage 0a preconditions are all confirmed with the user AND Stage 0b preflight exits 0. If you haven't done both, go back — see "Order of operations" above. This is the first stage where you write code; everything before it is setup you must verify first.
id.sumsub.comapi.sumsub.com@sumsub/id-connectid.sumsub.com?code={ code, codeVerifier }code → access_token → share token → reusePOST /api/sumsub/id-connect/exchangeexamples/oidc-button.htmlexamples/express-callback.js⚠️is not a verification signal. It only confirms the user finished the OIDC consent step and Sumsub issued an authorization code. The actual verification verdict comes from Stage 4 —onSuccessin theapplicant.review.reviewStatusresponse (immediate, if your recipient level runs no additional checks) or the/reusewebhook (authoritative, for any level that runs post-reuse checks). Never grant access or unlock features based onapplicantReviewedfiring.onSuccess
createButton@sumsub/id-connectimport { createButton } from '@sumsub/id-connect';
// PKCE helpers (S256 — full versions in examples/oidc-button.html).
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const sha256 = (s) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
// Generate the PKCE pair yourself, store the verifier under YOUR key — Stage 2
// on the backend needs that verifier to exchange the code for an access token.
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = b64url(await sha256(verifier));
sessionStorage.setItem('myapp.pkce.verifier', verifier);
createButton({
clientId: '<your-client-id>',
permissions: ['openid', 'share', 'name'],
container: document.getElementById('button-container'),
codeChallenge: challenge, // library uses YOUR challenge — skips its own PKCE gen
loginHint: 'user@example.com', // optional, prefills the email field
onSuccess: async ({ code, state }) => {
const v = sessionStorage.getItem('myapp.pkce.verifier');
await fetch('/api/sumsub/id-connect/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, codeVerifier: v, redirectUri: location.href }),
});
},
onError: (err) => console.error('sumsub-id-connect error', err),
});verifiersessionStorageonSuccesscodeChallengeexamples/oidc-button.htmlopenModal({...})examples/oidc-modal.htmlGET https://id.sumsub.com/api/snsId/oauth/authorize
?client_id=<your-client-id>
&response_type=code
&redirect_uri=<allowlisted URI>
&scope=openid+share+name
&state=<random CSRF nonce>
&code_challenge=<base64url(SHA256(code_verifier))>
&code_challenge_method=S256
&login_hint=<optional email>code_verifiercode_challenge = base64url(SHA256(verifier))sessionStoragecode_challenge_method=S256plainredirect_uriexamples/oidc-redirect.html['openid', 'share', 'name']emailprofileoffline_accessreferences/scopes-and-claims.mdclient_secretclient_secret_basicPOST https://id.sumsub.com/api/snsId/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(<client_id>:<client_secret>)
grant_type=authorization_code
&code=<the code your frontend got>
&redirect_uri=<exact-match URI from Stage 1 — byte-for-byte>
&code_verifier=<PKCE verifier matched to the code_challenge from Stage 1>access_token{
"access_token": "snd-id-con-a-...",
"refresh_token": "snd-id-con-r-...", // only with `offline_access`
"token_type": "Bearer",
"expires_in": 86400,
"id_token": "eyJhbGc..." // only with `openid` — RS256-signed
}grant_type: "refresh_token"Thestays server-side — never in the browser bundle. The exchange isclient_secret/client_secret_basic; do not HMAC-sign it. (The App-Token + HMAC auth is still used in Stage 3a/4 for the share token and reuse calls.)client_secret_post
Invalid coderedirect_uriid_tokenjosepyjwtjjwtjwks_uri.well-known/openid-configurationforClientIdsharingAllowed: falseforClientIdsharingAllowed: truePOST https://api.sumsub.com/resources/accessTokens/sumsubIdShareToken
{
"sumsubIdConnectToken": "<access_token from Stage 2>",
"forClientId": "<recipient clientId>",
"ttlInSecs": 1800
}forClientIdclientIdclientId{
"token": "_act-snsId-...",
"forClientId": "<echoed>",
"sharingAllowed": true // or false → continue to Stage 3b
}sharingAllowed: falsePOST https://api.sumsub.com/resources/snsId/oauth/shareLink
Authorization: Bearer <access_token from Stage 2>
X-App-Token + X-App-Access-Ts + X-App-Access-Sig
{
"redirectUri": "<your shareLink callback URL — must be in the allowlist>",
"forClientId": "<same recipient clientId>",
"displayMode": "page",
"state": "<csrf-or-correlation-id>"
}{ "link": "https://id.sumsub.com/share/<id>" }access_tokenstatelinkredirectUri?token=<JWT>statesharingAllowed: trueforClientId⚠️is inside the JWT payload, not a top-levelstatequery param. Decode the?state=JWT to readtokenback (verify its signature against thestatefirst), then look up the savedjwks_uriby thataccess_token. Re-call Stage 3a — the share token now returnsstate. Decoded payload shape:sharingAllowed: true.{ iss, sub, aud, iat, exp, sharingAllowed, state, forClientId }
examples/express-callback.jspendingSharestate/share-completeGET https://api.sumsub.com/resources/api/reusableIdentity/reuse/preview
?shareToken=<token>
&levelName=<your-level>
&userId=<externalUserId>ApplicantPublicDto/reusePOST https://api.sumsub.com/resources/api/reusableIdentity/reuse
?shareToken=<token>
&levelName=<your-level>
&userId=<externalUserId>userIdexternalUserId400errorCodeerrorName401errorCodedescription| HTTP / errorCode | name | Cause | Action |
|---|---|---|---|
| 401 / — | | Share token expired (TTL exceeded), already consumed, or malformed | Re-mint via Stage 3a — call |
400 / | | | Add the recipient as a partner in the Dashboard (Sumsub UI only — no public API) |
400 / | | The partner ID extracted from the share token is invalid | Re-check |
400 / | | Share token is malformed or expired | Re-mint via Stage 3a |
400 / | | Share token is not suitable for reuse at the given | Re-mint share token with matching scope, retry against the same level |
400 / | | Reusable KYC is disabled for this workspace | Contact Sumsub support to enable Reusable KYC for your workspace |
400 / | | Generic fallback — donor doesn't meet reuse eligibility and no more specific reason matched | Inspect the donor in the Dashboard; pick a different donor or fall back to the WebSDK |
400 / | | Donor applicant is not in an approved state (pending / rejected / on-hold) | Wait for donor's KYC to be approved; use a donor with approved status |
400 / | | Donor applicant is inactive (deactivated / blocked) | Use a different donor — reactivation is Sumsub-side |
400 / | | Donor's source moderation type is not suitable for reuse | Donor needs standard KYC moderation; specialised flows aren't reusable |
400 / | | Required selfie or identity document is missing on donor | Pick a level without that doc-set, or have donor re-verify |
400 / | | Recipient level requires liveness; donor's selfie wasn't captured with liveness | Use a level without |
400 / | | Required document types don't overlap between donor and recipient levels | Pick a recipient level whose required docs are a subset of donor's |
400 / | | Recipient level requires document types the donor doesn't carry | Use a less strict level, or fall back to the WebSDK for full capture |
400 / | | Donor's Proof of Identity is outdated and not valid for reuse | Donor must re-submit fresh POI, or relax recipient level's POI freshness window |
400 / | | Donor's Proof of Address is outdated and not valid for reuse | Donor must re-submit fresh POA, or relax recipient level's POA freshness window |
400 / | | Donor doesn't meet the age criteria of the recipient level | No recovery — different user required |
400 / | | Capture settings (file upload vs live camera, etc.) mismatch between donor and recipient | Align recipient level's |
400 / | | Donor's email doesn't match the email already on the recipient applicant | Resolve duplicate-applicant collision — different |
400 / | | Donor's phone doesn't match the phone already on the recipient applicant | Same as 10518 — resolve collision |
400 / | | Donor Sumsub ID account has no stored documents (email-only, KYC never completed) | Donor must complete full KYC on |
applicantReviewedsumsub-integrate-websdksumsub-manage-webhooksclientIdredirectUriid_tokenexternalUserId/reuse/preview/reusesharingAllowed: trueclient_secretreferences/scopes-and-claims.mdscripts/preflight.shexamples/oidc-button.htmlcreateButtonexamples/oidc-modal.htmlopenModalexamples/oidc-redirect.htmlexamples/express-callback.jssumsub-api-authsumsub-create-levelsumsub-integrate-websdk