Headless Mode

Headless mode lets you use the SDK's core logic without any pre-built UI. This is perfect for:

📘

This guide is specific to Web SDK 2.0. If you are still using 1.x, you can find documentation here. We strongly recommend upgrading - contact your Incode Representative for upgrade information.

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.

flowchart LR
    subgraph "Your Application"
        UI[Custom UI]
    end
    
    subgraph "@incodetech/core"
        Manager[Manager]
        StateMachine[State Machine]
        API[Incode API]
    end
    
    UI -->|"subscribe()"| Manager
    UI -->|"load(), submit(), etc."| Manager
    Manager -->|state updates| UI
    Manager <--> StateMachine
    StateMachine <--> API

Available Managers

ModuleManager FactoryImport
PhonecreatePhoneManager()@incodetech/core/phone
EmailcreateEmailManager()@incodetech/core/email
SelfiecreateSelfieManager()@incodetech/core/selfie
ID CapturecreateIdCaptureManager()@incodetech/core/id
Orchestrated FlowcreateOrchestratedFlowManager()@incodetech/core/flow

Setup

Before using any manager, configure the SDK and activate your session token:

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

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

flowchart LR
    idle -->|load| inputting
    inputting -->|submit| awaitingOtp
    awaitingOtp -->|submitOtp| finished
    awaitingOtp -.->|back| inputting

States Reference

StatusDescriptionProperties
idleInitial state
loadingPrefillFetching pre-filled phone
inputtingReady for phone inputcountryCode, phonePrefix, phoneError?
submittingSubmitting phone number
sendingInitialOtpSending first OTP
resendingOtpResending OTP
awaitingOtpWaiting for OTP entryresendTimer, canResend, attemptsRemaining
verifyingOtpVerifying OTP coderesendTimer, canResend
otpErrorOTP verification failedotpError, attemptsRemaining, resendTimer, canResend
finishedVerification complete
errorFatal errorerror

API Methods

MethodDescriptionWhen to Use
load()Initializes the flowAlways call first
setPhoneNumber(phone, isValid)Sets phone number and validation stateWhen inputting, before submit()
setOptInGranted(granted)Sets marketing opt-in preferenceWhen inputting, if opt-in enabled
submit()Submits the phone numberAfter setting valid phone
setOtpCode(code)Sets OTP without submitting (controlled input)When awaitingOtp
submitOtp(code)Sets and submits OTPWhen awaitingOtp or otpError
resendOtp()Requests new OTP codeWhen canResend is true
back()Returns to phone inputWhen awaitingOtp
reset()Resets to initial stateAfter finished or error
stop()Cleanup resourcesWhen unmounting
getState()Returns current state synchronouslyAnytime
subscribe(callback)Subscribe to state changesReturns unsubscribe function

Configuration Options

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

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('[email protected]', true);
manager.submit();

// When state is 'awaitingOtp'
manager.submitOtp('ABC123');

State Machine Flow

flowchart LR
    idle -->|load| inputting
    inputting -->|submit| awaitingOtp
    awaitingOtp -->|submitOtp| finished
    awaitingOtp -.->|back| inputting

States Reference

StatusDescriptionProperties
idleInitial state
loadingPrefillFetching pre-filled email
inputtingReady for email inputprefilledEmail?, emailError?
submittingSubmitting email
sendingInitialOtpSending first OTP
resendingOtpResending OTP
awaitingOtpWaiting for OTP entryresendTimer, canResend, attemptsRemaining
verifyingOtpVerifying OTP coderesendTimer, canResend
otpErrorOTP verification failedotpError, attemptsRemaining, resendTimer, canResend
finishedVerification complete
errorFatal errorerror

API Methods

MethodDescriptionWhen to Use
load()Initializes the flowAlways call first
setEmail(email, isValid)Sets email and validation stateWhen inputting, before submit()
submit()Submits the emailAfter setting valid email
setOtpCode(code)Sets OTP without submittingWhen awaitingOtp
submitOtp(code)Sets and submits OTPWhen awaitingOtp or otpError
resendOtp()Requests new OTP codeWhen canResend is true
back()Returns to email inputWhen awaitingOtp
reset()Resets to initial stateAfter finished or error
stop()Cleanup resourcesWhen unmounting
getState()Returns current state synchronouslyAnytime
subscribe(callback)Subscribe to state changesReturns unsubscribe function

