Sumsub KYC API Integration in Node.js: 14 Steps [2026]

Most Sumsub tutorials stop at the moment a player sees a green checkmark. That checkmark is a UI event – not a compliance decision – and building an iGaming onboarding flow on top of it is how operators end up paying out to unverified accounts. This guide takes a different route. We build a production-grade Sumsub KYC API integration in Node.js 24 LTS, TypeScript 6.0, and Express 5, structured around a single principle: the webhook is the source of truth, and everything else is a hint.

Across 14 steps you will build a signed API client, a jurisdiction-aware applicant service, a hardened WebSDK 2.6 embed, a signature-verified webhook endpoint with idempotency, an account-state machine driven by reviewResult, and a reconciliation job that survives missed events – a deeper build than the leaner 12-step Sumsub KYC API flow (five signed endpoints, one inbound webhook) Tech Insider published in July 2026 for AUSTRAC-regulated integrations, extended here with iGaming-specific jurisdiction routing. Every code block is real, runnable TypeScript. By the end you will have a KYC backend that behaves correctly when networks drop packets, when Sumsub retries a webhook, and when a regulator asks you to prove exactly when a player crossed from pending to verified.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why an Event-Driven Sumsub Integration Beats the Happy Path

The naive integration treats KYC as a request/response call: open the WebSDK, wait for onApplicantStatusChanged, flip the account to verified. It works in a demo and fails in production, because Sumsub’s verification is asynchronous by design. A document can be submitted, queued, reviewed by automation, escalated to a human, and finalized minutes or hours later. The browser that started the flow may be long gone.

Sumsub, founded in 2015 by CEO Andrew Sever alongside twin brothers Peter and Jacob Sever and CTO Vyacheslav Zholudev, and headquartered at 30 St Mary Axe in London, built its platform around this asynchronous model. The company now supports 14,000+ document types across 220+ countries and territories and serves 4,000+ clients – reach that embedded players are now building on directly, as when Monavate folded Sumsub document checks across those same 220+ countries and territories into its MonavateOne API in April 2026. In its 2026 Gambling IQ “Defenders of Trust” report, Sumsub was ranked the number-one iGaming fraud-prevention provider, and in May 2026 it extended that fraud-prevention stack beyond gambling with the launch of “Sumsub for Marketplaces,” aimed at onboarding and fraud checks for marketplace platforms. That scale exists precisely because verdicts are computed server-side and pushed to you, not returned inline.

The three signals and which one you can trust

Your integration will receive three kinds of signal about a player: WebSDK front-end events, HTTP responses to status polls, and webhooks. Only one of them is authoritative.

  • WebSDK events (e.g. idCheck.onApplicantSubmitted) tell you what the UI is doing. Great for UX – “show a spinner”, “advance the wizard” – never for granting access.
  • Status polls (GET /resources/applicants/{id}/status) are truthful but expensive and rate-limited. Use them for reconciliation, not as your primary path.
  • Webhooks (applicantReviewed carrying a reviewResult) are the compliance record. This is the only signal you use to move money-related permissions.

Everything below is organized to enforce that hierarchy in code. The payoff is concrete: when a player disputes a locked withdrawal, or an auditor asks why an account was approved at a specific minute, you can point to a signed webhook payload and a timestamped state transition rather than a browser log you cannot trust. Designing for that from the first commit costs almost nothing; retrofitting it after launch means reprocessing months of applicants and explaining gaps to a regulator.

Step 1–2: Prerequisites, Versions, and the Sumsub Credential Model

Before writing code, pin your toolchain and understand the three secrets Sumsub hands you. Confusing them is the single most common cause of a failed integration.

Verified 2026 version matrix

Component Version (July 2026) Source Notes
Node.js 24 LTS “Krypton” (24.18.0) nodejs.org LTS since 24.11.0; supported to April 2028; native fetch stable
TypeScript 6.0.3 npm Released 23 Mar 2026 – last JS-based release before the Go rewrite
Express 5.2.1 npm Express 5 is the current default; keep the raw body parser scoped
@sumsub/websdk 2.6.3 npm (2026-06-10) Vanilla builder loaded from Sumsub CDN or bundled
@sumsub/websdk-react 2.6.3 npm (2026-06-10) React wrapper component; same release train as core

The three secrets: app token, secret key, webhook secret

In your Sumsub dashboard under Dev Space → App Tokens you generate an app token and its paired secret key. The app token is prefixed sbx: in the sandbox and prod: in production – this prefix is how you tell environments apart, since both use the same host, https://api.sumsub.com. Separately, under Dev Space → Webhooks, you configure a webhook secret key. This is a distinct value used only to sign inbound webhooks. Using the API secret key to verify a webhook – a mistake the official quickstart never warns about – produces a signature that never matches.

