SDK reference, Incode Web SDK 2 Reference / Advanced

Headless Mode

Note

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
  • Back-end-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

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.

Headless integrations call setup() from @incodetech/core. The @incodetech/web wrapper exists to configure the component layer, so a headless app has nothing to gain from it — and importing it pulls in UI code you do not render. See Which setup() do I call?.

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.

Flow-backed standalone configuration is lazy by default. Resolve a module's Flow configuration asynchronously, then pass the result to the existing synchronous manager factory. Enable both setup prefetch and supplied-config merging when you need both behaviors:

import { setup } from '@incodetech/core';
import { resolveDashboardModuleConfig } from '@incodetech/core/flow';
import { createIdCaptureManager, type IdCaptureConfig } from '@incodetech/core/id';

await setup({
  apiURL,
  token,
  flow: { preload: true, mergeConfig: true },
});

const config = await resolveDashboardModuleConfig({
  moduleKey: 'ID',
  config: {
    showTutorial: false,
    captureAttempts: 5,
  },
});

const manager = createIdCaptureManager({ config });
manager.load();

Omitting flow or passing {} makes no setup request: supplied config is returned verbatim without a session requirement, while omitted config resolves Flow when requested.

flow: { preload: true } fetches and awaits Flow during setup but still returns supplied config verbatim. flow: { mergeConfig: true } merges supplied partial config over Flow config when the resolver runs and does not imply setup prefetch. Combine both flags for both behaviors.

flow: false prevents standalone Flow resolution; supplied config still works and omitted config errors. The resolver shares any prefetched request with orchestrated Flow, while each caller applies its own normalization. It strictly rejects Workflow-only sessions, missing Flows, and module keys absent from the active Flow. Duplicate modules can be selected with occurrence. Workflow child modules retain their orchestrator-provided configuration and do not trigger standalone fallback.


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

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 In error; create a new manager after finished
stop() Cleanup resources When unmounting
getState() Returns current state synchronously Anytime
subscribe(callback) Subscribe to state changes Returns 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 back end
  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('user@example.com', 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

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 In error; create a new manager after finished
stop() Cleanup resources When unmounting
getState() Returns current state synchronously 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

import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createSelfieManager } from '@incodetech/core/selfie';
import { resolveDashboardModuleConfig } from '@incodetech/core/flow';

// 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 config = await resolveDashboardModuleConfig({ moduleKey: 'SELFIE' });
const manager = createSelfieManager({ config });

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

Status Description Properties
idle Initial state –
loading Checking permissions –
tutorial Showing tutorial. Entered when showTutorial is true or ageAssurance is true (the age-assurance copy doubles as the privacy notice, so it survives a disabled tutorial) –
manualUpload File upload alternative phase, uploaded, canContinue, errorKey, attemptsRemaining
permissions Handling camera access permissionStatus
capture Camera active stream, captureStatus, detectionStatus, attemptsRemaining, uploadError?
processing Server-side processing of the captured selfie –
finished Capture complete processResponse?
closed User closed flow –
error Fatal error error

Manual upload

In 2.3.0, manualUploadSelfieCapture?: boolean enables file upload from the tutorial. The tutorial state includes required showManualUploadLink: boolean; use it to show goToManualUpload().

manualUpload carries phase: 'selecting' | 'uploading', uploaded: boolean, canContinue: boolean, errorKey: string | null, and attemptsRemaining: number. Add this variant to exhaustive state switches and include the tutorial flag in typed fixtures.

Call manualUploadSelectFile(file) while selecting and manualUploadContinue() when canContinue is true. Selfie accepts one file argument; ID's upload method takes (side, file). See Selfie manual upload.

Capture State Properties

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

Property Type Description
stream CameraStream | undefined Camera stream for the video element; wait until it is available before attaching it.
captureStatus string Sub-state: initializing, detecting, capturing, uploading, uploadError, success
detectionStatus DetectionStatus Face detection feedback (see below)
attemptsRemaining number Remaining capture attempts
uploadError string? Error message if upload failed
assistedOnboarding boolean Whether assisted onboarding mode was requested in config
usingBackCamera boolean Whether stream is really a back camera — mirror your <video> when false
onDeviceMode boolean When true, do not offer a manual capture button.
obfuscateWithAvatar boolean Whether a cosmetic concealment overlay is enabled.
avatarCanvas HTMLCanvasElement? Display-only canvas; unavailable until ready or when concealment is off.
avatarVariant 'avatar-3d' | 'avatar-2d' | 'privacy-lens' (optional) Derived display variant. Keep the face outline for Privacy Lens; hide it for full avatars. This is not a host-settable config field.

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

