Jumio NetVerify React SPA: 12-Step Vite KYC [2026]

Jumio NetVerify React integrations split into two camps in 2026: server-rendered Next.js apps and pure client-side single-page apps. This tutorial covers the second camp end to end – a Vite 8 + React 19 single-page iGaming frontend that talks to a small, decoupled Express 5 backend. You will mint Jumio’s two-token KYX session server-side, drop the @jumio/websdk 0.5.0 web component into a React hook, listen to its lifecycle events, receive the authoritative callback, and gate real-money deposits on a server-verified decision. Every version number, endpoint, and code sample below was checked against the npm registry and Jumio’s developer documentation on June 30, 2026.

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 a Vite SPA Changes the Jumio NetVerify Integration in 2026

Most published walkthroughs assume a full-stack React framework with server components and route handlers in the same repository. A large slice of the iGaming industry does not run that way. Sportsbooks and casino frontends are frequently shipped as static single-page apps on a CDN, with a separate Node, Go, or Java API behind a load balancer. That decoupling is deliberate: the static bundle is cheap to cache globally, the regulated backend lives inside a hardened VPC, and the two scale independently. When you bolt Jumio onto that topology, the integration questions are different from the server-rendered case.

The biggest difference is that a Vite SPA has no server runtime of its own. There is nowhere in import.meta.env to hide your Jumio API secret, no route handler to proxy a token mint, and no server callback endpoint living next to your pages. You must therefore treat the React bundle as fully untrusted and push every privileged operation – OAuth2 token minting, account creation, callback ingestion, and the final retrieval call – onto a backend you control. The SPA only ever sees a short-lived, single-use SDK token.

How This Differs From the Next.js Path

If your stack is Next.js App Router, the server-component model lets you mint tokens inside a route handler and stream the SDK token to a client component. That approach has its own pitfalls around SSR and hydration. We cover it separately; this guide is the SPA-native counterpart, and the two are designed to be read together depending on your architecture.

What You Will Build

By the end you will have a working useJumioVerification hook, a <JumioVerification /> component that mounts the official web component, an Express token-and-callback service, and a deposit gate that refuses to release the cashier until Jumio returns a PASSED decision. The complete project tree is included near the end so you can clone the shape directly.

Who This Is For

This guide assumes you are a frontend or full-stack engineer shipping a regulated real-money product and that you already have user authentication in place. You do not need prior Jumio experience, but you should be comfortable with React hooks, the Fetch API, and basic Express routing. If your platform is a server-rendered framework rather than a static SPA, the token-minting and callback concepts transfer directly even though the file layout differs. Everything here targets the 2026 toolchain – React 19.2, Vite 8, Express 5, and @jumio/websdk 0.5.0 – so you are building on current, supported releases rather than legacy NetVerify v4 iframe patterns.

Prerequisites and Exact Versions (June 2026)

Pin these versions. The @jumio/websdk package moves quickly – 0.5.0 shipped on June 24, 2026, only days before this guide – and mismatched React or Vite majors are the single most common cause of a blank verification screen. The table below reflects the latest releases on the npm registry as of June 30, 2026.

Tool / Package Pinned version Released Why this version
Node.js 24.x LTS (min 22.12) Oct 2025 LTS Vite 8 requires Node 20.19+ or 22.12+; global fetch is built in
npm 11.x 2026 Ships with Node 24
vite 8.1.0 2026-06-23 Rolldown bundler, faster cold start for SPA dev
@vitejs/plugin-react 6.0.3 2026-06-23 React 19 Fast Refresh support
react / react-dom 19.2.7 2026-06-01 Stable Actions, use hook, ref-as-prop
@jumio/websdk 0.5.0 2026-06-24 Current web component, <jumio-sdk> custom element
express 5.2.1 2025-12-01 Async error propagation, modern routing
swr 2.3.x 2026 Polling the deposit gate
zod 4.x 2026 Validating callback payloads

Jumio Account Requirements

You need a Jumio KYX customer account and credentials issued in the Customer Portal at customer-portal.netverify.com. Specifically you must have an OAuth2 client_id and client_secret for the API, at least one configured workflow definition (a numeric key that describes which checks run – ID, similarity/selfie, watchlist screening), and a registered callback URL pointing at your backend. Jumio’s commercial terms in 2026 are custom enterprise pricing in the roughly $3–$5 per verification range with no free tier, so do all of your iteration in the sandbox datacenter before you spend a cent.

Local Toolchain

Confirm your toolchain before writing a line of code. Run node -v and expect v24.x. The web component is distributed as ESM only, so a modern bundler is mandatory; Vite 8 handles this without configuration. You will also want a public HTTPS tunnel (such as a reverse proxy or an ngrok-style service) because Jumio’s callback must reach your local backend over TLS during testing.

The Two-Token KYX Flow Explained