# .env – never commit this file
SUMSUB_APP_TOKEN=sbx:XXXXXXXXXXXXXXXXXXXX.YYYYYYYYYYYYYYYYYYYY
SUMSUB_SECRET_KEY=ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
SUMSUB_WEBHOOK_SECRET=WWWWWWWWWWWWWWWWWWWWWWWW
SUMSUB_BASE_URL=https://api.sumsub.com
PORT=3000

Step 3: Scaffold the TypeScript Project

Create the project and install pinned dependencies. We deliberately avoid an HTTP client library – Node.js 24 ships a stable global fetch (undici), which keeps the dependency surface small and the signature logic transparent.

mkdir sumsub-igaming-kyc && cd sumsub-igaming-kyc
npm init -y
npm install express@^5.2 dotenv@^16 zod@^3
npm install -D typescript@^6.0 tsx@^4 @types/node@^24 @types/express@^5

Use a modern tsconfig.json targeting NodeNext resolution so ESM imports and node: built-ins behave predictably.

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

A type-safe config loader

Validate environment variables at boot with Zod so a missing secret fails loudly on startup instead of silently at the first API call.

// src/config.ts
import 'dotenv/config';
import { z } from 'zod';

const Env = z.object({
  SUMSUB_APP_TOKEN: z.string().refine(
    (v) => v.startsWith('sbx:') || v.startsWith('prod:'),
    'App token must start with sbx: or prod:',
  ),
  SUMSUB_SECRET_KEY: z.string().min(20),
  SUMSUB_WEBHOOK_SECRET: z.string().min(10),
  SUMSUB_BASE_URL: z.string().url().default('https://api.sumsub.com'),
  PORT: z.coerce.number().default(3000),
});

export const config = Env.parse(process.env);

Step 4: Build a Reusable HMAC Request Signer

Every Sumsub API call is authenticated with three headers: X-App-Token, X-App-Access-Ts, and X-App-Access-Sig. The signature is an HMAC-SHA256 hex digest, keyed with your secret key, computed over the exact string timestamp + HTTP_METHOD + path + body. Two details break integrations here: the method must be uppercase, and the path must include the query string exactly as sent.

// src/sumsub-client.ts
import crypto from 'node:crypto';
import { config } from './config.js';

type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE';

function sign(ts: number, method: Method, path: string, body: string): string {
  return crypto
    .createHmac('sha256', config.SUMSUB_SECRET_KEY)
    .update(ts + method + path + body)
    .digest('hex');
}

export async function sumsubRequest<T>(
  method: Method,
  path: string,          // MUST include the query string, e.g. /resources/applicants?levelName=...
  body?: unknown,
): Promise<T> {
  const ts = Math.floor(Date.now() / 1000);
  const payload = body ? JSON.stringify(body) : '';
  const signature = sign(ts, method, path, payload);

  const res = await fetch(config.SUMSUB_BASE_URL + path, {
    method,
    headers: {
      'X-App-Token': config.SUMSUB_APP_TOKEN,
      'X-App-Access-Ts': String(ts),
      'X-App-Access-Sig': signature,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: payload || undefined,
  });

  if (!res.ok) {
    throw new Error(`Sumsub ${method} ${path} -> ${res.status}: ${await res.text()}`);
  }
  return res.json() as Promise<T>;
}

Why signing the exact bytes matters

Notice we serialize the body once into payload and reuse that identical string both to sign and to send. A subtle bug appears the moment you sign one object and then let a framework re-serialize a different one – key ordering or whitespace changes the bytes, the digest changes, and Sumsub returns 401. Sign what you send, byte for byte.

Wrapping transient failures

Network blips and brief 5xx responses from any third party are inevitable at scale. Because a failed applicant lookup or token mint during signup directly blocks a deposit, wrap your idempotent GET calls in a small bounded retry with exponential backoff. Keep it deliberately narrow – never blindly retry a non-idempotent POST that could create a second applicant behind the scenes.

// src/retry.ts
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      await new Promise((r) => setTimeout(r, 250 * 2 ** i));   // 250ms, 500ms, 1s
    }
  }
  throw lastErr;
}

Step 5: Create Applicants with Jurisdiction-Aware Level Routing

A level in Sumsub is a named verification flow you configure in the dashboard – which documents are required, whether liveness is on, which AML checks run. iGaming operators almost never want one global level. A Malta (MGA) licence, a UK (UKGC) licence, and a Curaçao licence demand different evidence and different source-of-funds triggers. Encode that mapping in code so a player is routed to the correct flow the instant you know their jurisdiction.