Removed: debugFrame (ImageData?) is gone from the selfie, authentication, and ID capture state types. It had already stopped carrying frames — the per-frame emission was dropped because storing a full-resolution frame roughly ten times a second re-rendered the capture screen and starved it on lower-tier devices. Reading state.debugFrame is now a type error rather than a silent undefined. If you relied on per-frame pipeline frames, contact Incode: an opt-in hook is the intended replacement.

These are read-only presentation fields. In error, error: string carries the message and moduleErrorCode?: string carries an optional code. Forward that code unchanged when reporting failure to a Flow or Workflow manager.

Detection Status Values

Show these messages to guide the user during capture:

Status User 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)
success Capture succeeded — transitioning out
error Detection error — surfaces alongside captureStatus === 'uploadError'
offline "No network connection"

Permission Status Values

When status === 'permissions':

permissionStatus Meaning
idle Ready to request permission
requesting Permission dialog shown
denied User denied camera access
learnMore Showing help screen

API Methods

Method Description When to Use
load() Starts the selfie flow Always call first
goToManualUpload() Open upload tutorial, showManualUploadLink true
manualUploadSelectFile(file) Select one selfie file manualUpload, selecting phase
manualUploadContinue() Continue after accepted upload manualUpload, canContinue true
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 available) In capture, when !onDeviceMode && detectionStatus === 'manualCapture'
retryCapture() Retry after upload error When captureStatus === 'uploadError'
close() Closes the flow Anytime
reset() Resets to initial state In error; create a new manager after finished
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 — 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 is only valid from 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 and only the results are submitted to the server — the captured image never leaves the device. Has E2EE and WASM-pipeline prerequisites. See On-Device Face Capture for the full walkthrough, including the setup-time prerequisites and code example.

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 a supported subset of optional capture UX settings and a required onCapture(response) callback. It does not expose a recording flag. The flow delivers the captured face image locally 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 { resolveDashboardModuleConfig } from '@incodetech/core/flow';
import { createIdCaptureManager, type IdCaptureConfig } 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 config = await resolveDashboardModuleConfig({ moduleKey: 'ID' });
const manager = createIdCaptureManager({ config });

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

Status Description Properties
idle Initial state –
chooser Document type selection availableDocumentTypes
loading Loading configuration –
tutorial Showing tutorial selectedDocumentType
ageVerification Regulation-required age-confirmation gate. Reached when config.ageAssurance === true. Advance with nextStep(). –
permissions Camera permission request permissionStatus
capture Active capture stream, currentMode, captureStatus, detectionStatus, counterValue, attemptsRemaining, uploadError?, needsBackCapture, canRetry
frontFinished Front side captured –
backFinished Back side captured –
mandatoryConsent Regulation-required consent screen regulationType (see Mandatory Consent)
processing Server processing –
expiredExhausted Attempts exhausted; Continue calls continueExhausted() –
digitalIdRedirect Bank verification phase, launchUrl, failureReason, providerId
deviceWallet Wallet verification phase, failReason, platform
expired Document expired after processing –
manualUpload Manual file upload flow (see Manual Upload) full shape below
digitalIdUpload Digital ID upload sub-flow — PDF e-IDs uploaded by file picker (see Digital ID Upload) full shape below
finished Capture complete –
closed User closed flow –
error Fatal error error

Loading, tutorial, and permission properties

Property Type Available in
phase 'bootstrapping' | 'permissionsIdle' | 'checkingStream' | 'initializingDeepsight' | 'initializingCamera' loading
phase 'checkingPermission' | 'ready' | 'initializingCamera' | 'waitingForPermission' | 'waitingForCamera' tutorial
selectedDocumentType IdDocumentType | undefined loading, tutorial, permissions
currentMode 'front' | 'back' loading, tutorial, permissions
tutorialEnabled boolean loading, permissions
permissionStatus PermissionStatus permissions

Keep the document and side visible while waiting. permissionsIdle is a silent permission check; avoid flashing a new full-screen loader. Tutorial ready waits for Continue. waitingForPermission and waitingForCamera mean Continue was already requested; show a waiting state. Treat the other loading phases as preparation without exposing implementation details to the user.

Capture State Properties

When status === 'capture':