Selfie Capture

Selfie capture is more complex due to camera handling and ML-powered face detection. Requires WASM configuration.

Quick Start

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

flowchart LR
    idle -->|load| tutorial
    tutorial -->|nextStep| permissions
    permissions -->|granted| capture
    capture -->|upload done| processing
    processing -->|success| finished

States Reference

StatusDescriptionProperties
idleInitial state
loadingChecking permissions
tutorialShowing tutorial
permissionsHandling camera accesspermissionStatus
captureCamera activestream, captureStatus, detectionStatus, attemptsRemaining, uploadError?
processingServer-side processing of the captured selfie
finishedCapture completeprocessResponse?
closedUser closed flow
errorFatal errorerror

Capture State Properties

When status === 'capture', the following properties are available:

PropertyTypeDescription
streamCameraStreamCamera stream for <video> element
captureStatusstringSub-state: initializing, detecting, capturing, uploading, uploadError, success
detectionStatusDetectionStatusFace detection feedback (see below)
attemptsRemainingnumberRemaining capture attempts
uploadErrorstring?Error message if upload failed
assistedOnboardingbooleanWhether assisted onboarding mode is active
debugFrameImageData?Latest processed frame (for debugging)

Note: processing is a separate top-level state (status === 'processing'), not a captureStatus value.

Detection Status Values

Show these messages to guide the user during capture:

StatusUser Instruction
idle"Preparing camera..."
detecting"Detecting face..."
noFace"Position your face in the frame"
tooManyFaces"Only one face should be visible"
tooClose"Move back"
tooFar"Move closer"
blur"Hold still, image is blurry"
dark"Improve lighting conditions"
faceAngle"Face your camera directly"
headWear"Remove head coverings"
lenses"Remove glasses or lenses"
eyesClosed"Open your eyes"
faceMask"Remove face mask"
centerFace"Center your face"
getReady"Get ready..."
getReadyFinished"Hold still..."
capturing"Capturing photo..."
manualCapture"Tap to capture" (manual mode)
successCapture succeeded — transitioning out
errorDetection error — surfaces alongside captureStatus === 'uploadError'
offline"No network connection"

Permission Status Values

When status === 'permissions':

permissionStatusMeaning
idleReady to request permission
requestingPermission dialog shown
deniedUser denied camera access
learnMoreShowing help screen

API Methods

MethodDescriptionWhen to Use
load()Starts the selfie flowAlways call first
nextStep()Advances to next stepFrom tutorial to permissions
requestPermission()Requests camera permissionWhen permissions.idle or permissions.learnMore
goToLearnMore()Shows permission helpWhen permissions.idle
back()Goes back from learn moreWhen permissions.learnMore
capture()Manual capture (when available)When detectionStatus === 'manualCapture'
retryCapture()Retry after upload errorWhen captureStatus === 'uploadError'
close()Closes the flowAnytime
reset()Resets to initial stateAfter finished or error
stop()Cleanup resourcesWhen unmounting
getState()Returns current stateAnytime
subscribe(callback)Subscribe to state changesReturns unsubscribe function

Handling cancellation (closed)

When the user dismisses the selfie flow — either by calling manager.close() or by clicking the SDK's built-in close button — the manager transitions to closed. closed is a final state: the manager won't re-emit further updates, and reset() doesn't apply (it's only valid from finished or error). Custom UIs need to decide what to do next.

Inside an orchestrated flow — call flowManager.completeModule() so the orchestrator advances past the cancelled step:

selfieManager.subscribe((state) => {
  if (state.status === 'closed') {
    // Optional: render a brief "Cancelled — continuing…" screen, then advance.
    setTimeout(() => flowManager.completeModule(), 800);
  }
});

Opt-in: on-device face-results submission

Both Selfie and Authentication accept onDeviceFaceResultsSubmissionEnabled?: boolean on their config. When true, face analysis runs entirely on-device via the onDeviceSelfie WASM pipeline and only the results are submitted to the server. Video recording is skipped on this path. Opt-in only — leave the flag off to keep the legacy server-side pipeline. Requires WASM to be configured at setup. See Module: Selfie, Module: Authentication, and WASM Configuration for the full config shape and trade-offs.

Standalone (no orchestrator) — there's nowhere to advance to, so navigate the user out of the selfie surface entirely:

selfieManager.subscribe((state) => {
  if (state.status === 'closed') {
    selfieManager.stop(); // tear down resources
    navigate('/onboarding/cancelled');
  }
});

Common UX choice: render a brief "Cancelled — continuing…" screen for ~800 ms before calling flowManager.completeModule(), so the cancellation isn't jarring.

Capture-only variant

createSelfieCaptureOnlyManager (from @incodetech/core/selfie) exposes the same API surface as createSelfieManager with one additional config requirement — an onCapture(response) callback. The flow bypasses Incode's selfie upload pipeline and delivers the raw face image (and optional local video) back to the integrator. Use it when you're building a hybrid pipeline (capture in browser, upload elsewhere). See Module: Selfie → Capture-only flow for the full payload shape and the biometric-handling caveats.


ID Capture

ID capture handles document scanning with ML-powered quality checks. Requires WASM configuration.

Quick Start

import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createIdCaptureManager } from '@incodetech/core/id';

// Preload the idCapture 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: ['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
      // Call manager.selectDocument('id') or manager.selectDocument('passport')
      break;
      
    case 'tutorial':
      // Show tutorial for the selected document type
      // state.selectedDocumentType - 'id' or 'passport'
      // Call manager.nextStep() when ready
      break;
      
    case 'permissions':
      // Handle camera permissions (same as selfie)
      break;
      
    case 'capture':
      // Camera is active
      // state.stream - MediaStream for <video> element
      // state.currentMode - 'front' or 'back'
      // state.detectionStatus - Document detection feedback
      // state.captureStatus - 'detecting', 'capturing', 'uploading', etc.
      // state.counterValue - Countdown timer value
      break;
      
    case 'frontFinished':
      // Front capture complete, transitioning to back
      break;
      
    case 'processing':
      // Document being processed on server
      break;
      
    case 'finished':
      console.log('ID captured successfully!');
      manager.stop();
      break;
      
    case 'error':
      console.error('Error:', state.error);
      break;
  }
});

manager.load();

State Machine Flow

flowchart LR
    idle -->|load| chooser
    chooser -->|selectDocument| tutorial
    tutorial -->|nextStep| permissions
    permissions -->|granted| capture
    capture -->|front done| frontFinished
    frontFinished --> capture
    capture -->|back done| processing
    processing --> finished

States Reference

StatusDescriptionProperties
idleInitial state
chooserDocument type selectionavailableDocumentTypes
loadingLoading configuration
tutorialShowing tutorialselectedDocumentType
ageVerificationRegulation-required age-confirmation gate. Reached when config.ageAssurance === true. Advance with nextStep().
permissionsCamera permission requestpermissionStatus
captureActive capturestream, currentMode, captureStatus, detectionStatus, counterValue, attemptsRemaining, uploadError?, needsBackCapture, canRetry
frontFinishedFront side captured
backFinishedBack side captured
mandatoryConsentRegulation-required consent screenregulationType (see Mandatory Consent)
processingServer processing
expiredDocument expired after processing
manualUploadManual file upload flow (see Manual Upload)full shape below
digitalIdUploadDigital ID upload sub-flow — PDF e-IDs uploaded by file picker (see Digital ID Upload)full shape below
finishedCapture complete
closedUser closed flow
errorFatal errorerror

Capture State Properties

When status === 'capture':

PropertyTypeDescription
streamMediaStreamCamera stream for <video> element
currentMode'front' | 'back'Which side is being captured
captureStatusstringSub-state: initializing, detecting, capturing, uploading, uploadError, success (see important notes below)
detectionStatusstringDocument detection feedback (see below)
counterValuenumberAuto-capture countdown (seconds)
attemptsRemainingnumberRemaining capture attempts
uploadErrorstring?Error code if upload failed
uploadErrorMessagestring?Human-readable error message
uploadErrorDescriptionstring?Detailed error description
needsBackCapturebooleanWhether back capture is needed
showCaptureButtonInAutobooleanShow manual capture button in auto mode
canRetrybooleanWhether retry is available
orientationstring?Detected document orientation
idTypestring?Detected ID type
previewImageUrlstring?URL of captured image preview
uploadProgressnumber?Upload progress (0-100)

Mandatory Consent State Properties

When status === 'mandatoryConsent':