// src/applicants.ts
import { sumsubRequest } from './sumsub-client.js';

// Map licence/jurisdiction -> a level name you created in the Sumsub dashboard.
const LEVEL_BY_JURISDICTION: Record<string, string> = {
  MGA:  'igaming-mga-full',       // Malta: identity + liveness + PoA, SoF hooks
  UKGC: 'igaming-ukgc-age',       // UK: age & identity, source-of-funds triggers
  CW:   'igaming-curacao-basic',  // Curacao: identity + liveness
};

export interface Applicant { id: string; externalUserId: string; }

export async function createApplicant(userId: string, jurisdiction: string, email?: string) {
  const levelName = LEVEL_BY_JURISDICTION[jurisdiction] ?? 'igaming-curacao-basic';
  const path = `/resources/applicants?levelName=${encodeURIComponent(levelName)}`;
  return sumsubRequest<Applicant>('POST', path, {
    externalUserId: userId,   // your immutable player id – NEVER the email
    email,
    type: 'individual',
  });
}

// Idempotent: reuse an existing applicant instead of creating duplicates.
export async function getOrCreateApplicant(userId: string, jurisdiction: string, email?: string) {
  try {
    return await sumsubRequest<Applicant>(
      'GET',
      `/resources/applicants/-/one?externalUserId=${encodeURIComponent(userId)}`,
    );
  } catch {
    return createApplicant(userId, jurisdiction, email);
  }
}

Choosing a stable externalUserId

The externalUserId is the join key between your database and Sumsub. Make it your immutable internal player ID – a UUID or numeric primary key – never an email or username, both of which players change. If you use a mutable identifier, a player who updates their email spawns a second applicant, and your verified status silently detaches from the account. Because iGaming licences ultimately hinge on knowing exactly who deposited and withdrew, this single choice has downstream implications for both fraud and audit; if you are still deciding between vendors, our breakdown of how Sumsub’s per-verification pricing compares with Onfido, Jumio, and Veriff covers how each handles applicant identity and re-use.

Step 6: Generate Short-Lived WebSDK Access Tokens

The browser must never see your app token or secret. Instead, your backend mints a short-lived access token scoped to one user and one level, and hands it to the front end. Use the modern SDK endpoint POST /resources/accessTokens/sdk; a TTL of 600 seconds (10 minutes) is a sensible default because the SDK will request a fresh token through the expiration handler.

// src/access-token.ts
import { sumsubRequest } from './sumsub-client.js';

interface AccessToken { token: string; userId: string; }

export function createAccessToken(userId: string, levelName: string, ttlInSecs = 600) {
  return sumsubRequest<AccessToken>('POST', '/resources/accessTokens/sdk', {
    userId,
    levelName,
    ttlInSecs,
  });
}

Expose it behind an authenticated route. The player must already be logged in to your platform; the KYC token is issued for that session, never anonymously.

// src/token-route.ts
import { Router } from 'express';
import { createAccessToken } from './access-token.js';

export const tokenRouter = Router();

tokenRouter.post('/api/kyc/token', async (req, res) => {
  const player = req.session?.player;              // your auth middleware
  if (!player) return res.status(401).json({ error: 'unauthenticated' });

  const level = player.jurisdiction === 'MGA' ? 'igaming-mga-full' : 'igaming-curacao-basic';
  const { token } = await createAccessToken(player.id, level);
  res.json({ token });
});

Step 7: Embed and Harden the WebSDK 2.6 on the Frontend

With a token endpoint in place, embed the WebSDK. The vanilla builder is loaded from Sumsub’s CDN and initialized with two arguments: the current token and an expiration handler – a callback that returns a fresh token when the current one lapses. Omitting the second argument is why so many flows die with “Access token is invalid or expired” halfway through a liveness check.

<div id="sumsub-websdk-container"></div>
<script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script>
<script>
  async function getToken() {
    const r = await fetch('/api/kyc/token', { method: 'POST', credentials: 'include' });
    return (await r.json()).token;
  }

  getToken().then((token) => {
    window.snsWebSdk
      .init(token, () => getToken())            // expirationHandler: refreshes the token
      .withConf({ lang: 'en', theme: 'dark' })
      .withOptions({ addViewportTag: false, adaptIframeHeight: true })
      .on('idCheck.onApplicantSubmitted', () => console.log('documents submitted'))
      .on('idCheck.onApplicantStatusChanged', (p) => console.log('ui status', p))
      .on('idCheck.onError', (e) => console.error('sdk error', e))
      .build()
      .launch('#sumsub-websdk-container');
  });
