# Headless Mode

<WebSDK2 />

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.

```mermaid
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:

```typescript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';

await setup({ apiURL: 'https://demo-api.incodesmile.com' });
await initializeSession({ token: 'your-session-token' /* from createSession() */ });
```

`setup` provisions the HTTP client and (optionally) warms up WASM; `initializeSession` activates the session token and pre-loads session-scoped state. The two-call form lets you start `setup` (including WASM warmup) before the token is known. The one-shot form `setup({ apiURL, token })` is still supported as a convenience — it delegates to `initializeSession` for you.

***

## Phone Verification

### Quick Start

```typescript
import { createPhoneManager } from '@incodetech/core/phone';

// 1. Create manager with configuration
const manager = createPhoneManager({
  config: {
    otpVerification: true,
    otpExpirationInMinutes: 5,
    prefill: false,
  },
});

// 2. Subscribe to state changes
manager.subscribe((state) => {
  console.log('Status:', state.status);
  
  if (state.status === 'finished') {
    console.log('Phone verified!');
    manager.stop();
  }
});

// 3. Start the flow
manager.load();

// 4. When state is 'inputting', set the phone number
manager.setPhoneNumber('+14155551234', true);
manager.submit();

// 5. When state is 'awaitingOtp', submit the OTP
manager.submitOtp('ABC123');
```

### State Machine Flow

```mermaid
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                        | After `finished` or `error`         |
| `stop()`                         | Cleanup resources                              | When unmounting                     |
| `getState()`                     | Returns current state synchronously            | Anytime                             |
| `subscribe(callback)`            | Subscribe to state changes                     | Returns unsubscribe function        |

### Configuration Options

```typescript
type PhoneConfig = {
  otpVerification: boolean;       // Require OTP verification (default: true)
  otpExpirationInMinutes: number; // OTP validity in minutes (default: 5)
  prefill: boolean;               // Fetch pre-filled phone from backend
  isInstantVerify?: boolean;      // Use instant verification API
  optinEnabled?: boolean;         // Show marketing opt-in checkbox
  maxOtpAttempts?: number;        // Max OTP attempts (default: 3)
};
```

***

## Email Verification

Email verification works identically to phone verification.

### Quick Start

```typescript
import { createEmailManager } from '@incodetech/core/email';

const manager = createEmailManager({
  config: {
    otpVerification: true,
    otpExpirationInMinutes: 5,
    prefill: false,
  },
});

manager.subscribe((state) => {
  if (state.status === 'finished') {
    console.log('Email verified!');
    manager.stop();
  }
});

manager.load();
manager.setEmail('user@example.com', true);
manager.submit();

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

### State Machine Flow

```mermaid
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             | After `finished` or `error`         |
| `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

```typescript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createSelfieManager } from '@incodetech/core/selfie';

// Preload the selfie WASM pipeline as part of setup() — Incode CDN
// defaults are used unless you override paths. See WASM Configuration
// for self-hosted paths and the lower-level warmupWasm() API.
await setup({
  apiURL: 'https://demo-api.incodesmile.com',
  wasm: { pipelines: ['selfie'] },
});
await initializeSession({ token: 'your-token' });

const manager = createSelfieManager({
  config: {
    showTutorial: true,
    autoCaptureTimeout: 10,
    validateLenses: true,
    validateFaceMask: true,
  },
});

manager.subscribe((state) => {
  switch (state.status) {
    case 'tutorial':
      // Show your tutorial UI
      // Call manager.nextStep() when user is ready
      break;
      
    case 'permissions':
      // Show permission request UI
      if (state.permissionStatus === 'denied') {
        // Show instructions to enable camera
      }
      break;
      
    case 'capture':
      // Camera is active
      // state.stream - MediaStream for <video> element
      // state.detectionStatus - Current face detection feedback
      // state.captureStatus - 'detecting', 'capturing', 'uploading', etc.
      break;
      
    case 'finished':
      console.log('Selfie captured!', state.processResponse);
      manager.stop();
      break;
      
    case 'error':
      console.error('Error:', state.error);
      break;
  }
});

manager.load();
```

### State Machine Flow

```mermaid
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                              | –                                                                                 |
| `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`                                                                           |

### Capture State Properties

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

| Property             | Type              | Description                                                                                |
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------ |
| `stream`             | `CameraStream`    | Camera stream for `<video>` element                                                        |
| `captureStatus`      | `string`          | Sub-state: `initializing`, `detecting`, `capturing`, `uploading`, `uploadError`, `success` |
| `detectionStatus`    | `DetectionStatus` | Face detection feedback (see below)                                                        |
| `attemptsRemaining`  | `number`          | Remaining capture attempts                                                                 |
| `uploadError`        | `string?`         | Error message if upload failed                                                             |
| `assistedOnboarding` | `boolean`         | Whether assisted onboarding mode is active                                                 |
| `debugFrame`         | `ImageData?`      | Latest processed frame (for debugging)                                                     |

> **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:

| 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                                  |
| `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) | When `detectionStatus === 'manualCapture'`         |
| `retryCapture()`      | Retry after upload error        | When `captureStatus === 'uploadError'`             |
| `close()`             | Closes the flow                 | Anytime                                            |
| `reset()`             | Resets to initial state         | After `finished` or `error`                        |
| `stop()`              | Cleanup resources               | When unmounting                                    |
| `getState()`          | Returns current state           | Anytime                                            |
| `subscribe(callback)` | Subscribe to state changes      | Returns unsubscribe function                       |

### Handling cancellation (`closed`)

When the user dismisses the selfie flow — 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:

```typescript
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](doc:web-sdk-2-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:

```typescript
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](doc:web-sdk-2-module-selfie-1#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

```typescript
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

```mermaid
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](#mandatory-consent-state-properties))                                                                  |
| `processing`       | Server processing                                                                                                             | –                                                                                                                                                |
| `expired`          | Document expired after processing                                                                                             | –                                                                                                                                                |
| `manualUpload`     | Manual file upload flow (see [Manual Upload](#manual-upload-state-properties))                                                | full shape below                                                                                                                                 |
| `digitalIdUpload`  | Digital ID upload sub-flow — PDF e-IDs uploaded by file picker (see [Digital ID Upload](#digital-id-upload-state-properties)) | full shape below                                                                                                                                 |
| `finished`         | Capture complete                                                                                                              | –                                                                                                                                                |
| `closed`           | User closed flow                                                                                                              | –                                                                                                                                                |
| `error`            | Fatal error                                                                                                                   | `error`                                                                                                                                          |

### Capture State Properties

When `status === 'capture'`:

| Property                  | Type                | Description                                                                                                            |
| ------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `stream`                  | `MediaStream`       | Camera stream for `<video>` element                                                                                    |
| `currentMode`             | `'front' \| 'back'` | Which side is being captured                                                                                           |
| `captureStatus`           | `string`            | Sub-state: `initializing`, `detecting`, `capturing`, `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                                                                                         |
| `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)                                                                                                |

### Mandatory Consent State Properties

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:

```typescript
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.

| 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.                                                                                                                                                                            |
| `showBackSlot`     | `boolean`                                      | On 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). |
| `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.                                                                                                                                                                                   |
| `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 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).

| 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).

### 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:

```typescript
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()`**:

```typescript
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:

| 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                                                                                                |
| ------------------------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `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** after `finished` or `error` (not `expired`)                                                       |
| `stop()`                             | Cleanup resources                                                                    | When unmounting                                                                                            |
| `getState()`                         | Returns current state                                                                | Anytime                                                                                                    |
| `subscribe(callback)`                | Subscribe to state changes                                                           | Returns unsubscribe function                                                                               |

### Configuration Options

```typescript
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](doc:web-sdk-2-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

```typescript
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 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 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_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.

> ⚠️ **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.

| 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` |
| `finished` | Flow complete              | `flow`, `finishStatus`                                                      |
| `error`    | Fatal error                | `error`                                                                     |

### API Methods

| Method                          | Description                                                                                                                                                                                                  |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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:

```typescript
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

```typescript
import { setup } from '@incodetech/core';
import { initializeSession } from '@incodetech/core/session';
import { createWorkflowManager } from '@incodetech/core/workflow';

await setup({
  apiURL: 'https://demo-api.incodesmile.com',
  wasm: { pipelines: ['selfie', 'idCapture'] },
});
await initializeSession({ token: 'your-session-token' });

const workflow = createWorkflowManager({
  interviewId: session.interviewId,
  isDesktop: matchMedia('(min-width: 768px)').matches,
  customModuleCallback: ({ name, interviewId, nodeId, onSuccess, onError }) => {
    // Run your custom business logic, then advance the workflow.
    onSuccess('handled');
  },
});

workflow.subscribe((state) => {
  switch (state.status) {
    case 'idle':
    case 'loading':
      if (state.homeScreen.visible) {
        // Render launch screen — 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`, `completing` substates 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` |
| `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`                                       |
| `closed`          | User dismissed the workflow.                                                                                                                                                                                | –                                                                      |
| `error`           | Fatal 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](doc:web-sdk-2-module-workflow)).

### API Methods

| Method                 | Description                                                                                                                            |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `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.

```typescript
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](doc:web-sdk-2-module-workflow) for the `WorkflowConfig` shape and a deeper write-up of the lifecycle.

***

## Building Custom UI

### React Example: Phone Verification

```tsx
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

```tsx
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:

```typescript
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:

```mermaid
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:

```typescript
import { addEvent, moduleOpened, moduleClosed, eventModuleNames } from '@incodetech/core/events';

// Track module lifecycle
useEffect(() => {
  moduleOpened(eventModuleNames.phone);
  return () => moduleClosed(eventModuleNames.phone);
}, []);

// Track custom events
addEvent({
  code: 'customButtonClicked',
  module: eventModuleNames.phone,
  payload: { buttonId: 'submit' },
});
```

See [Dashboard Events](doc:web-sdk-2-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

* [Individual Modules](doc:web-sdk-2-individual-modules): Using pre-built UI components
* [Web Components](doc:web-sdk-2-web-components): Framework-agnostic components
* [IncodeFlow Component](doc:web-sdk-2-incodeflow-component): Orchestrated flows with UI
* [WASM Configuration](doc:web-sdk-2-wasm): Setting up WebAssembly for ML features