Property Type Description
captureFeedbackMode 'countdown' | 'optimizing' Capture presentation discriminator.
acceptedDocuments string[]? Accepted-document names for upload-error UI. Entries are strings; hide absent/empty lists and tolerate unknown names.
countryCode string? Country from the current upload response.
stream MediaStream Camera stream for <video> element
currentMode 'front' | 'back' Which side is being captured
captureStatus string Sub-state: initializing, detecting, capturing, optimizing, uploading, uploadError, success (see important notes below)
detectionStatus string Document detection feedback (see below)
counterValue number Auto-capture countdown (seconds)
attemptsRemaining number Remaining capture attempts
uploadError string? Error code if upload failed
uploadErrorMessage string? Human-readable error message
uploadErrorDescription string? Detailed error description
needsBackCapture boolean Whether back capture is needed
usingBackCamera boolean Whether the active stream uses a rear camera. Mirror a front-camera preview; leave the rear-camera preview unmirrored.
barcodeFastPathCaptured boolean Render barcode-capture success without a captured-image preview when true.
needsFrontCapture boolean A front-side capture is required after the back. Use continueToFront() when the completed capture awaits that follow-up.
showCaptureButtonInAuto boolean Show manual capture button in auto mode
canRetry boolean Whether retry is available
orientation string? Detected document orientation
idType string? Detected ID type
previewImageUrl string? URL of captured image preview
uploadProgress number? Upload progress (0-100)

When status === 'mandatoryConsent':

Property Type Description
regulationType RegulationTypes Which 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 back end forces it via configuration. The state has tab-aware UI hints (ID vs Passport), per-slot file metadata, and a continue gate.

With onlyBack: true, render only the ID tab and back-of-ID slot. canContinue becomes true after the back upload succeeds. If manual upload is disabled, onlyBack skips the chooser. If manual upload and showDocumentChooserScreen are enabled, availableDocumentTypes contains only id and manualIdUpload.

Property Type Description
phase 'selecting' | 'uploading' | 'exhausted' 'selecting' while the user picks files, 'uploading' during upload, 'exhausted' after retries are spent.
uploadingSide 'front' | 'back' | 'passport' | undefined Which side is currently uploading; undefined outside phase === 'uploading'.
activeTab 'id' | 'passport' Currently selected tab. Use manualUploadChangeTab() to switch.
showIdTab boolean Whether the ID tab should be rendered (driven by config).
showPassportTab boolean Whether the Passport tab should be rendered.
showFrontSlot boolean | undefined On the ID tab, whether to render the front-of-ID slot. Treat undefined as true for compatibility. It is false in back-only mode.
showBackSlot boolean On the ID tab, whether to render the back-of-ID slot. It is false in front-only mode and true in back-only or two-sided mode.
frontFileName string | undefined File name the user picked for the front of the ID, if any.
backFileName string | undefined File name for the back of the ID, if any.
passportFileName string | undefined File name for the passport upload, if any.
frontUploaded boolean True once the front file has been accepted server-side.
backUploaded boolean True once the back file has been accepted server-side.
passportUploaded boolean True once the passport file has been accepted server-side.
canContinue boolean Whether the Continue button should be enabled. Use this to gate manualUploadContinue().
retriesLeft number Remaining retries on the current tab.
errorSide 'front' | 'back' | 'passport' | null Slot associated with the current upload error.
errorKey string | null i18n 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 back end 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).

Property Type Description
phase union '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.
file File | null The PDF the user picked (null outside 'reviewing' / 'uploading' / 'holding').
fileName string | undefined The file's display name. Use this to render the preview row.
failReason DigitalUploadFailReason | null Set when phase === 'error'. Union: 'DIGITAL_ID_REQUESTED_BUT_OTHER_PROVIDED', 'ID_TYPE_UNACCEPTABLE', 'FILE_CHANGED_ERROR', 'INVALID_FILE_TYPE', 'NETWORK_ERROR', 'GENERIC'.
attemptsRemaining number Remaining upload retries (starts at 3). Hits 0 → phase === 'exhausted'.
uploadProgress number Upload progress 0–100. Only meaningful in 'uploading' / 'holding'.
pickerRequestId number Monotonic 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. tutorial → selecting (host opens picker on each pickerRequestId change) → reviewing (user confirms or replaces) → uploading → success (auto-advances out of the sub-flow) or error → back to selecting, until attemptsRemaining === 0 → exhausted. 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).

Digital ID redirect and wallet states