</script>

The React path with @sumsub/websdk-react 2.6.3

If your front end is a React SPA, the wrapper component is cleaner. Pass the token and the same refresh function as expirationHandler.

import SumsubWebSdk from '@sumsub/websdk-react';

export function KycGate({ token, refresh }: { token: string; refresh: () => Promise<string> }) {
  return (
    <SumsubWebSdk
      accessToken={token}
      expirationHandler={refresh}
      config={{ lang: 'en' }}
      options={{ addViewportTag: false }}
      onMessage={(type, payload) => console.log(type, payload)}
      onError={(e) => console.error(e)}
    />
  );
}

Never grant access from a front-end event

It is tempting to call your “unlock deposits” endpoint inside onApplicantStatusChanged. Do not. That event fires on UI transitions and carries no cryptographic proof. A user with dev tools can emit it at will. Deposits, withdrawals, and bonus eligibility are unlocked in exactly one place – the webhook handler in Step 8 – after a verified signature and a GREEN answer.

Step 8: The Webhook Endpoint – Your Source of Truth

Sumsub delivers verdicts by POSTing JSON to a URL you register. Each request carries two headers you must honor: x-payload-digest (a hex HMAC of the raw body) and x-payload-digest-alg (the algorithm – one of HMAC_SHA1_HEX, HMAC_SHA256_HEX, or HMAC_SHA512_HEX). Verify the signature against the raw request bytes using your webhook secret before you trust a single field.

// src/webhooks.ts
import crypto from 'node:crypto';
import express, { type Request, type Response } from 'express';
import { config } from './config.js';
import { routeEvent } from './kyc-state.js';

const ALGO: Record<string, string> = {
  HMAC_SHA1_HEX: 'sha1',
  HMAC_SHA256_HEX: 'sha256',
  HMAC_SHA512_HEX: 'sha512',
};

export const webhookRouter = express.Router();

// Capture the RAW body – the signature is computed over exact bytes, not parsed JSON.
webhookRouter.post(
  '/webhooks/sumsub',
  express.raw({ type: '*/*' }),
  (req: Request, res: Response) => {
    const algName = String(req.header('x-payload-digest-alg') ?? 'HMAC_SHA256_HEX');
    const algo = ALGO[algName];
    const received = req.header('x-payload-digest') ?? '';
    if (!algo) return res.status(400).send('unknown digest alg');

    const expected = crypto
      .createHmac(algo, config.SUMSUB_WEBHOOK_SECRET)
      .update(req.body)                 // Buffer from express.raw
      .digest('hex');

    const ok =
      received.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
    if (!ok) return res.status(401).send('bad signature');

    const event = JSON.parse(req.body.toString('utf8'));
    res.status(200).send('ok');         // ACK fast – Sumsub retries on non-2xx
    void routeEvent(event);             // process asynchronously
  },
);

Verify against raw bytes, in constant time

Two safeguards live in that handler. First, express.raw is scoped to this route only – if you register express.json() globally it will consume the stream, leave req.body parsed, and your digest will never match. Second, we compare with crypto.timingSafeEqual after a length check, closing a timing side channel that a naive === leaves open. Because a forged “GREEN” webhook would let an attacker mark themselves verified and bypass KYC entirely, this verification is not optional hardening – it is the security boundary of your entire compliance stack.

Step 9: Idempotent Event Handling

Sumsub retries webhooks that do not receive a prompt 2xx, and networks occasionally duplicate deliveries. If your handler credits a welcome bonus or flips an account on every delivery, a retry becomes a double payout. Deduplicate on a stable identifier – the correlationId (or the applicant plus event type plus createdAtMs) – and record processed events. For broader market context, see our coverage of best sweepstakes casino sites.

// src/idempotency.ts
const processed = new Set<string>();   // swap for Redis/Postgres in production

export async function alreadyProcessed(id: string): Promise<boolean> {
  return processed.has(id);
}
export async function markProcessed(id: string): Promise<void> {
  processed.add(id);
}

In production, back this with a unique constraint in your database (an INSERT ... ON CONFLICT DO NOTHING against a processed_events table) so idempotency survives restarts and horizontal scaling across multiple Node.js instances behind a load balancer.

Step 10: Model Applicant State from the reviewResult

