> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.withverifa.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.withverifa.com/_mcp/server.

# JavaScript SDK

> Embed Verifa's verification flow as a modal, popup, or redirect with a single script tag.

The Verifa JavaScript SDK handles the frontend capture experience — opening the
verification UI as a popup, modal, or redirect. Your backend creates the
session, the SDK handles the rest.

## Two API styles

Verifa exposes the same capabilities through two equivalent JavaScript APIs:

| Style                          | Recommended for                                    | Example                                                |
| ------------------------------ | -------------------------------------------------- | ------------------------------------------------------ |
| **`new Verifa.Client({...})`** | New integrations. Matches Persona-style class API. | `new Verifa.Client({ captureUrl, onComplete }).open()` |
| **`Verifa.open({...})`**       | Existing integrations. Returns a `Promise`.        | `await Verifa.open({ captureUrl })`                    |

Both call the same underlying code path. Pick one and stay consistent. The rest
of this page uses the flat `Verifa.open()` form because it's been around longer;
[skip to the `Verifa.Client` reference](#verifa-client-class-api) for the new form.

## Recommended integration

The most secure and production-ready approach: your backend creates the session
using a secret API key, **immediately exchanges it for a short-lived embed
token**, and passes ONLY that token's URL to the frontend. The long-lived
capture URL never leaves your server.

This matches the pattern used by Persona (`inquiry session token`), Stripe
Identity (`client_secret`), and Plaid Link (`link_token`).

### 1. Add the script

```html
<script
  src="https://cdn.withverifa.com/dist/verifa-v1.0.0.js"
  integrity="sha384-/2DZ+OBwe8NRgYq4X0/qpvx7e7s9CjFy0K7WZX4E5utplz4njxn3A0tNTXNsK4fm"
  crossorigin="anonymous"
></script>
```

Or via npm:

```sh
npm install @verifa/sdk
```

```js
import Verifa from "@verifa/sdk";
```

## Required host-page headers

The widget loads the capture flow inside an `<iframe>` on your page. For the
iframe to access the camera and render correctly, your page must permit it.
**Most customers don't set the relevant headers at all** — in which case
browser defaults apply and everything works out of the box. If your page does
set `Content-Security-Policy` or `Permissions-Policy`, you must explicitly
allow Verifa's origin.

### Content-Security-Policy

```
Content-Security-Policy:
  frame-src https://app.withverifa.com;
  script-src https://cdn.withverifa.com;
  connect-src https://app.withverifa.com;
```

### Permissions-Policy

Delegate camera and microphone to Verifa's origin (the iframe's `allow=`
attribute alone is not sufficient — the host's Permissions-Policy is the gate):

```
Permissions-Policy:
  camera=(self "https://app.withverifa.com"),
  microphone=(self "https://app.withverifa.com")
```

### Symptom of misconfiguration

If your headers block the iframe, the modal opens but the camera permission
prompt never appears and the capture page stays blank. The SDK detects this
case and emits `onError({ code: "ready_timeout", message: "..." })` after
8 seconds (configurable via `readyTimeoutMs`). Check your browser's
developer console for messages mentioning *"Content Security Policy"*,
*"Permissions-Policy"*, or *"frame-ancestors"* — those will pinpoint the
exact header that needs to change.

### 2. Create a session and issue a client token on your server

Two API calls, both with the secret API key:

#### Python

```python
import requests

HEADERS = {"X-API-Key": "vk_live_your_secret_key"}

# 1. Create the session
session = requests.post(
    "https://api.withverifa.com/api/v1/sessions",
    headers=HEADERS,
    json={"external_ref": user_id, "country": "US"},
).json()

# 2. Issue a 30-minute embed token for it
token = requests.post(
    f"https://api.withverifa.com/api/v1/sessions/{session['id']}/client-token",
    headers=HEADERS,
).json()

# Pass ONLY these to your frontend — never session["capture_url"]:
return {
    "embed_url":     token["embed_url"],
    "client_token":  token["client_token"],   # if you want the SDK's sessionToken form
    "session_id":    session["id"],
    "expires_at":    token["expires_at"],
}
```

#### Node

```javascript
const HEADERS = {
  'X-API-Key': 'vk_live_your_secret_key',
  'Content-Type': 'application/json',
};
const API = 'https://api.withverifa.com/api/v1';

const session = await fetch(`${API}/sessions`, {
  method: 'POST',
  headers: HEADERS,
  body: JSON.stringify({ external_ref: userId, country: 'US' }),
}).then(r => r.json());

const token = await fetch(`${API}/sessions/${session.id}/client-token`, {
  method: 'POST',
  headers: HEADERS,
}).then(r => r.json());

return {
  embed_url:    token.embed_url,
  client_token: token.client_token,
  session_id:   session.id,
  expires_at:   token.expires_at,
};
```

#### cURL

```bash
# Create session
SESSION_ID=$(curl -s -X POST https://api.withverifa.com/api/v1/sessions \
  -H "X-API-Key: vk_live_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{"external_ref": "user_42"}' \
  | jq -r .id)

# Issue client token
curl -X POST "https://api.withverifa.com/api/v1/sessions/$SESSION_ID/client-token" \
  -H "X-API-Key: vk_live_your_secret_key"
# → { "client_token": "ey...", "embed_url": "https://...", "expires_at": "..." }
```

The `embed_url` and `client_token` both expire in 30 minutes. The long-lived
session capture URL (`session.capture_url`) lives 24 hours, but **you don't
need to expose it to the browser** — that's the whole point of this pattern.
If a user takes a long time to finish, re-issue the client token on your
backend before it expires.

### 3. Open the capture UI on your frontend

```html
<button id="verify-btn">Verify Identity</button>

<script>
  document.getElementById('verify-btn').addEventListener('click', async () => {
    // Call YOUR backend — it returns the short-lived embed URL, not the API key
    const { embed_url, session_id } = await fetch('/api/start-verification', {
      method: 'POST',
    }).then(r => r.json());

    new Verifa.Client({
      captureUrl: embed_url,            // simplest path — just use the URL
      onComplete: ({ sessionId, status }) => console.log('done', sessionId, status),
      onCancel:   () => console.log('user closed'),
      onError:    ({ code, message }) => console.error(code, message),
    }).open();
  });
</script>
```

Equivalent using the Persona-shaped `sessionToken` form:

```javascript
new Verifa.Client({
  sessionToken: client_token,
  sessionId:    session_id,
  apiBase:      'https://api.withverifa.com',
  onComplete: ({ sessionId, status }) => console.log('done', sessionId, status),
}).open();
```

Both produce the same iframe URL — pick whichever fits your codebase.

### 4. Get the result

Results contain PII and must be retrieved server-side:

```bash
curl https://api.withverifa.com/api/v1/sessions/session_abc123/result \
  -H "X-API-Key: vk_live_your_secret_key"
```

Or receive results via [webhook](/integration-guides/webhooks-guide) (recommended for
production).

---

## API reference

### `Verifa.Client` class API

Class-based API matching the Persona-style surface. Construct an instance with
your options, then call `.open()`. The instance manages lifecycle and emits
callbacks for each verification event.

```javascript
const client = new Verifa.Client({
  captureUrl: session.capture_url,    // from POST /api/v1/sessions
  sessionId: session.id,
  mode: 'modal',                       // 'modal' (default) | 'popup' | 'redirect'

  onReady: ({ sessionId }) => console.log('ready', sessionId),
  onComplete: ({ sessionId, status }) => console.log('done', sessionId, status),
  onCancel: ({ sessionId }) => console.log('user closed'),
  onError: ({ code, message }) => console.error(code, message),
  onEvent: (name, meta) => console.debug('event', name, meta),
});
client.open();
```

#### Constructor options

| Option           | Type       | Required                     | Description                                                                                                                                                                         |
| ---------------- | ---------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `captureUrl`     | `string`   | One of these three           | Capture URL — either the long-lived `session.capture_url` or the short-lived `embed_url` returned by `POST /sessions/:id/client-token`. The embed URL is the production-grade path. |
| `sessionToken`   | `string`   | One of these three           | Short-lived JWT from `POST /sessions/:id/client-token`. Persona-shaped alternative to `captureUrl`. Must be paired with `sessionId`.                                                |
| `sessionId`      | `string`   | Required with `sessionToken` | Session ID echoed in callbacks; required when using `sessionToken`.                                                                                                                 |
| `publishableKey` | `string`   | One of these three           | `vk_pub_*` key for quickstart mode (no backend session-create call). The publishable key's allowed-origins list must include this page's origin.                                    |
| `templateId`     | `string`   | No                           | Workflow / template ID (quickstart mode).                                                                                                                                           |
| `referenceId`    | `string`   | No                           | Customer-controlled reference ID.                                                                                                                                                   |
| `environmentId`  | `string`   | No                           | Explicit environment routing.                                                                                                                                                       |
| `country`        | `string`   | No                           | ISO 3166-1 alpha-2 country pre-set.                                                                                                                                                 |
| `mode`           | `string`   | No                           | `"modal"` (default), `"popup"`, or `"redirect"`.                                                                                                                                    |
| `redirectUrl`    | `string`   | No                           | Post-completion redirect URL (redirect mode only).                                                                                                                                  |
| `metadata`       | `object`   | No                           | Arbitrary metadata passed to session creation.                                                                                                                                      |
| `apiBase`        | `string`   | No                           | Override the API base URL. Defaults to `https://app.withverifa.com`.                                                                                                                |
| `readyTimeoutMs` | `number`   | No                           | Time to wait for `onReady` before emitting `onError({code:"ready_timeout"})`. Default `8000`. Set to `0` to disable.                                                                |
| `onReady`        | `function` | No                           | Fired when capture page has finished loading.                                                                                                                                       |
| `onComplete`     | `function` | No                           | Fired on successful verification submission.                                                                                                                                        |
| `onCancel`       | `function` | No                           | Fired when the user closes without completing.                                                                                                                                      |
| `onError`        | `function` | No                           | Fired on any unrecoverable error.                                                                                                                                                   |
| `onEvent`        | `function` | No                           | Generic event firehose: `(name, meta) => void`.                                                                                                                                     |

#### Methods

| Method       | Description                                                   |
| ------------ | ------------------------------------------------------------- |
| `.open()`    | Mounts the capture UI. Idempotent — calling twice is a no-op. |
| `.cancel()`  | Closes the capture UI and fires `onCancel`.                   |
| `.destroy()` | Closes and tears down without firing any lifecycle events.    |

### `Verifa.open(options)` — flat / compat form

Equivalent to `new Verifa.Client(...).open()` but returns a `Promise`. Useful
for `await` style or existing integrations.

| Parameter     | Type       | Required | Description                                                                          |
| ------------- | ---------- | -------- | ------------------------------------------------------------------------------------ |
| `captureUrl`  | `string`   | Yes      | The `capture_url` from your session creation response.                               |
| `sessionId`   | `string`   | No       | The session ID (returned in callbacks). Recommended.                                 |
| `mode`        | `string`   | No       | `"popup"` (default), `"modal"`, or `"redirect"`.                                     |
| `redirectUrl` | `string`   | No       | URL to redirect to after completion (redirect mode only).                            |
| `onComplete`  | `function` | No       | Called with `{ sessionId, status }` on success.                                      |
| `onError`     | `function` | No       | Called with an `Error` on failure.                                                   |
| `onClose`     | `function` | No       | Called when the user closes without completing. Maps to `onCancel` on the class API. |

**Returns:** `Promise<{ sessionId: string, status: string }>`

```javascript
const result = await Verifa.open({
  captureUrl: session.capture_url,
  sessionId: session.id,
  mode: 'modal',
});
```

---

## Display modes

### Popup (default)

Opens the capture UI in a centered popup window. Best for desktop.

```javascript
Verifa.open({
  captureUrl: session.capture_url,
  sessionId: session.id,
  mode: 'popup',
  onComplete: function (data) {
    // Popup closes automatically
    console.log('Done:', data.sessionId);
  },
  onClose: function () {
    console.log('User closed the popup');
  },
});
```

If the browser blocks the popup, `onError` fires. Fall back to modal mode.

### Modal

Opens the capture UI in a full-screen overlay with an iframe. Works on desktop
and mobile. Closes with the X button, backdrop click, or Escape key.

```javascript
Verifa.open({
  captureUrl: session.capture_url,
  sessionId: session.id,
  mode: 'modal',
  onComplete: function (data) {
    console.log('Done:', data.sessionId);
  },
});
```

### Redirect

Navigates the browser to the capture page. After completion, the user returns
to the `redirect_url` you specified when creating the session.

```javascript
Verifa.open({
  captureUrl: session.capture_url,
  mode: 'redirect',
});
```

The user lands back on your redirect URL with query parameters:

```
https://your-app.com/verify/complete?session_id=session_abc123&status=completed
```

---

## Framework examples

### React

```jsx
import { useCallback } from 'react';
import Verifa from '@verifa/sdk';   // or use the global window.Verifa from the CDN script tag

function VerifyButton({ userId }) {
  const handleClick = useCallback(async () => {
    // Your backend creates the session
    const res = await fetch('/api/create-verification', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId }),
    });
    const session = await res.json();

    new Verifa.Client({
      captureUrl: session.capture_url,
      onComplete: ({ sessionId, status }) => {
        if (status === 'completed') {
          // Notify your backend; results live server-side
          fetch('/api/verification-submitted', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ sessionId }),
          });
        }
      },
      onCancel: () => console.log('User closed'),
      onError: ({ code, message }) => console.error(code, message),
    }).open();
  }, [userId]);

  return <button onClick={handleClick}>Verify Identity</button>;
}
```

### Next.js (app router)

The SDK only runs in the browser. Mark the component `"use client"` and load
the bundle with `next/script` so the integrity hash + crossorigin are set
correctly.

```jsx
'use client';
import Script from 'next/script';
import { useCallback } from 'react';

export default function VerifyPage() {
  const startVerification = useCallback(async () => {
    const session = await fetch('/api/create-verification', { method: 'POST' })
      .then(r => r.json());

    new window.Verifa.Client({
      captureUrl: session.capture_url,
      onComplete: ({ sessionId }) => router.push(`/verify/complete?id=${sessionId}`),
    }).open();
  }, []);

  return (
    <>
      <Script
        src="https://cdn.withverifa.com/dist/verifa-v1.0.0.js"
        integrity="sha384-/2DZ+OBwe8NRgYq4X0/qpvx7e7s9CjFy0K7WZX4E5utplz4njxn3A0tNTXNsK4fm"
        crossOrigin="anonymous"
        strategy="afterInteractive"
      />
      <button onClick={startVerification}>Verify Identity</button>
    </>
  );
}
```

The session-create call (`/api/create-verification`) lives in a Next.js
[Route Handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
or Server Action so the secret key never reaches the browser bundle.

### Vue

```vue
<template>
  <button @click="startVerification">Verify Identity</button>
</template>

<script setup>
const startVerification = async () => {
  const res = await fetch('/api/create-verification', { method: 'POST' });
  const session = await res.json();

  const result = await window.Verifa.open({
    captureUrl: session.capture_url,
    sessionId: session.id,
    mode: 'modal',
  });
  console.log('Done:', result.sessionId);
};
</script>
```

### Vanilla JavaScript

```javascript
document.getElementById('verify-btn').addEventListener('click', async () => {
  const res = await fetch('/api/create-verification', { method: 'POST' });
  const session = await res.json();

  try {
    const { sessionId, status } = await Verifa.open({
      captureUrl: session.capture_url,
      sessionId: session.id,
    });

    if (status === 'completed') {
      showSuccess('Verification submitted!');
    }
  } catch (err) {
    showError(err.message);
  }
});
```

---

## Error handling

`onError` is called with `{ code, message }` for every recoverable and
unrecoverable failure. Branch on `code` for stable handling — `message` may
change between versions.

### Error code reference

| `code`                  | Trigger                                                                                                                                                       | Message hint                                                                   | What to do                                                                                                                                                                                                                                                  |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session_create_failed` | The `POST /api/v1/sessions` call failed (publishable-key / quickstart mode only).                                                                             | Echo of the API error: `HTTP 401`, `Origin not allowed`, plan-limit text, etc. | Inspect `message` for the underlying API response. Most common cause: the publishable key's allowed-origins list doesn't include the host page's origin.                                                                                                    |
| `popup_blocked`         | The browser blocked `window.open` in popup mode.                                                                                                              | `Popup blocked. Please allow popups for this site.`                            | Fall back to `mode: 'modal'` and re-call `.open()`, or instruct the user to allow popups.                                                                                                                                                                   |
| `ready_timeout`         | The capture page didn't emit `verifa:ready` within `readyTimeoutMs` (default 8000 ms).                                                                        | Mentions CSP / Permissions-Policy.                                             | The host page's CSP or Permissions-Policy is almost certainly blocking the iframe or its camera access. See [Host page requirements](#required-host-page-headers). The iframe stays mounted so you can inspect via dev tools; call `.cancel()` to clean up. |
| `verification_failed`   | The capture page itself reported an error (the user couldn't complete the flow, e.g. document upload repeatedly failed, OCR couldn't parse, liveness failed). | Echo of the capture-side error.                                                | This is a real verification failure, not an integration bug. The session moves to a `failed` status and you should surface that to the user.                                                                                                                |

`onError` always fires once and then the SDK tears down — `onComplete` will not
fire after an error. If you need both branches, mirror the logic:

```javascript
new Verifa.Client({
  captureUrl: session.capture_url,
  onComplete: ({ sessionId, status }) => {
    if (status === 'completed') showSuccess(sessionId);
    else showRetry(`Verification ${status}`);
  },
  onError: ({ code, message }) => {
    switch (code) {
      case 'popup_blocked':
        return openInModalMode();
      case 'ready_timeout':
        return showSupportPrompt('The verification window did not load.');
      case 'session_create_failed':
        return showSupportPrompt(message);   // surface for support
      case 'verification_failed':
        return showRetry(message);
      default:
        return showSupportPrompt(message);
    }
  },
}).open();
```

---

## Sandbox testing

Use sandbox API keys to test without real verifications. In sandbox mode, the
capture flow lets you choose the outcome (approve, reject, or needs review).

```javascript
// Backend creates session with sandbox key (vk_sandbox_*)
// Frontend uses the capture_url — same code, no changes needed
Verifa.open({
  captureUrl: sandboxSession.capture_url,
  sessionId: sandboxSession.id,
  onComplete: function (data) {
    console.log('Sandbox result:', data.status);
  },
});
```

---

## Edge cases

### Mobile Safari before iOS 16.4

`getUserMedia` inside a cross-origin iframe was unreliable on iOS Safari
prior to **16.4** (March 2023). On older devices the modal opens but the
camera prompt never appears, and the SDK fires
`onError({ code: 'ready_timeout' })` after the timeout. Detect this and
offer the user a fallback:

```javascript
new Verifa.Client({
  captureUrl: session.capture_url,
  mode: 'modal',
  onError: ({ code }) => {
    if (code === 'ready_timeout') {
      // Re-open in a new tab — Safari is happy with camera in a top-level page.
      window.open(session.capture_url, '_blank');
    }
  },
}).open();
```

### Popup blockers

`mode: 'popup'` requires `.open()` to be called from a synchronous click
handler. If you `await` a fetch *before* opening the popup, browsers will
block it. Two patterns work:

```javascript
// PATTERN 1 — open the modal right away (synchronous), no popup blocker risk
button.onclick = () => new Verifa.Client({ captureUrl, mode: 'modal' }).open();

// PATTERN 2 — fetch first, then open popup. Will be blocked unless the user
// has allowed popups on your site. Fall back if blocked:
button.onclick = async () => {
  const session = await fetch('/api/create-verification', { method: 'POST' })
    .then(r => r.json());
  new Verifa.Client({
    captureUrl: session.capture_url,
    mode: 'popup',
    onError: ({ code }) => {
      if (code === 'popup_blocked') {
        new Verifa.Client({ captureUrl: session.capture_url, mode: 'modal' }).open();
      }
    },
  }).open();
};
```

### `ready_timeout` debugging

If the modal opens but the camera prompt never appears and you get
`ready_timeout`, your host page is almost certainly blocking the iframe.
Check the browser console:

| Console message                                        | Header to fix                                                                                                         |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `"Content Security Policy directive: 'frame-src ...'"` | Add `frame-src https://app.withverifa.com` to your page's CSP.                                                        |
| `"Permissions-Policy ... camera"`                      | Add `camera=(self "https://app.withverifa.com")` to your page's Permissions-Policy.                                   |
| `"Refused to display in a frame"`                      | Check that you aren't on an old `cdn.withverifa.com/dist/verifa-v0.x.js` — v1.0.0 dropped the SAMEORIGIN restriction. |

If nothing shows in the console, the iframe loaded fine but the capture
JavaScript itself failed to boot. Inspect the iframe's network tab for a
401/403 on `/verify/*` — that means the capture URL has expired or the
session was already submitted.

### Hidden iframe / tab visibility

When the modal is open and the user switches tabs, the camera stream pauses
(browser-level behavior). The capture flow resumes when the tab is focused
again. No SDK action needed.

### Strict CSP (`Content-Security-Policy: ...; sandbox`)

Pages with a strict `sandbox` directive can't open child iframes at all. If
you must run with `sandbox`, use `mode: 'redirect'` — the user leaves your
page, completes verification on `app.withverifa.com`, and returns via
`redirectUrl`.

## Browser support

The SDK uses `fetch`, `Promise`, and `postMessage`, supported in all modern
browsers:

* Chrome 42+, Firefox 39+, Safari 10.1+, Edge 14+

No polyfills required for any browser released after 2017.

## Security

* **No API key in the browser** — With the recommended `captureUrl` flow, the
  secret key stays on your server. The frontend only receives a capture URL
  scoped to one session, which expires when the session does (default 24 h,
  configurable per-session).
* **Origin allowlist on publishable keys** — The optional quickstart mode uses a
  publishable key (`vk_pub_*`). Publishable keys are required to declare an
  Allowed Origins list; requests from any other origin are rejected with 403.
  See [Authentication → Publishable keys](/api-details/authentication#publishable-keys).
* **Subresource Integrity** — The CDN script tag carries an `integrity=` hash so
  a tampered script can't execute. The browser refuses the script if the hash
  doesn't match the bytes received.
* **No PII exposure** — The SDK never receives verification results, document
  images, or personal data. Results are fetched server-side or pushed via
  webhook.
* **Token-based capture** — Document uploads inside the capture iframe use a
  session-specific token, never an API key.

## Related

* [Web Capture Flow](/integration-guides/web-capture-flow) — Full server-side integration
  guide
* [Choosing an Integration Method](/getting-started/choosing-an-integration-method) — Compare all
  integration options
* [Authentication](/api-details/authentication) — API key types and scopes
* [Webhooks Guide](/integration-guides/webhooks-guide) — Receive results in real-time
* [Testing](/best-practices/testing) — Sandbox testing guide