Jumio’s KYX platform – the modern successor to the original NetVerify suite – uses a two-token model, and understanding it is the difference between a five-minute integration and a five-day one. There are two distinct secrets, and they must never be confused.

The first is your API credential (the OAuth2 client_id and client_secret). It is long-lived, all-powerful, and must live only on your backend. The second is the SDK token, a short-lived, single-use bearer that authorizes exactly one verification session in the browser. Your backend exchanges the API credential for an OAuth2 access token, uses that to create an account and workflow execution, and receives the SDK token in return. Only the SDK token ever crosses the network boundary to React.

Datacenters and the dc Attribute

The web component is pinned to a datacenter through its dc attribute, which must match the region where your account was provisioned. Sending a US token to the EU datacenter returns an opaque initialization failure, so get this right early.

Region dc value OAuth2 / account API host Typical use
Americas us auth.amer-1.jumio.ai / account.amer-1.jumio.ai US state-regulated sportsbooks, casinos
Europe eu auth.emea-1.jumio.ai / account.emea-1.jumio.ai MGA, UKGC, EU licensees
Singapore sgp auth.apac-1.jumio.ai / account.apac-1.jumio.ai APAC operators

The Authoritative Result Lives on the Server

This is the rule iGaming compliance teams care about most: the browser is not allowed to decide whether a player passed. The SDK’s client-side events tell your UI when the user finished or hit an error, but the legally meaningful decision arrives at your backend through Jumio’s server-to-server callback. You then call the retrieval API to read the verified data. Build your deposit gate around the callback, never around a client event, and you will sleep through audits.

Token Lifetime and Expiry

The OAuth2 access token your backend receives lives roughly one hour; cache it in memory and refresh on a 401 rather than minting one per request. The SDK token is the opposite – it is single-use and expires within minutes, so mint it only when the player is about to verify and never store it. If a user abandons the flow and returns later, mint a fresh SDK token rather than reusing a stale one, which would fail initialization and produce the dreaded blank panel. This short lifetime is a security feature: even if a token leaks from the network tab, the window to abuse it is tiny and it cannot be replayed.

Step 1 – Scaffold the Vite 8 + React 19 SPA

Create the frontend with the official Vite scaffolder. We use the react template (not react-ts swc unless you want it) and immediately pin React 19.2.7.

npm create vite@8 jumio-igaming-spa -- --template react
cd jumio-igaming-spa
npm install [email protected] [email protected]
npm install @jumio/[email protected] [email protected]
npm install -D @vitejs/[email protected] [email protected]

Expected output ends with a clean dependency tree and no peer warnings. The @jumio/websdk package declares no runtime dependencies – it is fully self-contained – so the install is fast.

Configure Vite for the Web Component

The web component registers a custom element, <jumio-sdk>, which React 19 supports natively (React 19 finally passes unknown props straight through to custom elements). No special Vite config is required, but set a stable dev port so your backend CORS allowlist is predictable.

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: { port: 5173, strictPort: true },
  build: { target: 'es2022', sourcemap: false },
})

Verify the Dev Server

Run npm run dev and open http://localhost:5173. You should see the Vite starter. Leave this running in one terminal; the backend gets its own.

Step 2 – Stand Up the Decoupled Express 5 Backend

Create a sibling folder for the API. Keeping it in a separate package makes the trust boundary obvious and lets you deploy the static SPA and the regulated API independently.

mkdir jumio-igaming-api && cd jumio-igaming-api
npm init -y
npm install [email protected] [email protected] helmet@8 zod@4 dotenv@17

Express 5 propagates errors from async handlers automatically, which removes a whole class of unhandled-rejection bugs you would otherwise hit in the token-minting path.

Minimal Server Skeleton

// server.js
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'

const app = express()
app.use(helmet())
app.use(express.json({ limit: '1mb' }))
app.use(cors({ origin: process.env.SPA_ORIGIN, credentials: true }))

app.get('/healthz', (_req, res) => res.json({ ok: true }))

const port = process.env.PORT || 8080
app.listen(port, () => console.log(`API on :${port}`))

Why Not Just Use the SPA?

A reasonable objection: why not call Jumio directly from React with a proxy? Because every privileged call needs the client_secret, and anything in your bundle is one DevTools tab away from the public. The Express service is small on purpose – three routes – but it is the only place your secret exists.

Step 3 – Store Credentials and Configure CORS + TLS

Secrets belong in the backend environment, never in the SPA. Create the backend .env with your Jumio API credentials and region.

# jumio-igaming-api/.env
JUMIO_CLIENT_ID=your_oauth2_client_id
JUMIO_CLIENT_SECRET=your_oauth2_client_secret
JUMIO_DC=us                 # us | eu | sgp
JUMIO_AUTH_HOST=auth.amer-1.jumio.ai
JUMIO_ACCOUNT_HOST=account.amer-1.jumio.ai
JUMIO_CALLBACK_SECRET=long_random_value_for_hmac
SPA_ORIGIN=http://localhost:5173
PORT=8080