The applicantReviewed event carries a reviewResult object. Three fields drive your account-state machine: reviewAnswer (GREEN = approved, RED = rejected), reviewRejectType (RETRY = the player can resubmit, FINAL = permanent), and rejectLabels (machine-readable reasons such as FORGERY, SELFIE_MISMATCH, or BLOCKLIST). Collapsing RETRY and FINAL into a single “rejected” bucket either locks out fixable users or lets final-rejected fraudsters loop forever.

// src/kyc-state.ts
import { alreadyProcessed, markProcessed } from './idempotency.js';
import { setPlayerKyc } from './players.js';   // your DB layer

export async function routeEvent(event: any): Promise<void> {
  switch (event.type) {
    case 'applicantPending':  return onPending(event);
    case 'applicantReviewed': return onReviewed(event);
    default:                  return;           // log & ignore the rest
  }
}

async function onPending(event: any) {
  await setPlayerKyc(event.externalUserId, 'PENDING');
}

async function onReviewed(event: any) {
  if (await alreadyProcessed(event.correlationId)) return;   // idempotency guard
  const { reviewAnswer, reviewRejectType, rejectLabels } = event.reviewResult;

  if (reviewAnswer === 'GREEN') {
    await setPlayerKyc(event.externalUserId, 'VERIFIED');     // unlock deposits/withdrawals
  } else if (reviewRejectType === 'RETRY') {
    await setPlayerKyc(event.externalUserId, 'RESUBMIT_REQUIRED', rejectLabels);
  } else {
    await setPlayerKyc(event.externalUserId, 'REJECTED', rejectLabels);   // FINAL
  }
  await markProcessed(event.correlationId);
}

Webhook events that matter for gambling

Event type Meaning Your action
applicantCreated Applicant profile created Store applicant ID against the player
applicantPending Docs submitted, under review Set state PENDING; keep deposits gated
applicantReviewed Final verdict with reviewResult GREEN → verify; RED → RETRY or REJECT
applicantOnHold Manual review / temporary hold Freeze withdrawals; notify compliance
applicantActionPending Action flow (e.g. PoA, payment) Await applicantActionReviewed
applicantReset Verification reset by operator Revert to UNVERIFIED, re-trigger flow
applicantLevelChanged Moved to a different level Re-evaluate required evidence

Step 11: Reconciliation – Surviving Missed Webhooks

Webhooks are reliable, not infallible. A deploy that returns 502 for ninety seconds, an expired TLS certificate, or a firewall rule can cause Sumsub’s retries to eventually give up. Any operator running for months will accumulate a handful of players stuck in PENDING whose real status is GREEN. A reconciliation job closes that gap by pulling the authoritative status directly.

// src/reconcile.ts
import { sumsubRequest } from './sumsub-client.js';
import { routeEvent } from './kyc-state.js';

export async function reconcile(externalUserId: string) {
  const applicant = await sumsubRequest<{ id: string }>(
    'GET',
    `/resources/applicants/-/one?externalUserId=${encodeURIComponent(externalUserId)}`,
  );
  const status = await sumsubRequest<any>(
    'GET',
    `/resources/applicants/${applicant.id}/status`,
  );

  // If Sumsub says completed but our DB is still pending, replay the verdict.
  if (status.reviewStatus === 'completed' && status.reviewResult) {
    await routeEvent({
      type: 'applicantReviewed',
      externalUserId,
      correlationId: `reconcile:${applicant.id}:${status.reviewResult.reviewAnswer}`,
      reviewResult: status.reviewResult,
    });
  }
  return status;
}

Run this on a schedule (a cron worker every 15–30 minutes) over players whose state has been PENDING longer than, say, an hour. Because the same routeEvent path handles both live webhooks and reconciliation replays, your idempotency guard prevents double-processing when a delayed webhook and the reconciler race. This closed loop – webhooks for latency, polling for durability – is what separates a demo from a system a regulator will accept.

Step 12: AML Screening, Ongoing Monitoring, and the Travel Rule

KYC is the entry gate; AML is the standing obligation. Sumsub runs PEP, sanctions, and adverse-media screening as part of a verification level, and offers ongoing monitoring that re-screens your player base against updated watchlists and pushes a webhook when a previously-clear player later appears on a list. Since an April 2026 partnership, that screening layer runs on ComplyAdvantage’s Mesh engine, which Sumsub now plugs in across all three of its product lines – KYC, KYB, and Transaction Monitoring – rather than a bolted-on gambling-only check. For gambling operators this matters because a customer clean at signup can become a sanctions match months later, and your licence requires you to act on that.