Digital methods must be enabled in Dashboard and supported by the current device. Render options from chooser.availableDocumentTypes; do not offer an unavailable wallet route.

Status Property Type / behavior
digitalIdRedirect phase 'loading' | 'redirecting' | 'success' | 'failed' | 'error'
digitalIdRedirect launchUrl string | undefined; navigate only when phase === 'redirecting' and a URL is present.
digitalIdRedirect failureReason, providerId Optional public failure/provider identifiers. See Bank Redirect.
deviceWallet phase 'requesting' | 'presenting' | 'decrypting' | 'failed'. Show progress for requesting/decrypting and wait for the device sheet during presenting.
deviceWallet failReason 'UNSUPPORTED_PLATFORM' | 'NO_CREDENTIAL' | 'WALLET_ERROR' | null. Render localized recovery guidance.
deviceWallet platform 'ios' | 'android' | undefined

Wallet completion advances the ID manager to finished; identity attributes are retrieved through session results, not the wallet state. On failure, offer retry or another configured method. See the mDL headless example.

Expired-document recovery

expired exposes attemptsRemaining and currentMode. Once attempts are exhausted, render expiredExhausted with a Continue action calling continueExhausted(). The configured journey determines whether the SDK captures another side, completes processing, or finishes this module. Continue does not establish an approved verification; use the session result.

Important: Capture State Transitions

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

Optional result preview (captureStatus === 'optimizing')

The built-in web UI opts into an IDC-05 preview that runs during capture: partway through the frame-selection window it freezes the best frame found so far while the camera and frame analysis keep going. Headless integrations skip this state by default and continue directly from capture to upload.

Managers created by the built-in IdCapture component receive the SDK-owned experiment treatment when they declare the capability. Integrations that create an ID manager themselves, including custom components composed from the extensibility subpath, do not participate in SDK-driven experiments automatically unless they opt in with supportsOptimizingStatus: true.

To implement your own flash/frozen-preview treatment, create the manager with supportsOptimizingStatus: true. The optimizing state appears only when the SDK resolves the treatment arm — your integration must handle both the preview path and the direct capture-to-upload path. Pass false to opt out of the experiment entirely (no store read, no exposure, no assignment).

While in optimizing, optimizingDwellMs reports how long the SDK holds the state, and the frame to display depends on which capture path produced it:

Capture path Preview runs Frame to display
Automatic capture before the commit candidateImageUrl
Manual capture (tap) after the commit previewImageUrl

Treat optimizingDwellMs as a minimum, not an exact duration. The SDK runs the dwell on its own timer on both paths; on automatic capture the state additionally waits for the frame-selection loop to commit a frame, which can take slightly longer than the dwell. Don't build layout or animation that assumes an exact duration; react to the state leaving optimizing instead.

Reading state.candidateImageUrl ?? state.previewImageUrl covers both without branching on the path. On the automatic path that image is display-only — detection has not finished, and the frame that actually uploads arrives later on previewImageUrl. The SDK leaves the state by itself in both cases; there is nothing to call:

const idManager = createIdCaptureManager({
  config: {
    ...idConfig, // Complete IdCaptureConfig resolved from your active Flow
    supportsOptimizingStatus: true,
  },
});

idManager.subscribe((state) => {
  if (
    state.status === 'capture' &&
    state.captureStatus === 'optimizing'
  ) {
    // Automatic capture freezes the best frame so far; manual capture the
    // already-committed one.
    showResultPreview(state.candidateImageUrl ?? state.previewImageUrl);
  }
});

optimizing is not a waiting state: it is bounded by the SDK's frame-selection window on the automatic path and by an SDK-owned timer on the manual one, so it advances on its own either way. There is no callback to fire, and nextStep() has no effect here.

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 error. Create a new manager after the final finished state.

Detection Status Values

Show these messages to guide the user during capture:

Status User 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 Code Description User Action
UPLOAD_ERROR Upload failed Retry capture
CLASSIFICATION_FAILED Document not recognized Use a clearer image
LOW_SHARPNESS Image too blurry Hold device steadier
GLARE_DETECTED Glare on document Adjust lighting/angle
WRONG_DOCUMENT_SIDE Wrong side shown Flip the document
ID_TYPE_UNACCEPTABLE Document type not supported Use a different ID
READABILITY_ISSUE Cannot read document Improve lighting
RETRY_EXHAUSTED_CONTINUE_TO_BACK Front retries exhausted Auto-advances to back
RETRY_EXHAUSTED_SKIP_BACK Back retries exhausted Auto-advances
NO_MORE_TRIES Max attempts reached Flow will end
UNEXPECTED_ERROR Internal error Retry or contact support
NO_TOKEN Session token missing Re-initialize SDK
PERMISSION_DENIED Camera access denied Enable in browser settings
USER_CANCELLED User cancelled Re-open module
SERVER_ERROR Server-side error Retry later