PropertyTypeDescription
regulationTypeRegulationTypesWhich regional regulation triggered the consent screen. Used to pick consent copy.

RegulationTypes is a string union:

type RegulationTypes =
  | 'US'
  | 'Worldwide'
  | 'Other'
  | 'US_Illinois'
  | 'US_Texas'
  | 'US_California'
  | 'US_Washington';

'Other' is the fallback the SDK uses when the upload response doesn't pin a specific regulation. Drive your consent copy off this value, then call acceptMandatoryConsent() (proceed) or cancelMandatoryConsent() (abort the flow).

Manual Upload State Properties

When status === 'manualUpload'. The user is uploading ID images by file picker — used as a fallback when live capture isn't possible, or when the backend forces it via configuration. The state has tab-aware UI hints (ID vs Passport), per-slot file metadata, and a continue gate.

PropertyTypeDescription
phase'selecting' | 'uploading' | 'exhausted''selecting' while the user picks files, 'uploading' during upload, 'exhausted' after retries are spent.
uploadingSide'front' | 'back' | 'passport' | undefinedWhich side is currently uploading; undefined outside phase === 'uploading'.
activeTab'id' | 'passport'Currently selected tab. Use manualUploadChangeTab() to switch.
showIdTabbooleanWhether the ID tab should be rendered (driven by config).
showPassportTabbooleanWhether the Passport tab should be rendered.
showBackSlotbooleanOn the ID tab, whether to render the back-of-ID slot. The server controls visibility per document — the back slot is hidden when the front upload response sets skipBackIdCapture (e.g. passports, single-sided IDs).
frontFileNamestring | undefinedFile name the user picked for the front of the ID, if any.
backFileNamestring | undefinedFile name for the back of the ID, if any.
passportFileNamestring | undefinedFile name for the passport upload, if any.
frontUploadedbooleanTrue once the front file has been accepted server-side.
backUploadedbooleanTrue once the back file has been accepted server-side.
passportUploadedbooleanTrue once the passport file has been accepted server-side.
canContinuebooleanWhether the Continue button should be enabled. Use this to gate manualUploadContinue().
retriesLeftnumberRemaining retries on the current tab.
errorKeystring | nulli18n key for the current error message. Look it up via your translation table; null when there is no error.

Drive the screen with manualUploadSelectFile(side, file), manualUploadChangeTab(tab), and manualUploadContinue(). Call manualUploadReset() to clear all selections and start the tab over.

Digital ID Upload State Properties

When status === 'digitalIdUpload'. Reached when the backend allows uploading a digital ID (typically an EU eID or similar government-issued PDF) instead of capturing a physical document. The sub-flow walks the user through a tutorial → file picker → preview → upload → success/failure cycle with a bounded retry budget (3 attempts).

PropertyTypeDescription
phaseunion'tutorial', 'selecting', 'reviewing', 'uploading', 'holding', 'success', 'error', 'fileTooLarge', 'exhausted'. 'holding' is a brief stabilization step shown while the upload finishes; treat as 'uploading' UI-wise.
fileFile | nullThe PDF the user picked (null outside 'reviewing' / 'uploading' / 'holding').
fileNamestring | undefinedThe file's display name. Use this to render the preview row.
failReasonDigitalUploadFailReason | nullSet when phase === 'error'. Union: 'DIGITAL_ID_REQUESTED_BUT_OTHER_PROVIDED', 'ID_TYPE_UNACCEPTABLE', 'FILE_CHANGED_ERROR', 'INVALID_FILE_TYPE', 'NETWORK_ERROR', 'GENERIC'.
attemptsRemainingnumberRemaining upload retries (starts at 3). Hits 0 → phase === 'exhausted'.
uploadProgressnumberUpload progress 0–100. Only meaningful in 'uploading' / 'holding'.
pickerRequestIdnumberMonotonic counter — increments each time the SDK wants the host UI to open the file picker. Watch for changes and trigger your <input type="file"> programmatically (the SDK never opens it itself).

Accepted files. PDF only (application/pdf), 5 MB maximum.

Phase progression. tutorialselecting (host opens picker on each pickerRequestId change) → reviewing (user confirms or replaces) → uploadingsuccess (auto-advances out of the sub-flow) or error → back to selecting, until attemptsRemaining === 0exhausted. fileTooLarge is shown immediately when the user picks an oversized file and does not consume a retry.