The frontend gets a separate, deliberately boring .env that contains only the public backend URL. Vite exposes variables prefixed with VITE_ to client code, so anything sensitive must not carry that prefix.

# jumio-igaming-spa/.env
VITE_API_BASE=http://localhost:8080

TLS Is Non-Negotiable

Jumio requires TLS 1.3 for both the SDK and the callback in 2026, and browsers will not grant camera access on insecure origins anyway. In production, terminate TLS at your load balancer and forward to Express over the private network. Locally, front the backend with an HTTPS tunnel so the callback can reach you.

CORS Allowlist Hygiene

Set SPA_ORIGIN to an exact origin, never *. The SDK session endpoint hands out tokens that cost money to consume; an open CORS policy is an invitation to drain your verification budget. When you add staging and production domains, allowlist each explicitly.

Step 4 – Mint the Account and SDK Token (Server Only)

This is the heart of the integration. Two calls happen back to back: an OAuth2 client-credentials grant to get an access token, then an account-and-workflow creation call that returns the SDK token.

// jumio.js
const { JUMIO_CLIENT_ID, JUMIO_CLIENT_SECRET,
        JUMIO_AUTH_HOST, JUMIO_ACCOUNT_HOST, JUMIO_DC } = process.env

export async function getAccessToken() {
  const basic = Buffer
    .from(`${JUMIO_CLIENT_ID}:${JUMIO_CLIENT_SECRET}`)
    .toString('base64')
  const res = await fetch(`https://${JUMIO_AUTH_HOST}/oauth2/token`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${basic}`,
      'Content-Type': 'application/x-www-form-urlencoded',
      'User-Agent': 'AcmeCasino/1.0.0',          // Jumio mandates a named UA
    },
    body: 'grant_type=client_credentials',
  })
  if (!res.ok) throw new Error(`OAuth2 failed: ${res.status}`)
  const json = await res.json()
  return json.access_token                        // valid ~3600s
}

export async function createSession(userRef) {
  const accessToken = await getAccessToken()
  const res = await fetch(`https://${JUMIO_ACCOUNT_HOST}/api/v1/accounts`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
      'User-Agent': 'AcmeCasino/1.0.0',
    },
    body: JSON.stringify({
      customerInternalReference: userRef,         // your player id
      workflowDefinition: { key: 10013 },         // ID + similarity/selfie
      userReference: userRef,
    }),
  })
  if (!res.ok) throw new Error(`Account create failed: ${res.status}`)
  const data = await res.json()
  return {
    sdkToken: data.sdk?.token ?? data.web?.token, // the single-use SDK token
    accountId: data.account?.id,
    workflowExecutionId: data.workflowExecution?.id,
    dc: JUMIO_DC,
  }
}

Workflow definition 10013 is a common iGaming choice – government ID plus a selfie/similarity (liveness) check – but the exact key comes from your own Customer Portal configuration. Swap in a watchlist-screening workflow if your license requires AML screening at onboarding.

Expected Token Response

{
  "account":   { "id": "11111111-2222-3333-4444-555555555555" },
  "workflowExecution": { "id": "66666666-7777-8888-9999-000000000000" },
  "web": { "href": "https://us.web.jumio.ai/...", "token": "eyJhbGci..." }
}

The token is what React needs. It is single-use and expires fast (minutes), so mint it on demand, never ahead of time.

Step 5 – Expose a Session Endpoint to the SPA

Wrap createSession in an authenticated route. Critically, the player must already be logged in to your platform before you spend a verification – derive the user reference from your own session, not from the request body.

// add to server.js
import { createSession } from './jumio.js'
import { requireAuth } from './auth.js'   // your existing session middleware

app.post('/api/kyc/session', requireAuth, async (req, res) => {
  const userRef = req.user.id              // trusted, from your session
  const session = await createSession(userRef)
  res.json({ sdkToken: session.sdkToken, dc: session.dc })
})

Notice the response contains only the SDK token and the datacenter – never the account id, the access token, or anything that would let a client poke the Jumio API directly. This minimal surface is what keeps the SPA boundary clean.

Rate-Limit the Mint

Because each call can incur a real charge, put a per-user rate limit in front of this route. A simple in-memory or Redis counter that caps a player to, say, five session mints per hour blunts both accidental retry storms and deliberate abuse. iGaming operators running large-scale onboarding pipelines should also wire alerting on the mint rate; for teams still deciding which provider to standardize on, our side-by-side KYC vendor comparison breaks down per-verification economics across Jumio, Sumsub, Onfido, and Veriff.

Idempotency

If a player refreshes mid-flow, you do not want a fresh charge each time. Cache the most recent un-consumed session per user for a short window and return it on retry, only minting anew once the prior workflow execution reaches a terminal state.

Step 6 – Install and Lazy-Load the @jumio/websdk Web Component