API Methods

Method Description When to Use
continueExhausted() Continue after expired-document attempts are exhausted expiredExhausted
digitalIdRedirectRetry() Restart redirect verification digitalIdRedirect, failed or error phase
digitalIdRedirectUseAnotherMethod() Leave the redirect and use configured fallback digitalIdRedirect, failed or error phase
digitalIdRedirectContinue() Acknowledge the redirect success screen digitalIdRedirect, success phase
digitalIdRedirectRefresh() Refresh the pending launch URL digitalIdRedirect, redirecting phase
deviceWalletRetry() Retry wallet verification deviceWallet, failed phase
deviceWalletUseAnotherMethod() Return to the chooser, or close when no chooser is configured deviceWallet, failed phase
load() Starts the ID capture flow Always call first
selectDocument(type) Selects document type When chooser, pass 'id' or 'passport'
nextStep() Advances to next step From tutorial → permissions, **and from **captureStatus === 'success' → frontFinished/processing
requestPermission() Requests camera permission When permissions.idle
goToLearnMore() Shows permission help When permissions.idle
back() Goes back When permissions.learnMore
capture() Manual capture When detectionStatus === 'manualCapture'
switchToManualCapture() Switch to manual mode During auto-capture
retryCapture() Retry capture from beginning When captureStatus === 'uploadError' **or **status === 'expired'
continueFromError() Continue after non-fatal error When error allows continuation
continueToBack() Proceed to back capture When frontFinished
continueToFront() Flip back to front capture When backFinished
skipBack() Skip back capture When back is optional
acceptMandatoryConsent() Accept the regulation-required consent When mandatoryConsent
cancelMandatoryConsent() Decline the regulation-required consent When 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 tabs When manualUpload and both tabs are shown
manualUploadContinue() Submit the selected manual-upload files When manualUpload and state.canContinue is true
manualUploadReset() Clear all selected files on the active tab and start over When manualUpload
digitalUploadNextStep() Advance from the digital-upload tutorial to the picker When digitalIdUpload and phase === 'tutorial'
digitalUploadPickFile(file) Hand a picked PDF to the SDK for review When digitalIdUpload and phase === 'selecting'
digitalUploadConfirm() Confirm the previewed file and start uploading When digitalIdUpload and phase === 'reviewing'
digitalUploadReplace() Discard the previewed file and re-open the picker When 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 fileTooLarge When digitalIdUpload and phase === 'fileTooLarge'
digitalUploadScanInstead() Abandon digital upload and fall back to live capture When digitalIdUpload (any phase, if allowed by config)
updateDetectionArea(area) Update detection bounds On resize/orientation change
close() Closes the flow Anytime
reset() Resets to initial state Only in error (not expired or final finished)
stop() Cleanup resources When unmounting
getState() Returns current state Anytime
subscribe(callback) Subscribe to state changes Returns unsubscribe function

Configuration Options

Import IdCaptureConfig from @incodetech/core/id. The core factory requires its complete Dashboard fields; use resolveDashboardModuleConfig({ moduleKey: 'ID' }) after session activation or supply a complete object.

See ID configuration for all supported fields, including digitalIds, digitalIdReturnUrl, digitalIdReturn, and device-wallet availability. Do not replace the required core type with an optional-field copy.

Capture-only variant

createIdCaptureOnlyManager (from @incodetech/core/id) exposes the same API surface as createIdCaptureManager with a supported subset of optional capture UX settings and a required 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 back-end 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);
  if (flowState.status === 'ready') {
    console.log('Current step:', flowState.currentStep);
    console.log('Steps:', flowState.steps);
  } else if (flowState.status === 'error') {
    console.error(flowState.error);
  }
});

// Load the flow from back end
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 back end can return any of these keys depending on dashboard configuration:

Step Key Description Need to register?
ID Standard ID capture. Yes — always register ID: idCaptureMachine.
SECOND_ID Second ID document (when the flow is configured for two). Yes, when your flow captures a second ID.
TUTORIAL_ID ID capture with tutorial — the legacy back-end 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_ID Deprecated. 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.

