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:

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
Email 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

  1. Always clean up – Call manager.stop() when unmounting components
  2. Handle all states – Show appropriate UI for loading, error, and edge cases
  3. Validate inputs – Pass isValid to setPhoneNumber() / setEmail() before submit()
  4. Use getState() sparingly – Prefer subscribing to state changes
  5. Show feedback – Use detectionStatus to guide users during capture
  6. Handle retries – Check attemptsRemaining and canRetry for error recovery