The SDK is ESM and registers the <jumio-sdk> element as a side effect of import. In an SPA you should load it lazily so the heavy verification bundle is not in your initial route’s critical path – most users visit the cashier long before they verify.

// loadJumio.js
let loaded = null
export function loadJumioSdk() {
  if (!loaded) {
    loaded = import('@jumio/websdk')   // registers <jumio-sdk> once
  }
  return loaded
}

Calling loadJumioSdk() more than once is safe; the dynamic import is memoized and the custom element is only defined a single time. This pattern also keeps the SDK out of the bundle for users who never reach KYC.

React 19 and Custom Elements

React 19 changed how it handles custom elements: unknown attributes are now set as properties when a matching property exists and as attributes otherwise. That means you can pass dc and token as ordinary JSX attributes and they reach the element correctly – a real pain point removed compared with React 18, where you often needed a ref to set attributes imperatively.

Type Hints for the Element

If you use TypeScript, declare the element so JSX stops complaining. A minimal ambient declaration mapping jumio-sdk to an element accepting dc and token string props is enough; the package also ships its own index2.d.ts types you can lean on. For broader market context, see our coverage of best sweepstakes casino sites.

Step 7 – Build the useJumioVerification Hook and State Machine

Encapsulate the entire lifecycle in one hook so your UI stays declarative. A useReducer state machine models the flow cleanly: idle → minting → ready → running → finished | error. This is the single most reusable piece of the integration.

// useJumioVerification.js
import { useReducer, useCallback, useRef } from 'react'
import { loadJumioSdk } from './loadJumio.js'

const API = import.meta.env.VITE_API_BASE

const init = { status: 'idle', token: null, dc: null, error: null }
function reducer(state, action) {
  switch (action.type) {
    case 'MINT':     return { ...state, status: 'minting', error: null }
    case 'READY':    return { ...state, status: 'ready',
                              token: action.token, dc: action.dc }
    case 'RUNNING':  return { ...state, status: 'running' }
    case 'FINISHED': return { ...state, status: 'finished' }
    case 'ERROR':    return { ...state, status: 'error', error: action.error }
    case 'RESET':    return init
    default:         return state
  }
}

export function useJumioVerification() {
  const [state, dispatch] = useReducer(reducer, init)
  const started = useRef(false)

  const begin = useCallback(async () => {
    if (started.current) return
    started.current = true
    dispatch({ type: 'MINT' })
    try {
      await loadJumioSdk()                       // ensure element is defined
      const res = await fetch(`${API}/api/kyc/session`, {
        method: 'POST', credentials: 'include',
      })
      if (!res.ok) throw new Error(`session ${res.status}`)
      const { sdkToken, dc } = await res.json()
      dispatch({ type: 'READY', token: sdkToken, dc })
    } catch (err) {
      started.current = false
      dispatch({ type: 'ERROR', error: err.message })
    }
  }, [])

  return { state, dispatch, begin }
}

The started ref guards against React 19 Strict Mode double-invocation in development, which would otherwise mint two sessions and burn a verification. This guard is easy to forget and expensive to miss.

Why a Reducer Beats Scattered useState

Verification has genuinely discrete states with illegal transitions – you cannot be both minting and finished. Encoding that in a reducer makes invalid UI states unrepresentable and makes the event wiring in the next step trivial to read.

Step 8 – Render the SDK Element and Wire the workflow:* Events

With a token in hand, render the element and subscribe to its lifecycle events. The web component dispatches DOM CustomEvents as the user moves through capture; you treat them as UX signals only.

// JumioVerification.jsx
import { useEffect, useRef } from 'react'
import { useJumioVerification } from './useJumioVerification.js'

export default function JumioVerification({ onFinished }) {
  const { state, dispatch, begin } = useJumioVerification()
  const hostRef = useRef(null)

  useEffect(() => {
    const el = hostRef.current
    if (!el || state.status !== 'ready') return

    const onLoaded   = () => dispatch({ type: 'RUNNING' })
    const onSuccess  = () => { dispatch({ type: 'FINISHED' }); onFinished?.() }
    const onError    = (e) => dispatch({ type: 'ERROR',
                              error: e.detail?.message ?? 'sdk error' })

    el.addEventListener('workflow:loaded',   onLoaded)
    el.addEventListener('workflow:complete', onSuccess)
    el.addEventListener('workflow:error',    onError)
    return () => {
      el.removeEventListener('workflow:loaded',   onLoaded)
      el.removeEventListener('workflow:complete', onSuccess)
      el.removeEventListener('workflow:error',    onError)
    }
  }, [state.status, dispatch, onFinished])

  if (state.status === 'idle')
    return <button onClick={begin}>Verify my identity</button>
  if (state.status === 'minting')
    return <p>Preparing secure session…</p>
  if (state.status === 'error')
    return <p role="alert">Verification error: {state.error}</p>

  return (
    <jumio-sdk
      ref={hostRef}
      dc={state.dc}
      token={state.token}
      style={{ width: '100%', minHeight: 560 }}
    />
  )
}