Drive the screen with digitalUploadNextStep(), digitalUploadPickFile(file), digitalUploadConfirm(), digitalUploadReplace(), digitalUploadRetry(), digitalUploadChooseAnother(), and digitalUploadScanInstead() (falls back to live capture when allowed).

Important: Capture State Transitions

When building custom UI, you must handle these critical state transitions:

After Upload Success (captureStatus === 'success')

When upload completes successfully, the state machine waits in capture.success state. You must call nextStep() to advance:

idManager.subscribe((state) => {
  if (state.status === 'capture' && state.captureStatus === 'success') {
    // Upload successful - advance to next step (frontFinished or processing)
    idManager.nextStep();
  }
});

Without calling nextStep(), the state machine will appear stuck in the capture state.

Document Expired (status === 'expired')

When a document is detected as expired after processing, the state goes to expired. Use retryCapture(), not reset():

idManager.subscribe((state) => {
  if (state.status === 'expired') {
    // Show "Document expired" UI with retry button
    // On retry click:
    idManager.retryCapture(); // ✅ Correct - returns to capture flow
    // NOT idManager.reset(); // ❌ Wrong - will not work in expired state
  }
});

The expired state only responds to RETRY_CAPTURE event. The reset() method (which sends RESET) only works in finished or error states.

Detection Status Values

Show these messages to guide the user during capture:

StatusUser Instruction
idle"Preparing camera..."
idNotDetected"Position your ID in the frame"
detecting"Hold steady..."
farAway"Move closer"
blur"Hold still, image is blurry"
glare"Adjust angle to reduce glare"
wrongSide"Please show the other side"
capturing"Capturing..."
manualCapture"Tap to capture" (manual mode)
offline"No network connection"

Error Codes

When captureStatus === 'uploadError', check uploadError:

Error CodeDescriptionUser Action
UPLOAD_ERRORUpload failedRetry capture
CLASSIFICATION_FAILEDDocument not recognizedUse a clearer image
LOW_SHARPNESSImage too blurryHold device steadier
GLARE_DETECTEDGlare on documentAdjust lighting/angle
WRONG_DOCUMENT_SIDEWrong side shownFlip the document
ID_TYPE_UNACCEPTABLEDocument type not supportedUse a different ID
READABILITY_ISSUECannot read documentImprove lighting
RETRY_EXHAUSTED_CONTINUE_TO_BACKFront retries exhaustedAuto-advances to back
RETRY_EXHAUSTED_SKIP_BACKBack retries exhaustedAuto-advances
NO_MORE_TRIESMax attempts reachedFlow will end
UNEXPECTED_ERRORInternal errorRetry or contact support
NO_TOKENSession token missingRe-initialize SDK
PERMISSION_DENIEDCamera access deniedEnable in browser settings
USER_CANCELLEDUser cancelledRe-open module
SERVER_ERRORServer-side errorRetry later

API Methods

MethodDescriptionWhen to Use
load()Starts the ID capture flowAlways call first
selectDocument(type)Selects document typeWhen chooser, pass 'id' or 'passport'
nextStep()Advances to next stepFrom tutorialpermissions, and from captureStatus === 'success'frontFinished/processing
requestPermission()Requests camera permissionWhen permissions.idle
goToLearnMore()Shows permission helpWhen permissions.idle
back()Goes backWhen permissions.learnMore
capture()Manual captureWhen detectionStatus === 'manualCapture'
switchToManualCapture()Switch to manual modeDuring auto-capture
retryCapture()Retry capture from beginningWhen captureStatus === 'uploadError' or status === 'expired'
continueFromError()Continue after non-fatal errorWhen error allows continuation
continueToBack()Proceed to back captureWhen frontFinished
continueToFront()Flip back to front captureWhen backFinished
skipBack()Skip back captureWhen back is optional
acceptMandatoryConsent()Accept the regulation-required consentWhen mandatoryConsent
cancelMandatoryConsent()Decline the regulation-required consentWhen mandatoryConsent
manualUploadSelectFile(side, file)Pick a file for the manual upload fallback (side: 'front' | 'back' | 'passport')When manualUpload
manualUploadChangeTab(tab)Switch between ID and Passport upload tabsWhen manualUpload and both tabs are shown
manualUploadContinue()Submit the selected manual-upload filesWhen manualUpload and state.canContinue is true
manualUploadReset()Clear all selected files on the active tab and start overWhen manualUpload
digitalUploadNextStep()Advance from the digital-upload tutorial to the pickerWhen digitalIdUpload and phase === 'tutorial'
digitalUploadPickFile(file)Hand a picked PDF to the SDK for reviewWhen digitalIdUpload and phase === 'selecting'
digitalUploadConfirm()Confirm the previewed file and start uploadingWhen digitalIdUpload and phase === 'reviewing'
digitalUploadReplace()Discard the previewed file and re-open the pickerWhen digitalIdUpload and phase === 'reviewing'
digitalUploadRetry()Retry after an upload error (consumes one of attemptsRemaining)When digitalIdUpload and phase === 'error'
digitalUploadChooseAnother()Pick a different file after fileTooLargeWhen digitalIdUpload and phase === 'fileTooLarge'
digitalUploadScanInstead()Abandon digital upload and fall back to live captureWhen digitalIdUpload (any phase, if allowed by config)
updateDetectionArea(area)Update detection boundsOn resize/orientation change
close()Closes the flowAnytime
reset()Resets to initial stateOnly after finished or error (not expired)
stop()Cleanup resourcesWhen unmounting
getState()Returns current stateAnytime
subscribe(callback)Subscribe to state changesReturns unsubscribe function