Warning

Common Error: If you see "No registered module found for flow", you're missing one of the keys your back-end flow uses. Read flowState.steps in a subscription after narrowing to status === 'ready' 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.

Status Description Properties (in addition to homeScreen and presentation)
idle Initial state –
loading Loading flow configuration –
ready Module is active flow, currentStep, currentStepIndex, steps, config, moduleState
completing Completing the flow with the back end –
finished Flow complete flow, finishStatus
error Flow failed error?: string, errorCode?: number, moduleErrorCode?: string

Narrow on state.status before reading variant fields. For example, read state.moduleErrorCode only when state.status === 'error'; it may still be undefined.

API Methods

Method Description
load() Load flow configuration from back end
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() Complete early, skipping remaining steps and notifying the back end.
finishFlow() Finish locally after an external handoff has already completed the flow, such as desktop-to-mobile. Does not ask the back end to complete again.
errorModule(error?, moduleErrorCode?) Report a module failure. Supported module error codes can advance to the next step or completion; other failures enter error. Forward the module code unchanged so the SDK applies the appropriate behavior.
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)
subscribeFlowEvent(listener) Subscribe to curated flow milestones; returns an unsubscribe function. See headless flow events.

Working with Individual Module Managers

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

import { createIdCaptureManager, type IdCaptureConfig } 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: flowManager.getModuleConfig<IdCaptureConfig>(),
    });

    idManager.subscribe((idState) => {
      // Handle ID capture states...
      if (idState.status === 'finished') {
        flowManager.completeModule();
      }
    });

    idManager.load();
  }
});

Legacy Flow manager

createFlowManager is a separate compatibility API, with nextStep(), prevStep(), canNext, canPrev, and keyed getModuleConfig(moduleKey). It is not interchangeable with the modern orchestrator. See the legacy API reference.

createMandatoryConsentManager uses a required config.consentType, optional language, and type: 'MANDATORY' | 'ML'. Its display state exposes the supplied consent text and acceptance state. Drive it with toggle(), submit(), and cancel(); these differ from ID's embedded consent actions. See standalone mandatory consent for configuration, states, and action gates.

Workflow Manager

The Workflow Manager runs server-driven multi-step onboarding workflows, where the next step (and its configuration) is chosen by the back end 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

Status Description Properties
idle Initial state before load() homeScreen
loading Fetching the next node from the workflow server (covers the transient home, resolvingModule, handlingCustomModule, processingNode, and score-resolution phases the consumer doesn't need to distinguish) homeScreen
ready Current WorkflowNode is active. Render the matching module using config + moduleState. workflowConfig, currentNode, config, moduleState, homeScreen
completing Completing the workflow before the terminal node arrives homeScreen
asyncResolution Server is processing an ASYNC_RESOLUTION node. UI shows progress until the next node arrives. workflowConfig, currentNode
finished Terminal — workflow's final node was a FINISH node. workflowConfig, finishStatus, scoreStatus
closed User dismissed the workflow. –
error Fatal error. error?, errorCode?, moduleErrorCode?

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

API Methods

Method Description
load() Start loading the workflow configuration and the first node from the back end.
completeModule() Mark the current module as complete. Call from the active module's onFinish callback to advance to the next node.
errorModule(error?, moduleErrorCode?) Report a module failure. Supported module error codes can advance to the next step or completion; other failures enter error. Forward the module code unchanged so the SDK applies the appropriate behavior.
completeFlow() Complete early, skipping remaining steps and notifying the back end.
finishWorkflow() Finish locally after an external handoff has already completed the workflow, such as desktop-to-mobile. Does not ask the back end to complete again.
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).
subscribeFlowEvent(listener) Subscribe to curated flow milestones; returns an unsubscribe function. See headless flow events.

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 back end, 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. The manager method errorModule(error?, moduleErrorCode?) is separate: its outcome depends on the supplied module error code, as described above.

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, type SelfieConfig } from '@incodetech/core/selfie';

function SelfieCapture({ config }: { config: SelfieConfig }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [manager] = useState(() =>
    createSelfieManager({ config }),
  );
  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.onDeviceMode && 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 expose subscribe(), getState(), and stop(). Start and recovery methods vary: most use load(), while Video Selfie uses start(). After a final state, create a new manager instead of resetting it. The following diagram illustrates a manager that starts with load():

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

Was this page helpful?