The exact event names your tenant emits are listed in the official Jumio WebSDK documentation and the public web-sdk changelog; subscribe defensively and log any event you do not recognize during integration so you can map the full lifecycle for your workflow.

Client Events Are Hints, Not Truth

When workflow:complete fires, the user has finished capturing – that is not the same as passing. Show a friendly “we’re reviewing your documents” state and wait for the server callback before unlocking anything of value.

Cleanup on Unmount

Always remove listeners in the effect’s cleanup. In an SPA the user can navigate away mid-capture, and a leaked listener on a destroyed element is a classic source of memory growth in long-lived casino sessions.

Step 9 – Handle Camera Permissions and Mobile-Web Quirks

The SDK needs the camera, and the browser permission model is unforgiving. In a SPA you control the surrounding shell, so handle the unhappy paths explicitly rather than letting the SDK fail silently.

async function ensureCamera() {
  if (!navigator.mediaDevices?.getUserMedia)
    return { ok: false, reason: 'unsupported' }
  try {
    const stream = await navigator.mediaDevices
      .getUserMedia({ video: { facingMode: 'environment' } })
    stream.getTracks().forEach(t => t.stop())   // release immediately
    return { ok: true }
  } catch (err) {
    return { ok: false, reason: err.name }       // NotAllowedError, etc.
  }
}

Call this before you render the SDK so you can show a clear “allow camera access” explainer when permission is blocked, instead of a frozen panel. Release the probe stream immediately – holding it open conflicts with the SDK acquiring the camera itself.

iOS Safari and the Permissions-Policy Header

On iOS Safari the camera will not start unless the document and any iframe carry the right Permissions-Policy. If you embed the cashier in an iframe (common in white-label casino skins), the parent frame must include camera=(self "https://your-spa.example") or the prompt never appears. This is the most common “works on desktop, dead on iPhone” bug.

Content-Security-Policy

The SDK loads assets and opens connections to Jumio hosts. Your CSP must allow script-src, connect-src, img-src, and media-src for the relevant *.jumio.ai and *.netverify.com origins for your datacenter. Start strict, watch the console for blocked-resource reports, and widen precisely – never fall back to unsafe-inline just to make it work.

Step 10 – Receive the Authoritative Callback on the Backend

Jumio calls your registered callback URL server-to-server when a verification reaches a decision. This is the only source of truth. Validate the payload, then call the retrieval API to read the full result.

// add to server.js
import { z } from 'zod'
import { getAccessToken } from './jumio.js'

const Callback = z.object({
  workflowExecution: z.object({ id: z.string() }),
  account: z.object({ id: z.string() }),
  decision: z.object({ type: z.string() }).optional(),
})

app.post('/api/kyc/callback', async (req, res) => {
  // 1. Acknowledge fast so Jumio does not retry
  res.status(200).end()

  // 2. Validate shape
  const parsed = Callback.safeParse(req.body)
  if (!parsed.success) return console.warn('bad callback', parsed.error)

  const { account, workflowExecution } = parsed.data
  // 3. Pull the authoritative decision from the retrieval API
  const token = await getAccessToken()
  const host = process.env.JUMIO_ACCOUNT_HOST.replace('account', 'retrieval')
  const r = await fetch(
    `https://${host}/api/v1/accounts/${account.id}` +
    `/workflow-executions/${workflowExecution.id}`,
    { headers: { Authorization: `Bearer ${token}`,
                 'User-Agent': 'AcmeCasino/1.0.0' } })
  const result = await r.json()
  await persistDecision(account.id, result)   // your DB write
})

Acknowledge with a 200 immediately, then do the retrieval work – Jumio retries callbacks that do not get a prompt acknowledgement, and a slow handler causes duplicate processing.

Verify the Caller

Lock the callback route to Jumio’s published source IP ranges at your firewall or load balancer, and treat the retrieval call as the real authentication: you fetch the decision from Jumio yourself, so a forged callback body cannot fake a PASSED. Never trust a decision that arrives in the callback body without confirming it via retrieval.

Idempotent Persistence

Key your decision write on the workflowExecution.id so duplicate callbacks are no-ops. Store the decision type (PASSED, REJECTED, WARNING), the timestamp, and a minimized subset of the result – see the compliance section on data minimization.

Step 11 – Gate Deposits on Server-Verified Status

Now connect verification to money. The cashier must ask your backend whether the player is verified; it must never read a client-side flag. Use SWR to poll a status endpoint while the player waits.

// useKycStatus.js
import useSWR from 'swr'
const API = import.meta.env.VITE_API_BASE
const fetcher = (u) => fetch(u, { credentials: 'include' }).then(r => r.json())