Configuration Options

type IdCaptureConfig = {
  showTutorial?: boolean;          // Show tutorial (default: false)
  showDocumentChooserScreen?: boolean; // Show document type chooser
  enableId?: boolean;              // Enable ID card capture (default: true)
  enablePassport?: boolean;        // Enable passport capture (default: false)
  onlyBack?: boolean;              // Capture back side only
  autoCaptureTimeout?: number;     // Seconds before auto-capture (default: 5)
  captureAttempts?: number;        // Maximum retry attempts (default: 3)
  enableIdRecording?: boolean;     // Enable video recording
  usSmartCapture?: boolean;        // US-specific smart capture
  ageAssurance?: boolean;          // Enable age assurance features
  showCaptureButtonInAuto?: boolean; // Show manual button during auto-capture
};

Capture-only variant

createIdCaptureOnlyManager (from @incodetech/core/id) exposes the same API surface as createIdCaptureManager with one additional config requirement — an onCapture(response) callback. The flow bypasses Incode's ID upload pipeline and delivers the captured front (and back, when applicable) images back to the integrator. Use it when you're building a hybrid pipeline. See Module: ID Capture → Capture-only flow for the full payload shape, including the croppedImage caveat.


Orchestrated Flow Manager

The Orchestrated Flow Manager coordinates multiple modules based on backend configuration, automatically handling module sequencing and flow state.

Quick Start

import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createOrchestratedFlowManager } from '@incodetech/core/flow';
import { phoneMachine } from '@incodetech/core/phone';
import { emailMachine } from '@incodetech/core/email';
import { selfieMachine } from '@incodetech/core/selfie';
import { idCaptureMachine } from '@incodetech/core/id';

// Preload both ML pipelines via setup() — defaults to the Incode CDN.
// Omit `wasm` (or pass `wasm: false`) to skip preload and let the
// pipelines load lazily when the user reaches a camera step.
await setup({
  apiURL: 'https://demo-api.incodesmile.com',
  wasm: { pipelines: ['selfie', 'idCapture'] },
});
await initializeSession({ token: 'your-session-token' });

// Create flow manager. Register every machine your flow needs.
const flowManager = createOrchestratedFlowManager({
  modules: {
    PHONE: phoneMachine,
    EMAIL: emailMachine,
    SELFIE: selfieMachine,
    ID: idCaptureMachine,
    SECOND_ID: idCaptureMachine, // only if your flow captures a second ID
  },
});

// Subscribe to flow state changes
flowManager.subscribe((flowState) => {
  console.log('Flow status:', flowState.status);
  console.log('Current step:', flowState.currentStep);
  console.log('Steps:', flowState.steps);
});

// Load the flow from backend
flowManager.load();

Module Registration

Register a state machine for every module key your flow uses. The orchestrator looks each step's key up in your modules map; if it can't find a machine, the flow fails with "No registered module found for flow".

ID capture step keys

The backend can return any of these keys depending on dashboard configuration:

Step KeyDescriptionNeed to register?
IDStandard ID capture.Yes — always register ID: idCaptureMachine.
SECOND_IDSecond ID document (when the flow is configured for two).Yes, when your flow captures a second ID.
TUTORIAL_IDID capture with tutorial — the legacy backend variant.No. The SDK normalizes TUTORIAL_ID into ID (and SECOND_ID when configured) before your subscribe handler runs, so the orchestrator never emits TUTORIAL_ID as a step. Registering it is harmless but unused.
THIRD_IDDeprecated.No. The SDK ignores it; do not register it.

In practice this means registering ID (and SECOND_ID when relevant) is sufficient for any ID-capture flow.

⚠️

Common Error: If you see "No registered module found for flow", you're missing one of the keys your backend flow uses. Log flowState.steps after flowManager.load() resolves to see exactly which keys you need.

Flow States

Every variant also carries homeScreen ({ visible, isContinueLoading }) and presentation ({ isAwaitingReady, lazyModuleKey, shouldPrefetchHome }). These are UI hints used by <incode-flow> for transition timing; in custom-UI integrations you can usually ignore them and let your shell own the loading/transition UX.

StatusDescriptionProperties (in addition to homeScreen and presentation)
idleInitial state
loadingLoading flow configuration
readyModule is activeflow, currentStep, currentStepIndex, steps, config, moduleState
finishedFlow completeflow, finishStatus
errorFatal errorerror

API Methods

MethodDescription
load()Load flow configuration from backend
cancel()Cancel an in-progress flow load
reset()Reset the flow to its initial idle state
completeModule()Mark the current module as complete and advance to the next step. Call from the active module's onFinish callback.
completeFlow()Skip remaining modules and go straight to the completion step. Used when the flow was already completed externally — e.g. by the desktop → mobile redirect handoff.
errorModule(error)Signal that the current module hit a fatal error. Transitions the flow into the error state.
continueFromHome()Advance past the SDK's launch / home screen. Only meaningful when enableHome: true is set and state.homeScreen.visible === true. Awaits the orchestrator-ready handshake when called from loading.
shouldRenderHomeScreen()Convenience getter returning state.homeScreen.visible. Used by <incode-flow> to decide whether to mount the launch screen; custom shells can do the same.
isAwaitingOrchestratorReady()true while the flow has loaded but the first module's machine isn't ready yet. Use to gate an initialization spinner.
waitForReady()Promise that resolves once the orchestrator has loaded the flow and the first module is ready to render. Pair with other parallel async work (theme fetch, asset preload, etc.) before unmasking the UI.
getLazyModuleKey()Returns the module key the orchestrator wants you to lazy-load next, or undefined. Drives module-chunk prefetching.
getModuleConfig<T>()Returns the current step's module configuration with workflow-level flags (e.g. ds) merged in. Typed via the generic parameter.
isModuleEnabled(moduleKey)Returns whether the module key appears in the active flow's step list. Useful for "is this user going to hit ID capture?" gating.
canNext (getter)true when completeModule() is safe to call right now (current module has finished and the orchestrator is ready to advance).
send(event)Send a raw event to the orchestrator state machine. Prefer the typed methods above; use this only for events that don't have a dedicated wrapper.
getState()Get the current flow state synchronously
subscribe(callback)Subscribe to flow state changes (returns an unsubscribe function)

Working with Individual Module Managers

When a module step is active, create the appropriate manager and handle its state:

import { createIdCaptureManager } from '@incodetech/core/id';

flowManager.subscribe((flowState) => {
  if (flowState.status !== 'ready') return;

  const { currentStep, config } = flowState;

  // ID and SECOND_ID are the only ID-capture step keys you'll actually
  // see — TUTORIAL_ID is normalized into ID before reaching subscribers,
  // and THIRD_ID is deprecated.
  const isIdStep = currentStep === 'ID' || currentStep === 'SECOND_ID';

  if (isIdStep) {
    // Create ID manager for this step
    const idManager = createIdCaptureManager({
      config: config,
    });
    
    idManager.subscribe((idState) => {
      // Handle ID capture states...
      if (idState.status === 'finished') {
        flowManager.completeModule();
      }
    });
    
    idManager.load();
  }
});

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. Use it instead of the Orchestrated Flow Manager when the workflow is configured in the Incode Workflows engine (not in the dashboard Flow). Only one orchestrator — Workflow or Flow — should be active per session.