Ongoing monitoring hits arrive as review events on the existing applicant – the same onReviewed handler applies, but a change from GREEN to RED on an already-verified player should trigger a withdrawal freeze and a compliance alert rather than a simple lockout. If you are moving crypto or need value-transfer reporting, the same Sumsub applicant graph feeds Travel Rule messaging through the Travel Rule SDK Sumsub first shipped in June 2025, which in May 2026 gained a Travel Rule Self-Service Setup tier for configuring counterparty VASP checks without a dedicated integration project; Sumsub also announced a cross-chain identity integration with Chainlink in April 2026 to extend that same on-chain compliance graph to smart-contract-level checks. We walk through wiring the AML side of this into a real-time AML transaction-monitoring pipeline in a companion build. Whichever regulator you answer to, aligning your level configuration with the 2026 MGA and UKGC casino API audit requirements before go-live saves an expensive re-integration later.

Operationally, treat AML as a stream, not a gate. Configure ongoing monitoring on every gambling level so that sanctions and PEP list updates re-screen your entire verified base automatically, and route the resulting webhooks through the same idempotent handler you built in Step 9. Persist the specific rejectLabels that triggered any freeze – a compliance officer preparing a suspicious-activity report needs to know whether the hit was a sanctions match, adverse media, or a PEP designation, and reconstructing that after the fact from the dashboard alone is slow and error-prone.

Re-verification and reusable KYC

Players are re-verified for many reasons – a licence upgrade, a large-withdrawal source-of-funds check, or expired documents. Rather than creating a new applicant, reset or upgrade the existing one so the identity history stays intact. Sumsub’s reusable-KYC features let a verified applicant be shared or re-checked without forcing the player through the entire flow again, which materially improves conversion on returning customers; in May 2026 Sumsub updated the Reusable KYC service and its Reuse Applicant API/SDK to support full data reuse, adding two new webhooks for share-token events that you can route through the same idempotent handler from Step 9. Note that as of August 2026 the older Reusable KYC via API path was marked legacy and moved to maintenance mode with no further feature development, so new integrations should build on the current Reuse Applicant API/SDK rather than that path.

Step 13: Sandbox Simulation

Never test against production. Generate an sbx: app token and point SUMSUB_BASE_URL at the same host – the prefix routes you to the sandbox automatically. A minimal smoke test proves the whole chain end-to-end: create an applicant, mint a token, and confirm the signer works.

// src/smoke-test.ts
import { getOrCreateApplicant } from './applicants.js';
import { createAccessToken } from './access-token.js';

async function main() {
  const userId = 'player-test-0001';
  const applicant = await getOrCreateApplicant(userId, 'MGA', '[email protected]');
  console.log('applicant:', applicant.id);

  const { token } = await createAccessToken(userId, 'igaming-mga-full');
  console.log('access token (first 12):', token.slice(0, 12));
}

main().catch((e) => { console.error(e); process.exit(1); });

Run it with npx tsx src/smoke-test.ts. Expected output resembles:

applicant: 665f2a1c9b4e3d0012ab34cd
access token (first 12): _act-sbx-eyJ

To drive a verdict, complete the WebSDK flow with Sumsub’s provided test images, then open the applicant in the dashboard’s testing panel and force Approved or Rejected. That action fires a real applicantReviewed webhook to your endpoint, letting you exercise both the GREEN and RED/RETRY branches without any real documents. For broader market context, see our coverage of leading online casino sites.

Step 14: The Production Go-Live Gate

Promotion to production is a checklist, not a config flip. Each item below has burned an operator who skipped it.

  1. Swap sbx: for prod: tokens and rotate the sandbox secret out of every environment.
  2. Register the production webhook URL over HTTPS with a valid certificate; sandbox webhook secrets differ from production ones.
  3. Confirm each dashboard level name referenced in code exists in the production account – levels do not copy across environments automatically.
  4. Enable server clock sync (NTP); a drift beyond a few minutes invalidates every request signature.
  5. Move idempotency and processed-event storage to a shared, durable store before scaling past one instance.
  6. Schedule the reconciliation worker and alert on any player PENDING longer than a defined SLA.
  7. Load-test the webhook endpoint; it must ACK within Sumsub’s timeout even under deposit-time traffic spikes.

7 Pitfalls That Break iGaming KYC Integrations in Node.js

These are the failures that reach production because they pass a happy-path demo and only surface under real traffic.

1. Trusting a front-end event as a verdict

Granting deposit access inside onApplicantStatusChanged means any user with browser dev tools can self-verify. Verdicts come only from a signature-verified applicantReviewed webhook.

2. Signing a different body than you send

If you serialize the payload twice – once to sign, once to transmit – key ordering or whitespace can diverge and every call returns 401. Serialize once, reuse the exact string.