export function useKycStatus() {
  const { data } = useSWR(`${API}/api/kyc/status`, fetcher,
    { refreshInterval: 4000 })          // poll until decided
  return data?.status ?? 'pending'      // pending | passed | rejected
}
// DepositButton.jsx
import { useKycStatus } from './useKycStatus.js'
export function DepositButton() {
  const status = useKycStatus()
  if (status !== 'passed')
    return <button disabled>Verify to deposit</button>
  return <button onClick={openCashier}>Deposit</button>
}

The server endpoint reads the persisted decision from Step 10. Even if a determined user flips the React state in DevTools, the deposit API itself re-checks verification server-side before accepting a cent, so the client gate is purely cosmetic – exactly as it should be. For broader market context, see our coverage of leading online casino sites.

Server-Side Enforcement Is the Real Gate

Repeat after me: the disabled button is a courtesy, not a control. The deposit and withdrawal endpoints must independently verify KYC status on every call. Regulators test exactly this by hitting your API directly. Pair the identity check with a geolocation control too – many US licenses require both, and our walkthrough on wiring a production geolocation compliance stack shows how to combine the two checks into one onboarding gate.

Re-Verification Triggers

Plenty of jurisdictions require re-verification on risk signals – a new device, a large first deposit, or a dormant account reactivating. Model status as a function of the latest decision plus those triggers, not a permanent boolean, so you can demand a fresh session when policy says so.

Step 12 – Test in Sandbox, Harden, and Go Live

Do every bit of iteration against Jumio’s sandbox credentials. The sandbox accepts test documents and returns deterministic decisions so you can exercise pass, reject, and warning paths without cost.

# smoke-test the full chain locally
curl -i -X POST http://localhost:8080/api/kyc/session \
  -H 'Cookie: session=...'        # expect 200 + { sdkToken, dc }

# simulate a callback (sandbox)
curl -i -X POST http://localhost:8080/api/kyc/callback \
  -H 'Content-Type: application/json' \
  -d '{"account":{"id":"..."},"workflowExecution":{"id":"..."}}'

Once the chain is green, switch to production credentials, point the callback URL at your production backend, and run one real verification with a genuine document before opening the floodgates.

Go-Live Checklist

  • API secret present only in backend env; SPA bundle grep-clean of any client_secret.
  • CORS allowlist contains exact production origins, no wildcard.
  • Callback URL registered for the production datacenter and reachable over TLS 1.3.
  • Deposit and withdrawal endpoints independently re-check KYC server-side.
  • Rate limit and idempotency on the session mint route.
  • Per-user audit log of every decision, keyed on workflow execution id.

Bundle Audit

Run npm run build and search the output for anything that should not ship: grep -ri "client_secret\|JUMIO_CLIENT" dist/ must return nothing. This thirty-second check has saved more than one operator from a catastrophic secret leak.

Complete Project Example

Here is the full shape of both packages so you can recreate the architecture quickly. The frontend is a pure static bundle; the backend is the only privileged component.

jumio-igaming-spa/            # static, ships to CDN
├─ .env                       # VITE_API_BASE only
├─ vite.config.js
└─ src/
   ├─ loadJumio.js            # lazy import of @jumio/websdk
   ├─ useJumioVerification.js # reducer state machine
   ├─ JumioVerification.jsx   # renders <jumio-sdk>, wires events
   ├─ useKycStatus.js         # SWR poll of /api/kyc/status
   ├─ DepositButton.jsx       # gated cashier entry
   └─ App.jsx

jumio-igaming-api/            # regulated, lives in VPC
├─ .env                       # client_id/secret, hosts, callback secret
├─ server.js                  # /session, /callback, /status, /healthz
├─ jumio.js                   # getAccessToken, createSession
└─ auth.js                    # your existing session middleware

The App Shell

// App.jsx
import JumioVerification from './JumioVerification.jsx'
import { DepositButton } from './DepositButton.jsx'

export default function App() {
  return (
    <main>
      <h1>Account verification</h1>
      <JumioVerification onFinished={() => console.log('capture done')} />
      <hr />
      <DepositButton />
    </main>
  )
}

Status Endpoint

// add to server.js
app.get('/api/kyc/status', requireAuth, async (req, res) => {
  const decision = await readDecision(req.user.id)  // from Step 10 store
  const map = { PASSED: 'passed', REJECTED: 'rejected' }
  res.json({ status: map[decision?.type] ?? 'pending' })
})

Running Both Processes

In development you run two terminals: npm run dev for the SPA on port 5173 and node --watch server.js for the API on port 8080, with an HTTPS tunnel pointed at 8080 so Jumio’s callback can reach you. In production the two part ways entirely – the SPA builds to static assets behind a CDN while the API runs as a containerized service inside your regulated network. Because the only contract between them is the VITE_API_BASE URL and the CORS allowlist, you can redeploy either side independently, which is exactly the operational flexibility that makes the decoupled model worth the extra moving part.

