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.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
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.







