Headless Mode
Web SDK 2.0 Guide
This guide is specific to Web SDK 2.0. If you are still using 1.x, you can find documentation here. Contact your Incode Representative for upgrade information and check if you are a candidate for this upgrade.
Full rollout to all clients still TBD.
Headless mode lets you use the SDK's core logic without any pre-built UI. This is perfect for:
- Custom UI implementations with your own design system
- Backend-driven verification flows
- Automated testing
- React Native or other non-web platforms
How It Works
Each verification module provides a manager that encapsulates state machine logic. You subscribe to state changes and call methods to drive the flow.
@incodetech/core
Your Application
subscribe()
load(), submit(), etc.
state updates
Custom UI
Manager
State Machine
Incode API
Available Managers
| Module | Manager Factory | Import |
|---|---|---|
| Phone | createPhoneManager() |
@incodetech/core/phone |
createEmailManager() |
@incodetech/core/email |
|
| Selfie | createSelfieManager() |
@incodetech/core/selfie |
| ID Capture | createIdCaptureManager() |
@incodetech/core/id |
| Orchestrated Flow | createOrchestratedFlowManager() |
@incodetech/core/flow |
Setup
Before using any manager, configure the SDK and activate your session token:
TypeScript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
await setup({ apiURL: 'https://demo-api.incodesmile.com' });
await initializeSession({
token: 'your-session-token' /* from createSession() */
});
setup provisions the HTTP client and (optionally) warms up WASM; initializeSession activates the session token and pre-loads session-scoped state. The two-call form lets you start setup (including WASM warmup) before the token is known. The one-shot form setup({ apiURL, token }) is still supported as a convenience — it delegates to initializeSession for you.
Phone Verification
Quick Start
TypeScript
import { createPhoneManager } from '@incodetech/core/phone';
// 1. Create manager with configuration
const manager = createPhoneManager({
config: {
otpVerification: true,
otpExpirationInMinutes: 5,
prefill: false,
},
});
// 2. Subscribe to state changes
manager.subscribe((state) => {
console.log('Status:', state.status);
if (state.status === 'finished') {
console.log('Phone verified!');
manager.stop();
}
});
// 3. Start the flow
manager.load();
// 4. When state is 'inputting', set the phone number
manager.setPhoneNumber('+14155551234', true);
manager.submit();
// 5. When state is 'awaitingOtp', submit the OTP
manager.submitOtp('ABC123');
State Machine Flow
load
submit
submitOtp
back
idle
inputting
awaitingOtp
finished
States Reference
| Status | Description | Properties |
|---|---|---|
idle |
Initial state | – |
loadingPrefill |
Fetching pre-filled phone | – |
inputting |
Ready for phone input | countryCode, phonePrefix, phoneError? |
submitting |
Submitting phone number | – |
sendingInitialOtp |
Sending first OTP | – |
resendingOtp |
Resending OTP | – |
awaitingOtp |
Waiting for OTP entry | resendTimer, canResend, attemptsRemaining |
verifyingOtp |
Verifying OTP code | resendTimer, canResend |
otpError |
OTP verification failed | otpError, attemptsRemaining, resendTimer, canResend |
finished |
Verification complete | – |
error |
Fatal error | error |
API Methods
| Method | Description | When to Use |
|---|---|---|
load() |
Initializes the flow | Always call first |
setPhoneNumber(phone, isValid) |
Sets phone number and validation state | When inputting, before submit() |
setOptInGranted(granted) |
Sets marketing opt-in preference | When inputting, if opt-in enabled |
submit() |
Submits the phone number | After setting valid phone |
setOtpCode(code) |
Sets OTP without submitting (controlled input) | When awaitingOtp |
submitOtp(code) |
Sets and submits OTP | When awaitingOtp or otpError |
resendOtp() |
Requests new OTP code | When canResend is true |
back() |
Returns to phone input | When awaitingOtp |
reset() |
Resets to initial state | After finished or error |
stop() |
Cleanup resources | When unmounting |
getState() |
Returns current state synchronously | Anytime |
subscribe(callback) |
Subscribe to state changes | Returns unsubscribe function |
Configuration Options
TypeScript
type PhoneConfig = {
otpVerification: boolean; // Require OTP verification (default: true)
otpExpirationInMinutes: number; // OTP validity in minutes (default: 5)
prefill: boolean; // Fetch pre-filled phone from backend
isInstantVerify?: boolean; // Use instant verification API
optinEnabled?: boolean; // Show marketing opt-in checkbox
maxOtpAttempts?: number; // Max OTP attempts (default: 3)
};
Email Verification
Email verification works identically to phone verification.
Quick Start
TypeScript
import { createEmailManager } from '@incodetech/core/email';
const manager = createEmailManager({
config: {
otpVerification: true,
otpExpirationInMinutes: 5,
prefill: false,
},
});
manager.subscribe((state) => {
if (state.status === 'finished') {
console.log('Email verified!');
manager.stop();
}
});
manager.load();
manager.setEmail('user@example.com', true);
manager.submit();
// When state is 'awaitingOtp'
manager.submitOtp('ABC123');
State Machine Flow
load
submit
submitOtp
back
idle
inputting
awaitingOtp
finished
States Reference
| Status | Description | Properties |
|---|---|---|
idle |
Initial state | – |
loadingPrefill |
Fetching pre-filled email | – |
inputting |
Ready for email input | prefilledEmail?, emailError? |
submitting |
Submitting email | – |
sendingInitialOtp |
Sending first OTP | – |
resendingOtp |
Resending OTP | – |
awaitingOtp |
Waiting for OTP entry | resendTimer, canResend, attemptsRemaining |
verifyingOtp |
Verifying OTP code | resendTimer, canResend |
otpError |
OTP verification failed | otpError, attemptsRemaining, resendTimer, canResend |
finished |
Verification complete | – |
error |
Fatal error | error |
API Methods
| Method | Description | When to Use |
|---|---|---|
load() |
Initializes the flow | Always call first |
setEmail(email, isValid) |
Sets email and validation state | When inputting, before submit() |
submit() |
Submits the email | After setting valid email |
setOtpCode(code) |
Sets OTP without submitting | When awaitingOtp |
submitOtp(code) |
Sets and submits OTP | When awaitingOtp or otpError |
resendOtp() |
Requests new OTP code | When canResend is true |
back() |
Returns to email input | When awaitingOtp |
reset() |
Resets to initial state | After finished or error |
stop() |
Cleanup resources | When unmounting |
getState() |
Returns current state | Anytime |
subscribe(callback) |
Subscribe to state changes | Returns unsubscribe function |
Selfie Capture
Selfie capture is more complex due to camera handling and ML-powered face detection. Requires WASM configuration.
Quick Start
TypeScript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createSelfieManager } from '@incodetech/core/selfie';
// Preload the selfie WASM pipeline as part of setup() — Incode CDN
// defaults are used unless you override paths. See WASM Configuration
// for self-hosted paths and the lower-level warmupWasm() API.
await setup({
apiURL: 'https://demo-api.incodesmile.com',
wasm: { pipelines: ['selfie'] },
});
await initializeSession({ token: 'your-token' });
const manager = createSelfieManager({
config: {
showTutorial: true,
autoCaptureTimeout: 10,
validateLenses: true,
validateFaceMask: true,
},
});
manager.subscribe((state) => {
switch (state.status) {
case 'tutorial':
// Show your tutorial UI
// Call manager.nextStep() when user is ready
break;
case 'permissions':
// Show permission request UI
if (state.permissionStatus === 'denied') {
// Show instructions to enable camera
}
break;
case 'capture':
// Camera is active
// state.stream - MediaStream for <video> element
// state.detectionStatus - Current face detection feedback
// state.captureStatus - 'detecting', 'capturing', 'uploading', etc.
break;
case 'finished':
console.log('Selfie captured!', state.processResponse);
manager.stop();
break;
case 'error':
console.error('Error:', state.error);
break;
}
});
manager.load();
State Machine Flow
load
nextStep
granted
upload done
success
idle
tutorial
permissions
capture
processing
finished
States Reference
| Status | Description | Properties |
|---|---|---|
idle |
Initial state | – |
loading |
Checking permissions | – |
tutorial |
Showing tutorial | – |
permissions |
Handling camera access | permissionStatus |
capture |
Camera active | stream, captureStatus, detectionStatus |
processing |
Server-side processing of the captured selfie | – |
finished |
Capture complete | processResponse? |
closed |
User closed flow | – |
error |
Fatal error | error |
Capture State Properties
When status === 'capture', the following properties are available:
| Property | Type | Description |
|---|---|---|
stream |
CameraStream |
Camera stream for <video> element |
captureStatus |
string |
Sub-state: initializing, detecting, capturing, uploading, uploadError, success |
detectionStatus |
DetectionStatus |
Face detection feedback |
attemptsRemaining |
number |
Remaining capture attempts |
uploadError |
string? |
Error message if upload failed |
assistedOnboarding |
boolean |
Whether assisted onboarding mode is active |
debugFrame |
ImageData? |
Latest processed frame (for debugging) |
API Methods
| Method | Description | When to Use |
|---|---|---|
load() |
Starts the selfie flow | Always call first |
nextStep() |
Advances to next step | From tutorial to permissions |
requestPermission() |
Requests camera permission | When permissions.idle or permissions.learnMore |
goToLearnMore() |
Shows permission help | When permissions.idle |
back() |
Goes back from learn more | When permissions.learnMore |
capture() |
Manual capture | When detectionStatus === 'manualCapture' |
retryCapture() |
Retry after upload error | When captureStatus === 'uploadError' |
close() |
Closes the flow | Anytime |
reset() |
Resets to initial state | After finished or error |
stop() |
Cleanup resources | When unmounting |
getState() |
Returns current state | Anytime |
subscribe(callback) |
Subscribe to state changes | Returns unsubscribe function |
Handling cancellation (closed)
When the user dismisses the selfie flow, the manager transitions to closed. closed is a final state: the manager won't re-emit further updates. Custom UIs need to decide what to do next.
selfieManager.subscribe((state) => {
if (state.status === 'closed') {
// Optional: render a brief "Cancelled — continuing…" screen, then advance.
setTimeout(() => flowManager.completeModule(), 800);
}
});
ID Capture
ID capture handles document scanning with ML-powered quality checks. Requires WASM configuration.
Quick Start
TypeScript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createIdCaptureManager } from '@incodetech/core/id';
await setup({
apiURL: 'https://demo-api.incodesmile.com',
wasm: { pipelines: ['idCapture'] },
});
await initializeSession({ token: 'your-token' });
const manager = createIdCaptureManager({
config: {
showTutorial: true,
enableId: true,
enablePassport: false,
autoCaptureTimeout: 5,
captureAttempts: 3,
},
});
manager.subscribe((state) => {
switch (state.status) {
case 'chooser':
// Show document type selection UI
break;
case 'tutorial':
// Show tutorial for the selected document type
break;
case 'permissions':
// Handle camera permissions
break;
case 'capture':
// Camera is active
break;
case 'finished':
console.log('ID captured successfully!');
manager.stop();
break;
case 'error':
console.error('Error:', state.error);
break;
}
});
manager.load();
States Reference
| Status | Description | Properties |
|---|---|---|
idle |
Initial state | – |
chooser |
Document type selection | availableDocumentTypes |
loading |
Loading configuration | – |
tutorial |
Showing tutorial | selectedDocumentType |
permissions |
Camera permission request | permissionStatus |
capture |
Active capture | stream, currentMode, captureStatus, detectionStatus, counterValue |
finished |
Capture complete | – |
closed |
User closed flow | – |
error |
Fatal error | error |
Workflow Manager
The Workflow Manager runs server-driven multi-step onboarding workflows, where the next step (and its configuration) is chosen by the backend per session.
Quick Start
TypeScript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createWorkflowManager } from '@incodetech/core/workflow';
await setup({
apiURL: 'https://demo-api.incodesmile.com',
wasm: { pipelines: ['selfie', 'idCapture'] },
});
await initializeSession({ token: 'your-session-token' });
const workflow = createWorkflowManager({
interviewId: session.interviewId,
isDesktop: matchMedia('(min-width: 768px)').matches,
customModuleCallback: ({ name, interviewId, nodeId, onSuccess, onError }) => {
// Run your custom business logic, then advance the workflow.
onSuccess('handled');
},
});
workflow.subscribe((state) => {
switch (state.status) {
case 'idle':
case 'loading':
if (state.homeScreen.visible) {
// Render launch screen
}
break;
case 'ready':
// Render the module for state.currentNode.moduleKey
break;
case 'finished':
workflow.stop();
break;
}
});
workflow.load();
API Methods
| Method | Description |
|---|---|
load() |
Start loading the workflow configuration |
completeModule() |
Mark the current module as complete |
errorModule(error) |
Mark the current module as failed |
completeFlow() |
Skip the remaining nodes and go straight to the completion step |
continueFromHome() |
Advance past the launch / home screen |
getState() |
Get the current workflow state synchronously |
subscribe(callback) |
Subscribe to workflow state changes |
Building Custom UI
React Example: Phone Verification
import { useState, useEffect } from 'react';
import { createPhoneManager, type PhoneState } from '@incodetech/core/phone';
function PhoneVerification() {
const [manager] = useState(() =>
createPhoneManager({
config: {
otpVerification: true,
otpExpirationInMinutes: 5,
prefill: false,
},
}),
);
const [state, setState] = useState<PhoneState>({ status: 'idle' });
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
useEffect(() => {
const unsubscribe = manager.subscribe(setState);
manager.load();
return () => {
unsubscribe();
manager.stop();
};
}, [manager]);
const handlePhoneSubmit = () => {
const isValid = phone.length >= 10; // Add real validation
manager.setPhoneNumber(phone, isValid);
manager.submit();
};
switch (state.status) {
case 'inputting':
return (
<div>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="Enter phone number"
/>
{state.phoneError && <p className="error">{state.phoneError}</p>}
<button onClick={handlePhoneSubmit}>Send OTP</button>
</div>
);
case 'awaitingOtp':
return (
<div>
<p>Enter the code sent to your phone</p>
<input
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value)}
placeholder="Enter OTP"
maxLength={6}
/>
<button onClick={() => manager.submitOtp(otp)}>Verify</button>
{state.canResend ? (
<button onClick={() => manager.resendOtp()}>Resend</button>
) : (
<p>Resend in {state.resendTimer}s</p>
)}
<button onClick={() => manager.back()}>Change phone</button>
</div>
);
case 'otpError':
return (
<div>
<p className="error">{state.otpError}</p>
<p>Attempts remaining: {state.attemptsRemaining}</p>
<input
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value)}
placeholder="Enter OTP"
/>
<button onClick={() => manager.submitOtp(otp)}>Try Again</button>
</div>
);
case 'finished':
return <div>✅ Phone verified successfully!</div>;
case 'error':
return <div className="error">Error: {state.error}</div>;
default:
return <div>Loading...</div>;
}
}
React Example: Selfie Capture
import { useState, useEffect, useRef } from 'react';
import { createSelfieManager, type SelfieState } from '@incodetech/core/selfie';
function SelfieCapture() {
const videoRef = useRef<HTMLVideoElement>(null);
const [manager] = useState(() =>
createSelfieManager({
config: {
showTutorial: true,
autoCaptureTimeout: 10,
validateLenses: true,
},
}),
);
const [state, setState] = useState<SelfieState>({ status: 'idle' });
useEffect(() => {
const unsubscribe = manager.subscribe(setState);
manager.load();
return () => {
unsubscribe();
manager.stop();
};
}, [manager]);
// Connect stream to video element
useEffect(() => {
if (state.status === 'capture' && state.stream && videoRef.current) {
videoRef.current.srcObject = state.stream;
}
}, [state]);
switch (state.status) {
case 'tutorial':
return (
<div>
<h2>Take a Selfie</h2>
<ul>
<li>Ensure good lighting</li>
<li>Remove glasses and hats</li>
<li>Look directly at the camera</li>
</ul>
<button onClick={() => manager.nextStep()}>Continue</button>
</div>
);
case 'permissions':
if (state.permissionStatus === 'denied') {
return (
<div>
<p>Camera access is required.</p>
<p>Please enable camera in your browser settings.</p>
</div>
);
}
return (
<div>
<p>We need camera access to take your selfie.</p>
<button onClick={() => manager.requestPermission()}>
Allow Camera
</button>
</div>
);
case 'capture':
return (
<div>
<video ref={videoRef} autoPlay playsInline muted />
<p>{getDetectionMessage(state.detectionStatus)}</p>
{state.detectionStatus === 'manualCapture' && (
<button onClick={() => manager.capture()}>Take Photo</button>
)}
{state.captureStatus === 'uploadError' && (
<div>
<p className="error">{state.uploadError}</p>
<button onClick={() => manager.retryCapture()}>Retry</button>
</div>
)}
</div>
);
case 'finished':
return <div>✅ Selfie captured successfully!</div>;
case 'error':
return <div className="error">Error: {state.error}</div>;
default:
return <div>Loading...</div>;
}
}
function getDetectionMessage(status: string): string {
const messages: Record<string, string> = {
noFace: 'Position your face in the frame',
tooFar: 'Move closer',
tooClose: 'Move back',
centerFace: 'Center your face',
blur: 'Hold still, image is blurry',
dark: 'Improve lighting conditions',
lenses: 'Remove glasses or lenses',
faceMask: 'Remove face mask',
capturing: 'Capturing...',
manualCapture: 'Ready - tap to capture',
};
return messages[status] || 'Detecting face...';
}
TypeScript Support
All managers provide full TypeScript support with discriminated unions.
import { type PhoneState } from '@incodetech/core/phone';
function handleState(state: PhoneState) {
switch (state.status) {
case 'inputting':
console.log(`Country: ${state.countryCode}`);
break;
case 'awaitingOtp':
console.log(`Resend in: ${state.resendTimer}s`);
break;
case 'error':
console.error(state.error);
break;
}
}
Manager Lifecycle
All managers follow the same lifecycle pattern:
Incode API
Manager
Your App
Incode API
Manager
Your App
loop
[User Interaction]
cleanup resources
createXxxManager(config)
subscribe(callback)
initial state
load()
fetch initial data
response
state update
setXxx() / submit()
API call
response
state update
stop()
Dashboard Event Tracking
Track events to the Incode dashboard from your custom UI:
TypeScript
import {
addEvent,
moduleOpened,
moduleClosed,
eventModuleNames,
} from '@incodetech/core/events';
// Track module lifecycle
useEffect(() => {
moduleOpened(eventModuleNames.phone);
return () => moduleClosed(eventModuleNames.phone);
}, []);
// Track custom events
addEvent({
code: 'customButtonClicked',
module: eventModuleNames.phone,
payload: { buttonId: 'submit' },
});
Best Practices
- Always clean up – Call
manager.stop()when unmounting components - Handle all states – Show appropriate UI for loading, error, and edge cases
- Validate inputs – Pass
isValidtosetPhoneNumber()/setEmail()beforesubmit() - Use
getState()sparingly – Prefer subscribing to state changes - Show feedback – Use
detectionStatusto guide users during capture - Handle retries – Check
attemptsRemainingandcanRetryfor error recovery