Five Critical Pitfalls in a React SPA Integration

These are the failures that cost teams the most hours. Each maps to a specific architectural mistake that the Vite-SPA topology makes especially easy to commit.

1. Trusting a Client Event as the Decision

The number-one production incident: unlocking deposits when workflow:complete fires. That event means capture finished, not that the player passed. Always wait for the server callback and retrieval. If you remember one thing from this guide, make it this.

2. Minting Two Sessions Under Strict Mode

React 19 Strict Mode invokes effects twice in development. Without the started ref guard from Step 7, you mint two SDK tokens and consume two verifications – invisible in dev, embarrassing on the invoice. Guard every paid side effect.

3. Leaking the Secret Into the Bundle

It is tempting to “temporarily” read the Jumio secret in React to get unblocked. Anything bundled by Vite is public. Keep the client_secret exclusively on Express, and audit dist/ before every release.

4. Wrong Datacenter on the Element

A token minted in amer-1 with dc="eu" on the element fails with an unhelpful initialization error. Always return the dc from the same backend that minted the token so the two can never drift apart.

5. Camera Blocked by Missing Permissions-Policy

Desktop works, mobile is dead. Almost always a missing or wrong Permissions-Policy header on the document or an embedding iframe. Set camera=(self ...) explicitly and test on a real iPhone, not just a desktop emulator.

Eight-Item Troubleshooting Guide

Symptom-to-cause table for the errors you are most likely to hit during integration and the first thing to check for each.

Symptom Likely cause First fix
Blank panel, no errors Wrong dc or expired token Re-mint; confirm dc matches mint region
401 on OAuth2 Bad client id/secret or region host Verify credentials and auth.* host pairing
Camera never prompts Insecure origin or Permissions-Policy Serve over HTTPS; add camera=(self)
CORS error on session call SPA origin not allowlisted Set exact SPA_ORIGIN, no wildcard
Callback never arrives URL unreachable or not registered Public HTTPS tunnel; check Customer Portal
Duplicate decisions stored Slow callback ack triggering retries Return 200 first; idempotent write
Two verifications per user Strict Mode double-mint Add the started ref guard
SDK assets blocked CSP too strict Allow *.jumio.ai in connect/img/media-src

Reading the Decision Object

When a verification rejects, the retrieval response carries reason capabilities (data such as document validity, similarity, and usability). Log the rejection reason categories – never the raw PII – so support can tell a player “your document photo was too blurry” without your logs becoming a breach liability.

Advanced Tips: Webhook Resilience and Deepfake Defense

A demo integration ends at “it passed.” A production iGaming integration has to survive callback storms, network partitions, and adversaries armed with generative AI. These patterns separate the two.

Make the Callback Path Crash-Safe

Treat the callback as an at-least-once delivery channel. Persist a raw record of every callback before doing any retrieval work, process asynchronously off a queue, and make the retrieval-and-persist step idempotent on the workflow execution id. If your backend restarts mid-callback, the queued record replays harmlessly instead of leaving a player stuck in pending forever.

Lean on Liveness and Continuous Signals

Jumio’s 2026 platform performs document and biometric liveness checks designed to reject presentation and injection attacks, including AI-generated face swaps. Choose a workflow definition that includes a similarity/liveness step (not document-only) for any real-money onboarding, and treat a WARNING decision as a trigger for manual review rather than an automatic pass. Identity fraud in regulated gambling is increasingly synthetic, and a document-only check is no longer enough on its own.

Pre-Mint Latency Hiding

Token minting plus account creation adds a network round trip before the camera opens. Hide it: call loadJumioSdk() as soon as the player lands on the verification route (so the bundle is warm), and only mint the token when they click “Verify.” The SDK is then already parsed and the only visible wait is the sub-second mint.

Observability: Metrics That Matter

Instrument four numbers from day one: session mint count (your spend), mint-to-callback latency (your funnel health), decision-type distribution (pass, reject, and warning rates), and callback retry count (your reliability). A spike in mint count with flat callbacks usually means a double-mint regression has slipped through; a rising reject rate can signal a document-capture UX problem or an incoming fraud wave. Emit all four as structured logs or metrics from the Express backend, never from the SPA, so the figures are trustworthy and cannot be tampered with client-side. Alert on mint-count anomalies in particular, because that line maps one to one onto your Jumio invoice.

Beyond the Standard Coverage: What Jumio’s Docs Skip

Jumio’s official integration-channels page does an excellent job explaining the four delivery models – Web Client redirect, embedded Web SDK, native Mobile SDKs, and raw REST APIs – and when to choose each. What it deliberately does not cover, because it is product-neutral documentation, is the React-specific and iGaming-specific reality you actually ship. This section fills those gaps.

The SPA Trust-Boundary Gap