Quick Start

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 — advance with workflow.continueFromHome().
      }
      break;
    case 'ready':
      // Render the module for state.currentNode.moduleKey using the merged
      // module config from workflow.getModuleConfig(). Call completeModule()
      // / errorModule() from the module's onFinish / onError callbacks.
      break;
    case 'asyncResolution':
      // Server is processing — show a progress UI; next node arrives soon.
      break;
    case 'finished':
      workflow.stop();
      break;
  }
});

workflow.load();

States Reference

StatusDescriptionProperties
idleInitial state before load()homeScreen
loadingFetching the next node from the workflow server (covers the transient home, resolvingModule, handlingCustomModule, processingNode, completing substates the consumer doesn't need to distinguish)homeScreen
readyCurrent WorkflowNode is active. Render the matching module using config + moduleState.workflowConfig, currentNode, config, moduleState, homeScreen
asyncResolutionServer is processing an ASYNC_RESOLUTION node. UI shows progress until the next node arrives.workflowConfig, currentNode
finishedTerminal — workflow's final node was a FINISH node.workflowConfig, finishStatus
closedUser dismissed the workflow.
errorFatal error.error, errorCode?

homeScreen is an { visible, isContinueLoading } overlay surfaced on idle, loading, and ready. When visible === true, the consumer renders the launch / intro screen and calls continueFromHome() on user confirmation. The flag is suppressed when the backend sets WorkflowConfig.disableLaunchScreen === true (see Module: Workflow).

API Methods

MethodDescription
load()Start loading the workflow configuration and the first node from the backend.
completeModule()Mark the current module as complete. Call from the active module's onFinish callback to advance to the next node.
errorModule(error)Mark the current module as failed. Transitions the workflow to a terminal error state.
completeFlow()Skip the remaining nodes and go straight to the completion step. Used when the workflow was completed externally (desktop → mobile).
continueFromHome()Advance past the launch / home screen. No-op when the home screen isn't currently visible (e.g. when disableLaunchScreen is true).
getModuleConfig<T>()Returns the current node's moduleConfiguration merged with workflow-level flags (ds). Typed via the generic parameter.
getState()Get the current workflow state synchronously.
subscribe(callback)Subscribe to workflow state changes (returns an unsubscribe function).

Custom module callback

Workflows can include CUSTOM_MODULE nodes that hand control back to your code. Wire customModuleCallback to handle them — the callback receives interviewId, nodeId, and the configured name, plus onSuccess / onError hooks that advance the workflow.

customModuleCallback: ({ interviewId, nodeId, name, onSuccess, onError }) => {
  // Run your custom check (call your backend, etc.). Then advance:
  onSuccess('Custom check passed');
  // or onError('Something went wrong');
},

Both onSuccess and onError advance the workflow to the next node — matching SDK 1 behavior. Use errorModule(error) on the manager itself if you need to abort the workflow rather than continue.

See Module: Workflow for the WorkflowConfig shape and a deeper write-up of the lifecycle.


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':
      // TypeScript knows: state.countryCode, state.phonePrefix exist
      console.log(`Country: ${state.countryCode}`);
      break;
    case 'awaitingOtp':
      // TypeScript knows: state.resendTimer, state.canResend exist
      console.log(`Resend in: ${state.resendTimer}s`);
      break;
    case 'error':
      // TypeScript knows: state.error exists
      console.error(state.error);
      break;
  }
}

Manager Lifecycle

All managers follow the same lifecycle pattern:

sequenceDiagram
    participant App as Your App
    participant Manager
    participant API as Incode API
    
    App->>Manager: createXxxManager(config)
    App->>Manager: subscribe(callback)
    Manager-->>App: initial state
    
    App->>Manager: load()
    Manager->>API: fetch initial data
    API-->>Manager: response
    Manager-->>App: state update
    
    loop User Interaction
        App->>Manager: setXxx() / submit()
        Manager->>API: API call
        API-->>Manager: response
        Manager-->>App: state update
    end
    
    App->>Manager: stop()
    Note over Manager: cleanup resources

Dashboard Event Tracking

Track events to the Incode dashboard from your custom UI:

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' },
});

See Dashboard Events for full documentation.


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

See Also