3. A global JSON parser eating the raw webhook body

Register express.json() app-wide and the webhook stream is consumed before your handler sees it, so the digest never matches. Scope express.raw to the webhook route only.

4. Using a mutable externalUserId

Keying on email or username spawns duplicate applicants when the player edits their profile, detaching verified status from the account. Use an immutable internal ID.

5. Flattening RETRY and FINAL rejections

Treating every RED the same either permanently blocks users who merely uploaded a blurry photo or lets final-rejected accounts retry indefinitely. Branch on reviewRejectType.

6. Skipping webhook signature verification

Accepting webhook bodies without checking x-payload-digest means anyone who discovers your endpoint URL can POST a forged GREEN and verify themselves. Verify every payload against the raw bytes with the webhook secret, in constant time – this is the security boundary of your compliance stack, not optional polish.

7. Polling instead of listening

Replacing webhooks with a tight status-poll loop invites 429 rate limits and still misses the real-time signal, because a poll that runs every ten seconds is both too frequent for Sumsub and too slow for a player waiting to deposit. Listen for webhooks as the primary path and reserve polling for scheduled reconciliation only.

Troubleshooting: 8 Common Sumsub Errors and Fixes

Symptom Likely cause Fix
401 “app token or signature invalid” Clock skew, wrong secret, or body/signature mismatch NTP-sync the server; sign the exact bytes sent; confirm secret key
403 on /accessTokens/sdk Level name does not exist or is unpublished Match the dashboard level name exactly, in the same environment
WebSDK “Access token is invalid or expired” TTL too short or no expiration handler Pass a refresh callback as the second init argument
Webhook signature always fails Global JSON parser, or API secret used instead of webhook secret Use raw body + the dedicated webhook secret key
Duplicate applicants for one player Mutable externalUserId Use immutable ID + getOrCreateApplicant
Player stuck PENDING indefinitely A webhook delivery was missed Run the reconciliation job; replay the verdict
No applicantReviewed in sandbox Review not triggered Force Approved/Rejected in the dashboard testing panel
WebSDK iframe blocked Content-Security-Policy too strict Allow static.sumsub.com and api.sumsub.com in CSP frame/connect-src

Beyond the Standard Coverage: What the Vendor Docs and Competitors Skip

Reviewing the top-ranking material for this topic – Sumsub’s own get-started reference, the sdk.finance integration write-up, and assorted quickstarts – reveals a consistent shape. They cover credential creation, a single applicant, embedding the SDK, and reading a GREEN result. That is the happy path, and it is where the public guidance ends. The operational reality of running KYC under a gambling licence lives in the gaps those documents leave open.

The gaps this build fills

  • Idempotency and retries. No quickstart addresses duplicate webhook deliveries, yet in iGaming a double-processed event can mean a double bonus or a double withdrawal unlock.
  • Reconciliation. The docs assume webhooks always arrive. Production doesn’t. A polling backstop over GET /resources/applicants/-/one is what keeps status accurate across outages.
  • Jurisdiction routing. Vendor examples use one level. Operators licensed in Malta, the UK, and Curaçao need level selection wired to the player’s regulatory context.
  • The RETRY/FINAL distinction. Rarely explained, it is the difference between a fixable rejection and a permanent one – and between a frustrated real customer and an unblockable fraudster.
  • Signature attack surface. Constant-time comparison and raw-body verification are treated as afterthoughts, though a forged webhook is a direct KYC bypass.

Fraud pressure makes these gaps expensive: Sumsub’s 2026 iGaming fraud research reported suspicious-transaction volume up 4.5x as attackers moved deeper into platforms. An integration that only handles the happy path is an integration that hands those attackers the edge cases. Operators layering this KYC flow alongside geolocation and payment controls will find the same event-driven discipline pays off across the stack – from GeoComply-style location assurance to card processing.

Advanced Tips and the Complete Project Layout

A few refinements separate a solid integration from a resilient one.

  • Queue the heavy work. Have the webhook handler ACK immediately and push the event onto a durable queue (BullMQ, SQS). Processing – DB writes, notifications, freezes – happens in a worker, so a slow database never causes Sumsub retries.
  • Persist every raw webhook. Store the raw body and headers before parsing. When compliance asks why an account unlocked at 02:14, you have the signed evidence.
  • Alert on RED spikes. A sudden rise in FORGERY or SELFIE_MISMATCH labels is an early fraud-wave signal worth paging on.
  • Cache access tokens per session only within their TTL; never cache across users.

The finished project has a small, legible structure:

sumsub-igaming-kyc/
├─ .env
├─ tsconfig.json
├─ src/
│  ├─ config.ts          # zod-validated env
│  ├─ sumsub-client.ts   # HMAC signer + fetch wrapper
│  ├─ applicants.ts      # create / getOrCreate + level routing
│  ├─ access-token.ts    # WebSDK token minting
│  ├─ token-route.ts     # POST /api/kyc/token
│  ├─ webhooks.ts        # signature-verified webhook router
│  ├─ idempotency.ts     # processed-event guard
│  ├─ kyc-state.ts       # routeEvent + reviewResult state machine
│  ├─ reconcile.ts       # missed-webhook backstop
│  ├─ players.ts         # your DB layer (setPlayerKyc)
│  └─ index.ts           # wires routers, starts Express
└─ package.json

The entry point simply mounts the routers, keeping the raw parser scoped to the webhook path:

// src/index.ts
import express from 'express';
import { config } from './config.js';
import { tokenRouter } from './token-route.js';
import { webhookRouter } from './webhooks.js';

const app = express();
app.use(webhookRouter);              // raw parser scoped inside this router
app.use(express.json());             // JSON for everything else, mounted AFTER
app.use(tokenRouter);

app.listen(config.PORT, () => console.log(`KYC service on :${config.PORT}`));

Frequently Asked Questions

Do I need a separate Sumsub level for each jurisdiction?

In practice, yes. MGA, UKGC, and Curaçao licences demand different documents, liveness settings, and source-of-funds triggers. Create one level per regulatory context in the dashboard and route players to the right one at applicant creation, as shown in Step 5.

How much does Sumsub charge per verification?

Sumsub uses volume-based, custom pricing rather than a fixed public list price; as of July 2026, the publicly reported entry rate sits around $1.35 per check, and Sumsub offers a 14-day, 50-check sandbox trial for teams evaluating the KYC API before committing to a plan. For a like-for-like breakdown against Onfido, Jumio, and Veriff, see our dedicated vendor comparison; always confirm current figures directly with Sumsub sales.

Can I run KYC entirely server-side without the WebSDK?

Yes. You can create an applicant and upload documents through the API (POST /resources/applicants/{id}/info/idDoc) and request a check. The WebSDK exists because it handles camera capture, liveness, and document guidance far better than a hand-rolled UI, and it keeps sensitive capture off your servers.

How long does a Sumsub verification take?

Automated decisions on a clean document commonly complete in seconds to a couple of minutes; cases escalated to manual review take longer. Because timing is variable, your architecture must be asynchronous – which is exactly why the webhook, not a synchronous poll, is the source of truth.

What is the difference between sandbox and production tokens?

The app token prefix determines the environment: sbx: for sandbox, prod: for production. Both use the https://api.sumsub.com host. Levels, applicants, and webhook secrets do not carry over between environments, so re-create and re-register them at go-live.

How do I handle re-verification or reusable KYC?

Reset or upgrade the existing applicant rather than creating a new one, preserving identity history. Sumsub’s reusable-KYC capabilities let a verified applicant be re-checked or shared without repeating the full flow, which lifts conversion on returning players and large-withdrawal source-of-funds checks.

Which webhook events actually matter for a gambling platform?

At minimum: applicantPending (gate deposits), applicantReviewed (the verdict that unlocks money movement), applicantOnHold (freeze withdrawals), and applicantReset (revert to unverified). Ongoing-monitoring hits also arrive as review events and can flip a previously verified player to RED.

How does this keep me compliant with MGA and UKGC?

Sumsub provides the identity, liveness, and AML screening; your integration provides the auditable trail – signed webhooks, persisted raw payloads, and a deterministic state machine showing exactly when each permission changed. Regulators care about that provenance as much as the verdict itself, which is why the event-driven design in this guide is a compliance asset, not just an engineering preference.

Conclusion

A durable Sumsub integration is less about the API calls and more about respecting that verification is asynchronous. Sign the exact bytes you send, mint short-lived tokens for the browser, treat the WebSDK as UX and the signature-verified webhook as truth, make event processing idempotent, and back it all with reconciliation. Build on those foundations with Node.js 24, TypeScript 6, and the WebSDK 2.6 train, and you have a KYC backend that stays correct when the network doesn’t – and that stands up when a regulator asks you to prove it.

Casey Mitchell

Casey Mitchell

Crypto Casino Editor

Casey Mitchell tracks crypto casinos, on-chain gaming, provably fair RNG, and the regulatory gray zones around US-facing crypto gambling platforms. Reviews wallets, deposit flows, and KYC policies across BTC, ETH, and stablecoin operators.

View all articles