The official docs assume you have a server to mint tokens but never prescribe the shape of a decoupled SPA-plus-API split. They will not tell you to keep the account id off the wire, to rate-limit the mint route against budget abuse, or to guard against Strict Mode double-mints – all of which are specific to the React SPA model and all of which can cost real money. Treat those as your responsibility, not Jumio’s. If your platform is server-rendered instead, the same trust boundary holds but the mechanics differ – our Next.js App Router walkthrough for Jumio NetVerify shows the route-handler equivalent of every server-side step above.

The Deposit-Gating Gap

Vendor documentation stops at “verification complete.” It is silent on the part regulators care about: refusing deposits and withdrawals until a server-confirmed PASSED, re-verifying on risk triggers, and proving in an audit that the gate is enforced server-side. That logic is yours to build, and it is the difference between a verified user and a compliant operator.

The Multi-Vendor Reality

Real operators rarely run a single identity vendor. They route by jurisdiction, fail over when one provider degrades, and reconcile decisions across stacks. Jumio’s docs naturally never mention Onfido (now Entrust IDV), Veriff, Sumsub, or Persona. If you are architecting for that reality, build your decision store vendor-agnostic from day one so a second provider is a new adapter, not a rewrite.

iGaming Compliance Mapping by Jurisdiction

Identity verification is a means to a regulatory end. The table below maps common 2026 iGaming requirements to where they land in the architecture above. Always confirm specifics with your licensing counsel – this is engineering guidance, not legal advice.

Requirement Where it lives Implementation note
KYC before first deposit Step 11 deposit gate Server re-check on every cashier call
AML / watchlist screening Step 4 workflow definition Use a screening-enabled workflow key
Age verification (18+/21+) Retrieval decision (Step 10) Read DOB capability, enforce server-side
Data minimization (GDPR/RGPD) Step 10 persistence Store decision + minimal subset, not raw images
Audit trail Decision store Immutable log keyed on execution id
Re-verification on risk Step 11 triggers Status as function of decision + signals

Data Minimization in Practice

Under GDPR and its national equivalents, you should retain the minimum personal data needed to evidence the decision. In practice that means persisting the decision type, the timestamp, the document type and issuing country, and the masked document number – not the captured images, which Jumio retains under its own retention policy. The UK’s regulator publishes detailed source-of-funds and AML expectations through the UK Gambling Commission, and US operators should map onboarding controls to the Bank Secrecy Act obligations described by FinCEN.

Jurisdiction-Aware Workflows

Because the workflow definition is just a numeric key passed at account creation, you can route different players to different verification depths – a lighter flow where permitted, a screening-heavy flow where required – by selecting the key server-side based on the player’s licensed jurisdiction. Keep that mapping in your backend config, never in the SPA, so policy changes ship without a frontend release.

Frequently Asked Questions

Is it safe to put the Jumio SDK token in the React bundle?

Yes – the SDK token is single-use and short-lived, designed to live in the browser. What must never reach the bundle is the OAuth2 client_id/client_secret. Mint the SDK token on your Express backend and return only the token and datacenter to React.

Do I really need a separate backend, or can the SPA call Jumio directly?

You need a backend. Every privileged Jumio call requires the API secret, account creation must be server-controlled to prevent budget abuse, and the authoritative decision arrives via a server-to-server callback. A static SPA has nowhere safe to do any of that.

Which @jumio/websdk version should I pin?

Pin 0.5.0, published June 24, 2026 – the current release on npm as of this writing. The package declares no dependencies and ships ESM with TypeScript types. Watch the public web-sdk changelog before bumping, since the 0.x line still introduces breaking changes between minors.

Does this work with React 19 and Vite 8?

Yes, and React 19.2.7 specifically improves custom-element handling so you can pass dc and token as plain JSX attributes. Vite 8.1.0 bundles the ESM SDK without extra configuration. Older React 18 setups need a ref to set the element’s attributes imperatively.

How much does Jumio cost in 2026?

Jumio uses custom enterprise pricing, generally in the $3–$5 per verification range, with no free tier. Because each session mint can incur a charge, rate-limit your session endpoint and do all iteration in the sandbox, which is free and returns deterministic decisions.

What is the difference between NetVerify and KYX?

NetVerify is the original product name for Jumio’s identity verification suite; KYX is the modern platform that supersedes it, using the two-token model and the @jumio/websdk web component shown here. The Customer Portal still lives at customer-portal.netverify.com, which is why both names persist in 2026.

How do I gate deposits correctly?

Persist the decision from the server callback, expose a status endpoint, and have the SPA poll it. Crucially, your deposit and withdrawal endpoints must independently re-check the server-stored decision on every call. The disabled button in React is cosmetic; the server check is the real gate.

Why is the camera dead on iPhone but fine on desktop?

Almost always a missing or incorrect Permissions-Policy header – on the document itself or on an embedding iframe in a white-label skin. iOS Safari refuses camera access without an explicit camera=(self ...) grant and a secure (HTTPS) origin. Test on a real device, not just a desktop emulator.

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