# Add SecurySign to your backend, web page or app

SecurySign's server libraries handle signing-token requests, sign-in and webhook verification. The capture SDKs handle document photos and live face capture in a browser or mobile app, with API requests sent through the application's backend. For browser signing, see the [Iframe guide](#/docs/iframe).

- [Server libraries](#/docs/sdk-reference#server-libraries): Node.js, PHP, Python and Java clients for your backend
- [Web capture SDK](#/docs/sdk-reference#web-capture-sdk): ID capture and the live face check in a web page
- [Mobile capture SDK](#/docs/sdk-reference#mobile-capture-sdk): ID capture and the live face check in iOS, Android, Flutter, React Native and .NET MAUI apps

## Server libraries

Download the source file for the backend's language and import it into the project. The client provides functions for signing tokens, OpenID Connect (OIDC) login and webhook-signature verification. Set `SSC_SECRET` in the server environment to the Secure Signature Confirmation secret from the RP dashboard. The [API reference](#/docs/api-reference) contains the underlying HTTP requests.

| Language | File |
|---|---|
| Node.js | [`signa-node.js`](/docs/sdk-examples/signa-node.js) |
| PHP | [`signa-php.php`](/docs/sdk-examples/signa-php.php) |
| Python | [`signa-python.py`](/docs/sdk-examples/signa-python.py) |
| Java | [`signa-java.java`](/docs/sdk-examples/signa-java.java) |

### Install a server library

These downloads use the production host. Install the source file and the dependencies listed for the chosen language.

#### Node

```bash
curl -fsSLo signa-node.cjs https://securysign.com/docs/sdk-examples/signa-node.js
npm install axios
node --check signa-node.cjs
```

#### PHP

```bash
curl -fsSLo signa-php.php https://securysign.com/docs/sdk-examples/signa-php.php
# Enable the PHP curl extension, then check the file:
php -m | grep -i '^curl$'
php -l signa-php.php
```

#### Python

```bash
curl -fsSLo signa_python.py https://securysign.com/docs/sdk-examples/signa-python.py
python -m pip install requests
python -m py_compile signa_python.py
```

#### Java

```bash
curl -fsSLo SecurySignClient.java https://securysign.com/docs/sdk-examples/signa-java.java
# Add jackson-databind to your build; see the Maven dependency below.
```

The Node file is saved as `.cjs` to work with both CommonJS and ESM projects. The Python filename uses an underscore because Python module names cannot contain a hyphen. Java requires Java 11 or later and Jackson; put the source in the caller's package or add the appropriate package declaration.

```xml
<!-- pom.xml, inside <dependencies> -->
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.17.0</version>
</dependency>
```

### Request a signing token

#### Node

```javascript
const { SecurySignClient } = require("./signa-node.cjs");

const client = new SecurySignClient({
  appUrl: "https://securysign.com",
  keycloakUrl: "https://securysign.com/auth",
  clientId: "signa-rp-42",
  sscSecret: process.env.SSC_SECRET,
  redirectUri: "https://app.example.com/auth/callback",
});

async function signingTokenFor(documentHash) {
  const { token } = await client.requestSigningToken({ documentHash, loa: "LOA-2" });
  return token;
}
```

#### PHP

```php
require_once __DIR__ . '/signa-php.php';

$client = new SecurySignClient([
    'appUrl'      => 'https://securysign.com',
    'keycloakUrl' => 'https://securysign.com/auth',
    'clientId'    => 'signa-rp-42',
    'sscSecret'   => getenv('SSC_SECRET'),
    'redirectUri' => 'https://app.example.com/auth/callback',
]);

$token = $client->requestSigningToken($documentHash, 'LOA-2');
```

#### Python

```python
import os
from signa_python import SecurySignClient

client = SecurySignClient(
    app_url="https://securysign.com",
    keycloak_url="https://securysign.com/auth",
    client_id="signa-rp-42",
    ssc_secret=os.environ["SSC_SECRET"],
    redirect_uri="https://app.example.com/auth/callback",
)

token = client.request_signing_token(document_hash=document_hash, loa="LOA-2")
```

#### Java

```java
SecurySignClient client = new SecurySignClient(
    "https://securysign.com",
    "https://securysign.com/auth",
    "signa-rp-42",
    System.getenv("SSC_SECRET"),
    "https://app.example.com/auth/callback"
);

Map<String, Object> token = client.requestSigningToken(documentHash, "LOA-2");
```

Pass the document's SHA-256 hash as a 64-character hexadecimal `documentHash`. Send the returned `token` from the backend to the page that opens the [signing iframe](#/docs/iframe). In PHP, Python and Java, the return value is a response object or map containing that `token` field.

### Verify a webhook signature

#### Node

```javascript
const valid = SecurySignClient.verifyWebhookSignature(rawBody, req.headers["x-webhook-signature"], webhookSecret);
```

#### Python

```python
import hashlib, hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header or "", expected)
```

#### PHP

```php
function verifyWebhook(string $rawBody, string $header, string $secret): bool {
    return hash_equals('sha256=' . hash_hmac('sha256', $rawBody, $secret), $header);
}
```

Webhook verification takes the raw request body, `X-Webhook-Signature` header and subscription secret, and checks whether the body matches the signed delivery. For a per-request `callbackUrl`, confirm completion through an authenticated API result; see [completion events](#/docs/api-hash-signing#receive-completion-events). Contact [support@tenda.world](mailto:support@tenda.world) for other languages.

## Web capture SDK

The web integration uses the browser camera for document photos and `<securysign-liveness>` from [`@securysign/identity-capture`](https://www.npmjs.com/package/@securysign/identity-capture) for face capture. The backend authenticates with RP credentials and submits the photos and capture transaction ID to SecurySign.

Before adding capture to a page, obtain:

- an approved RP with `signa-kyc` and its OIDC `client_id` and `client_secret`;
- an HTTPS capture page;
- a signed-in customer record containing their email or international phone number and their `phone_number`.

See [KYC](#/docs/kyc) for the verification sequence and verdict fields.

### Add the backend routes

Implement four application routes for starting verification, uploading the document, requesting a liveness session and submitting the face result. Authentication middleware supplies `req.user` or `current_user`. Read `customer_id` from that record's email or international phone number, and `phone_number` from its registered number. The `/kyc/` paths below are application routes that forward requests to SecurySign.

Routes without `rp_urn`:

#### Node

```javascript
import express from "express";

const API = "https://securysign.com/api/kyc";
const AUTH = "Basic " + Buffer.from(`${process.env.SECURYSIGN_CLIENT_ID}:${process.env.SECURYSIGN_CLIENT_SECRET}`).toString("base64");

async function kyc(path, body = {}) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: { Authorization: AUTH, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return [res.status, await res.json()];
}

const app = express();
app.use("/kyc", express.json({ limit: "15mb" }));
// requireUser: your auth middleware. Every call identifies the signed-in customer by customer_id.

app.post("/kyc/start", requireUser, async (req, res) => {
  const [status, body] = await kyc("/verifications", { customer_id: req.user.email, phone_number: req.user.phone });
  res.status(status).json(body); // { customer_id, phone_number, status, created }
});

app.post("/kyc/document", requireUser, async (req, res) => {
  const [status, body] = await kyc("/customers/document", { customer_id: req.user.email, images: req.body.images });
  res.status(status).json(body);
});

app.post("/kyc/liveness", requireUser, async (req, res) => {
  const [status, body] = await kyc("/customers/liveness-session", { customer_id: req.user.email });
  if (status !== 200) return res.status(status).json(body);
  res.status(status).json({ url: body.url, token: body.token }); // only these reach the browser
});

app.post("/kyc/face", requireUser, async (req, res) => {
  // The capture reports "done" a moment before its upload reaches SecurySign,
  // which answers 404 until it lands. Retry briefly instead of failing.
  let status, body;
  for (let attempt = 0; attempt < 5; attempt++) {
    [status, body] = await kyc("/customers/face", { customer_id: req.user.email, livenessTransactionId: req.body.transactionId });
    if (status !== 404) break;
    await new Promise((r) => setTimeout(r, 1000));
  }
  res.status(status).json(body); // the verdict
});
```

#### Python

```python
import os
import time

import httpx
from fastapi import Depends, FastAPI
from fastapi.responses import JSONResponse

API = "https://securysign.com/api/kyc"
AUTH = (os.environ["SECURYSIGN_CLIENT_ID"], os.environ["SECURYSIGN_CLIENT_SECRET"])
app = FastAPI()

def kyc(path: str, body: dict | None = None) -> httpx.Response:
    return httpx.post(API + path, auth=AUTH, json=body or {}, timeout=30)

def relay(r: httpx.Response) -> JSONResponse:
    return JSONResponse(r.json(), status_code=r.status_code)  # keep SecurySign's status

# current_user: your auth dependency. Every call identifies the signed-in customer by customer_id.

@app.post("/kyc/start")
def start(user=Depends(current_user)):
    return relay(kyc("/verifications", {"customer_id": user.email, "phone_number": user.phone}))  # { customer_id, phone_number, status, created }

@app.post("/kyc/document")
def document(payload: dict, user=Depends(current_user)):
    return relay(kyc("/customers/document", {"customer_id": user.email, "images": payload["images"]}))

@app.post("/kyc/liveness")
def liveness(user=Depends(current_user)):
    r = kyc("/customers/liveness-session", {"customer_id": user.email})
    if r.status_code != 200:
        return relay(r)
    return {"url": r.json()["url"], "token": r.json()["token"]}  # only these reach the browser

@app.post("/kyc/face")
def face(payload: dict, user=Depends(current_user)):
    # The capture reports "done" a moment before its upload reaches SecurySign,
    # which answers 404 until it lands. Retry briefly instead of failing.
    for _ in range(5):
        r = kyc("/customers/face", {"customer_id": user.email, "livenessTransactionId": payload["transactionId"]})
        if r.status_code != 404:
            break
        time.sleep(1)
    return relay(r)
```

For an application reference, also send `rp_urn` when starting verification. These routes include it on every request while retaining `customer_id`. The reference comes from `req.user.id` or `user.id` in the same authenticated record:

#### Node

```javascript
import express from "express";

const API = "https://securysign.com/api/kyc";
const AUTH = "Basic " + Buffer.from(`${process.env.SECURYSIGN_CLIENT_ID}:${process.env.SECURYSIGN_CLIENT_SECRET}`).toString("base64");

async function kyc(path, body = {}) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: { Authorization: AUTH, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return [res.status, await res.json()];
}

const app = express();
app.use("/kyc", express.json({ limit: "15mb" }));
// requireUser: your auth middleware. The start sets your user ID as rp_urn, alongside
// customer_id and phone_number; the calls after it send rp_urn with customer_id.

app.post("/kyc/start", requireUser, async (req, res) => {
  const [status, body] = await kyc("/verifications", { customer_id: req.user.email, phone_number: req.user.phone, rp_urn: String(req.user.id) });
  res.status(status).json(body); // { customer_id, phone_number, rp_urn, status, created }
});

app.post("/kyc/document", requireUser, async (req, res) => {
  const [status, body] = await kyc("/customers/document", { customer_id: req.user.email, rp_urn: String(req.user.id), images: req.body.images });
  res.status(status).json(body);
});

app.post("/kyc/liveness", requireUser, async (req, res) => {
  const [status, body] = await kyc("/customers/liveness-session", { customer_id: req.user.email, rp_urn: String(req.user.id) });
  if (status !== 200) return res.status(status).json(body);
  res.status(status).json({ url: body.url, token: body.token }); // only these reach the browser
});

app.post("/kyc/face", requireUser, async (req, res) => {
  // The capture reports "done" a moment before its upload reaches SecurySign,
  // which answers 404 until it lands. Retry briefly instead of failing.
  let status, body;
  for (let attempt = 0; attempt < 5; attempt++) {
    [status, body] = await kyc("/customers/face", { customer_id: req.user.email, rp_urn: String(req.user.id), livenessTransactionId: req.body.transactionId });
    if (status !== 404) break;
    await new Promise((r) => setTimeout(r, 1000));
  }
  res.status(status).json(body); // the verdict
});
```

#### Python

```python
import os
import time

import httpx
from fastapi import Depends, FastAPI
from fastapi.responses import JSONResponse

API = "https://securysign.com/api/kyc"
AUTH = (os.environ["SECURYSIGN_CLIENT_ID"], os.environ["SECURYSIGN_CLIENT_SECRET"])
app = FastAPI()

def kyc(path: str, body: dict | None = None) -> httpx.Response:
    return httpx.post(API + path, auth=AUTH, json=body or {}, timeout=30)

def relay(r: httpx.Response) -> JSONResponse:
    return JSONResponse(r.json(), status_code=r.status_code)  # keep SecurySign's status

# current_user: your auth dependency. The start sets your user ID as rp_urn, alongside
# customer_id and phone_number; the calls after it send rp_urn with customer_id.

@app.post("/kyc/start")
def start(user=Depends(current_user)):
    return relay(kyc("/verifications", {"customer_id": user.email, "phone_number": user.phone, "rp_urn": str(user.id)}))  # { customer_id, phone_number, rp_urn, status, created }

@app.post("/kyc/document")
def document(payload: dict, user=Depends(current_user)):
    return relay(kyc("/customers/document", {"customer_id": user.email, "rp_urn": str(user.id), "images": payload["images"]}))

@app.post("/kyc/liveness")
def liveness(user=Depends(current_user)):
    r = kyc("/customers/liveness-session", {"customer_id": user.email, "rp_urn": str(user.id)})
    if r.status_code != 200:
        return relay(r)
    return {"url": r.json()["url"], "token": r.json()["token"]}  # only these reach the browser

@app.post("/kyc/face")
def face(payload: dict, user=Depends(current_user)):
    # The capture reports "done" a moment before its upload reaches SecurySign,
    # which answers 404 until it lands. Retry briefly instead of failing.
    for _ in range(5):
        r = kyc("/customers/face", {"customer_id": user.email, "rp_urn": str(user.id), "livenessTransactionId": payload["transactionId"]})
        if r.status_code != 404:
            break
        time.sleep(1)
    return relay(r)
```

Include `return_selfie: true` or `1` in `/face` to request the capture image. A `verified` result includes it when available; see [Selfie fields](#/docs/kyc#when-you-get-the-selfie) for cases that return `null`.

### Add the pass-through

The browser component sends capture requests to `/api/kyc/faceapi/*` on the application's origin. Relay those requests from the backend to the same path on SecurySign, preserving the scoped liveness token supplied by the session route. Mount the raw-body relay before the JSON body parser:

#### Next.js

```typescript
// app/api/kyc/faceapi/[...path]/route.ts
import { NextRequest } from "next/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const UPSTREAM = "https://securysign.com/api/kyc/faceapi";
const FORWARD = ["authorization", "content-type", "accept", "x-client-key"];

async function relay(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
  const { path } = await params;
  if (path.some((s) => s === "." || s === "..")) return Response.json({ error: "bad_path" }, { status: 400 });

  const target = new URL(`${UPSTREAM}/${path.map(encodeURIComponent).join("/")}`);
  target.search = req.nextUrl.search;
  const headers = new Headers();
  for (const name of FORWARD) {
    const value = req.headers.get(name);
    if (value) headers.set(name, value);
  }

  const upstream = await fetch(target, {
    method: req.method,
    headers,
    body: req.method === "GET" ? undefined : await req.arrayBuffer(),
    cache: "no-store",
    signal: AbortSignal.timeout(path.at(-1) === "video" ? 120_000 : 30_000),
  }).catch((err) => {
    console.error(`[kyc/faceapi] ${req.method} /${path.join("/")}:`, err?.name, err?.cause?.code ?? err?.message);
    return null;
  });
  if (!upstream) return Response.json({ error: "unavailable" }, { status: 502 });

  const out = new Headers();
  for (const name of ["content-type", "cache-control"]) {
    const value = upstream.headers.get(name);
    if (value) out.set(name, value);
  }
  return new Response(upstream.body, { status: upstream.status, headers: out });
}

export { relay as GET, relay as POST, relay as PUT };
```

#### Express

```javascript
// Express 5. Mount before any global body parser.
// Express 4: use "/api/kyc/faceapi/*" and `const segments = req.params[0].split("/")`.
const UPSTREAM = "https://securysign.com/api/kyc/faceapi";
const FORWARD = ["authorization", "content-type", "accept", "x-client-key"];

app.all("/api/kyc/faceapi/*sub", express.raw({ type: () => true, limit: "25mb" }), async (req, res) => {
  if (!["GET", "POST", "PUT"].includes(req.method)) return res.sendStatus(405);
  const segments = req.params.sub;
  if (segments.some((s) => s === "." || s === "..")) return res.status(400).json({ error: "bad_path" });
  const sub = segments.map(encodeURIComponent).join("/");

  const headers = {};
  for (const name of FORWARD) if (req.get(name)) headers[name] = req.get(name);
  const query = req.originalUrl.includes("?") ? req.originalUrl.slice(req.originalUrl.indexOf("?")) : "";

  try {
    const upstream = await fetch(`${UPSTREAM}/${sub}${query}`, {
      method: req.method,
      headers,
      body: req.method === "GET" ? undefined : req.body,
      signal: AbortSignal.timeout(sub.endsWith("/video") ? 120_000 : 30_000),
    });
    for (const name of ["content-type", "cache-control"]) {
      const value = upstream.headers.get(name);
      if (value) res.set(name, value);
    }
    res.status(upstream.status).send(Buffer.from(await upstream.arrayBuffer()));
  } catch (err) {
    console.error(`[kyc/faceapi] ${req.method} /${sub}:`, err?.name, err?.cause?.code ?? err?.message);
    res.status(502).json({ error: "unavailable" });
  }
});
```

In another backend framework, preserve the `GET`, `POST` or `PUT` method, sub-path, query string and raw body, along with the `authorization`, `content-type`, `accept` and `x-client-key` headers. Allow bodies of at least 25 MB. The example uses a 30-second timeout, extended to 120 seconds for `/liveness/video`.

```callout warning
**Intermittent `ETIMEDOUT` after about 250 ms on Node 20 or later** means your network cannot route the IPv6 address that `securysign.com` resolves to. Start Node with `--dns-result-order=ipv4first --no-network-family-autoselection`.
```

### Install the component

#### npm

```bash
npm install @securysign/identity-capture@0.2.0
```

#### CDN

```html
<script type="module">
  import "https://cdn.jsdelivr.net/npm/@securysign/identity-capture@0.2.0/+esm";
</script>
```

These examples use version `0.2.0` and its TypeScript types. Pin that version to keep the installed component API consistent with the code below.

### Capture the ID in the browser

Start the document screen with `/kyc/start`, then open the rear camera. Capture the front and back of a national identity card, or the photo page of a passport. Upload the array to `/kyc/document`; the backend adds the customer identifier and forwards it to SecurySign.

#### JavaScript

```javascript
await fetch("/kyc/start", { method: "POST" }); // your backend identifies the signed-in customer

const video = document.querySelector("video");
video.srcObject = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: "environment", width: { ideal: 1920 }, height: { ideal: 1080 } },
  audio: false,
});

// Current frame as base64 JPEG, ~1600 px long edge, no data: prefix.
function grabFrame(v, maxEdge = 1600) {
  const scale = Math.min(1, maxEdge / Math.max(v.videoWidth, v.videoHeight));
  const canvas = document.createElement("canvas");
  canvas.width = Math.round(v.videoWidth * scale);
  canvas.height = Math.round(v.videoHeight * scale);
  canvas.getContext("2d").drawImage(v, 0, 0, canvas.width, canvas.height);
  const url = canvas.toDataURL("image/jpeg", 0.85);
  return url.slice(url.indexOf(",") + 1);
}

const images = [];
document.querySelector("#capture").onclick = async () => {
  images.push(grabFrame(video));
  if (images.length < 2) return; // national ID: front, back. Passport: stop at 1.
  video.srcObject.getTracks().forEach((t) => t.stop());
  const verdict = await (await fetch("/kyc/document", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ images }),
  })).json();
  // verdict.status: "document_verified", or "failed" with "document_not_authentic" -> retake
};
```

#### React

```tsx
import { useEffect, useRef, useState } from "react";

function grabFrame(v: HTMLVideoElement, maxEdge = 1600): string {
  const scale = Math.min(1, maxEdge / Math.max(v.videoWidth, v.videoHeight));
  const canvas = document.createElement("canvas");
  canvas.width = Math.round(v.videoWidth * scale);
  canvas.height = Math.round(v.videoHeight * scale);
  canvas.getContext("2d")!.drawImage(v, 0, 0, canvas.width, canvas.height);
  const url = canvas.toDataURL("image/jpeg", 0.85);
  return url.slice(url.indexOf(",") + 1);
}

export function IdCapture({ onUploaded }: { onUploaded: (verdict: { status: string }) => void }) {
  const video = useRef<HTMLVideoElement>(null);
  const [images, setImages] = useState<string[]>([]);

  useEffect(() => {
    let stream: MediaStream | undefined;
    navigator.mediaDevices
      .getUserMedia({ video: { facingMode: "environment", width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false })
      .then((s) => { stream = s; if (video.current) video.current.srcObject = s; });
    return () => stream?.getTracks().forEach((t) => t.stop());
  }, []);

  async function capture() {
    const next = [...images, grabFrame(video.current!)];
    setImages(next);
    if (next.length < 2) return; // national ID: front, back
    const res = await fetch("/kyc/document", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ images: next }),
    });
    onUploaded(await res.json());
  }

  return (
    <>
      <video ref={video} playsInline muted autoPlay />
      <button onClick={capture}>{images.length === 0 ? "Capture the front" : "Capture the back"}</button>
    </>
  );
}
```

Offer a [phone handoff](#/docs/kyc#6-optional-continue-on-a-phone) for customers who want to use their phone's camera.

### Run the face check in the browser

Request `{ url, token }` from `/kyc/liveness` and pass the session to the component. On `confirmed`, send the event's `transactionId` to `/kyc/face`, which submits it as `livenessTransactionId`. Each retry needs a new session.

#### JavaScript

```javascript
import "@securysign/identity-capture"; // registers <securysign-liveness>

const el = document.querySelector("securysign-liveness");

el.addEventListener("securysign-liveness", async (e) => {
  const result = e.detail; // { outcome, transactionId?, reason? }
  if (result.outcome !== "confirmed") return offerRetry(result.reason); // "not-live" | "stopped"

  const verdict = await (await fetch("/kyc/face", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ transactionId: result.transactionId }),
  })).json();
  verdict.status === "verified" ? proceed(verdict) : offerRetry(verdict.reasons);
});

document.querySelector("#start").onclick = async () => {
  el.session = await (await fetch("/kyc/liveness", { method: "POST" })).json();
  await el.start();
};
```

#### React

```tsx
import "@securysign/identity-capture";
import type { LivenessResult, SecurySignLiveness } from "@securysign/identity-capture";
import { useEffect, useRef } from "react";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "securysign-liveness": React.DetailedHTMLProps<React.HTMLAttributes<SecurySignLiveness>, SecurySignLiveness>;
    }
  }
}

export function FaceCheck({ onVerdict }: { onVerdict: (verdict: { status: string } | null) => void }) {
  const ref = useRef<SecurySignLiveness>(null);

  useEffect(() => {
    const el = ref.current!;
    const onResult = async (e: Event) => {
      const result = (e as CustomEvent<LivenessResult>).detail;
      if (result.outcome !== "confirmed") return onVerdict(null);
      const res = await fetch("/kyc/face", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ transactionId: result.transactionId }),
      });
      onVerdict(await res.json());
    };
    el.addEventListener("securysign-liveness", onResult);
    return () => el.removeEventListener("securysign-liveness", onResult);
  }, [id, onVerdict]);

  async function start() {
    const el = ref.current!;
    el.session = await (await fetch("/kyc/liveness", { method: "POST" })).json();
    await el.start();
  }

  return (
    <>
      <securysign-liveness ref={ref} style={{ display: "block", minHeight: 480 }} />
      <button onClick={start}>Start face check</button>
    </>
  );
}
```

| `status` | `reasons` | What your page does |
|---|---|---|
| `verified` | None | Continue. This verdict is final. |
| `needs_review` | `face_match_borderline` | Offer another face capture when the customer chooses to retry. |
| `failed` | `liveness_failed` or `face_mismatch` | Offer another face capture when the customer chooses to retry. |
| `failed` | `document_not_authentic` | Offer another document capture. |

Use `status` and `reasons` to handle the verdict. With `return_selfie`, a `verified` result can include the retained capture image; see [Selfie rules](#/docs/kyc#when-you-get-the-selfie) for unavailable images. [Get the verdict](#/docs/api-identity-verification#get-verification) lists all fields.

### Component API

| Member | Kind | What you do with it |
|---|---|---|
| `session` | Property | Set it to `{ url, token }` from your backend's `/kyc/liveness` route before `start()`. |
| `start(session?)` | Method | Call it to load the capture engine and start the capture. It throws if you set no session with `url` and `token`. |
| `enginePath` | Property | Where your page downloads the capture engine from. Defaults to `https://securysign.com/capture-engine/8.3.2310`. Change it only for a SecurySign environment other than production. |
| `apiBase` | Property | The base that a relative `session.url` resolves against. Defaults to `location.origin`, which routes traffic to your pass-through. |
| `securysign-liveness` | Event | Listen for it: you get it once per capture with a `LivenessResult` in `detail`. It bubbles and crosses shadow roots. |

```ts
type LivenessResult =
  | { outcome: "confirmed"; transactionId: string }
  | { outcome: "not-live"; reason: "LIVENESS_NOT_CONFIRMED" }
  | { outcome: "stopped"; reason: string }; // "APP_INACTIVE" when the tab lost focus
```

Give the element an explicit height in the layout. Capture needs the tab to remain in the foreground.

For `outcome: "stopped"`, display the recovery action that corresponds to `reason`. Start a fresh session if the customer chooses to retry:

| `reason` | What happened | Tell your customer |
|---|---|---|
| `BAD_FACE_QUALITY` | The face was too dark, blurred or small to judge | Face a light source, hold the camera steady at eye level, and wipe the lens |
| `CAMERA_PERMISSION_DENIED` | The browser blocked the camera | Allow camera access for this site in the browser settings |
| `NO_CAMERA` | The device has no camera the page can use | Use a device with a front camera, or continue on a phone |
| `CAMERA_UNKNOWN_ERROR`, `INCORRECT_CAMERA_ID` | The camera did not start | Close other apps using the camera, then try again |
| `CHANGE_CAMERA` | The camera changed during the capture | Keep using the same camera |
| `CONNECTION_ERROR` | The connection dropped | Check the internet connection |
| `TIMEOUT_ERROR` | The capture took too long | Start again and follow the prompts on screen |
| `LANDSCAPE_MODE_RESTRICTED`, `DEVICE_ROTATE` | The phone was held sideways | Hold the phone upright |
| `APP_INACTIVE` | The tab or app went to the background | Keep the page open until the check finishes |
| `CANCELLED` | Your customer closed the capture | Start again when ready |
| `BAD_FRAME_SIZE` | The camera's picture is too small | Use a device with a better front camera |
| `NOT_SUPPORTED`, `HTTP_NOT_SUPPORTED`, `WASM_ERROR`, `WEBSERVICE_NOT_COMPATIBLE` | The browser or connection cannot run the capture | Open the page in a current Chrome or Safari, over HTTPS |
| `UNKNOWN_ERROR` | Anything else | Keep the page open and in front, then try again |

### Content Security Policy

| Directive | Add | Why you need it |
|---|---|---|
| `connect-src` | `'self' https://securysign.com` | Your pass-through, and the capture engine download from `securysign.com/capture-engine/` |
| `worker-src` | `blob:` | The engine runs in a blob worker |
| `script-src` | `'wasm-unsafe-eval'` | The engine is WebAssembly |
| `script-src` | `https://cdn.jsdelivr.net` | Only when you load the component from the CDN |
| `media-src` | `blob: mediastream:` | Only if you restrict media sources |

An embedded capture page requires `allow="camera"` on its iframe. The `Permissions-Policy` header must also permit the capture origin; use `camera=(self)` for a same-origin frame.

### Troubleshooting the web SDK

| Symptom | Fix |
|---|---|
| CORS error on `…/api/kyc/faceapi/…` | Keep the default `apiBase` and serve the pass-through, so the component stops calling SecurySign directly |
| `404` on your `/api/kyc/faceapi/…` | Mount the pass-through on `/api/kyc/faceapi/` |
| `422` `x-client-key Field required` | Forward the `x-client-key` header in your pass-through |
| `401` `Invalid or expired liveness session` | Open one new session per attempt |
| `403 CSRF token validation failed` from your backend routes | Your backend sent no `Authorization` header. Check that both environment variables are set. |
| `502` from the pass-through | Read its log. A `TimeoutError` on `/video` needs the 120-second timeout. |
| Blank capture area | Give the element a height |
| Capture never starts | Add the Content Security Policy entries above |
| Page reloads after an Android photo | `<input type="file" capture>` opened the Camera app. Capture in the page instead, as shown above. |
| `selfie` is `null` with `return_selfie` | Capture is unfinished, the verdict is unsuccessful, or the retained image is unavailable. See [what `null` means](#/docs/kyc#when-you-get-the-selfie). |

## Mobile capture SDK

Mobile capture runs on iOS, Android, Flutter, React Native or .NET MAUI. The app takes document and face captures; the backend identifies the signed-in customer, opens the verification and liveness session, and submits the results.

Mobile integration requires:

- an approved RP with `signa-kyc`, and its `client_id` and `client_secret` on your backend;
- the application identifiers for your platform;
- the native SDK package supplied by SecurySign, or the web component for an embedded or browser-based capture.

For native capture, request the package and capture entitlement for the application's identifiers from SecurySign.

### Choose a capture method

Choose where face capture runs. All options submit a capture for evaluation by the same verification flow:

| Method | What the app does | What you install |
|---|---|---|
| Native capture SDK | Runs the face check in native code, with no WebView | The native SDK package for your platform, from support |
| Web component in a WebView | Loads `<securysign-liveness>` in a WebView | [`@securysign/identity-capture@0.2.0`](#/docs/sdk-reference#install-the-component) from npm |
| Browser tab (Android) | Opens your own capture page in a Chrome Custom Tab, which returns to the app through a link | `@securysign/identity-capture@0.2.0` on that page |
| Hosted capture page | Opens a SecurySign page in the system browser | Nothing |

### Connect the app to your backend

Use the same start, document, liveness-session and face routes as the web integration. The [backend examples](#/docs/sdk-reference#add-the-backend-routes) include Node.js and Python versions, with and without an application customer reference.

Read the identifiers from the authenticated customer record on the backend. `customer_id` is an international phone number, such as `+254712345678`, or an email address. Starting verification also requires the international `phone_number`; see the [identifier rules](#/docs/kyc#1-start-a-verification). Starting again with the same `customer_id` returns the existing verification and status, allowing the app to resume. An optional `rp_urn` records the application's own customer ID and can accompany `customer_id` in later calls.

### Capture the ID on the device

Use CameraX or Camera2 on Android, or AVFoundation on iOS, to open the rear camera. Collect the front and back of a national ID in that order, or the passport photo page.

| Setting | Value |
|---|---|
| Preview | About 1920 × 1080, with autofocus on the document |
| Guide overlay | ID card 85.6 × 54 mm, passport page 125 × 88 mm |
| Framing | The whole document, with a margin of background around the card edges. |
| Output | About 1600 px on the long edge, EXIF orientation applied, JPEG quality 85: roughly 150 to 300 KB per side |
| Upload | Base64 without a `data:` prefix, sent by your backend to `POST /api/kyc/customers/document` with the `customer_id` |

Proceed to face capture at `document_verified`. For `failed` with `document_not_authentic`, offer a document retake with all edges visible and without blur or glare.

For each face attempt, request a liveness session on the backend and return `{ url, token }` to the app. The token authorizes capture for that verification for 600 seconds.

### Native capture SDK

Configure the native SDK with the session's service URL and bearer token:

| Setting | Value |
|---|---|
| Service URL | Your `baseUrl` followed by the session `url`: `https://securysign.com/api/kyc/faceapi`, or your pass-through. Native networking is not subject to CORS, so the pass-through is optional here. |
| Request header | `Authorization: Bearer <session token>` on every request. Set it in the SDK's network interceptor, which runs before each request. |

A new session invalidates the previous token. Use a fresh session for retries and allow up to 30 seconds for the capture result after the interaction finishes.

Map native callbacks to the web component's three outcomes:

| The SDK reports | Outcome | What to do |
|---|---|---|
| Liveness passed, with a transaction ID | `confirmed` | Send the `transactionId` to your backend, as shown in [Get the verdict in the app](#/docs/sdk-reference#get-the-verdict-in-the-app) |
| Liveness not passed | `not-live` | Ask the customer to try again, facing a light source |
| An error code | `stopped` | Show the reason, and offer to try again with a new session |

### Web component in a WebView

The [web component](#/docs/sdk-reference#web-capture-sdk) can run in a native WebView, Capacitor, Ionic, React Native WebView or Flutter WebView. The container provides camera permissions and layout; the page runs the component with its session.

| Requirement | Android | iOS |
|---|---|---|
| Camera permission | `android.permission.CAMERA` in the manifest, requested at runtime | `NSCameraUsageDescription` in `Info.plist` |
| Secure page | Serve over `https://`. For bundled pages, use `WebViewAssetLoader` (`https://appassets.androidplatform.net/...`), not `file://`. | Serve over `https://` or a custom scheme handler. `getUserMedia` in `WKWebView` needs iOS 14.3 or later. |
| Camera access for the page | `WebChromeClient.onPermissionRequest` grants `RESOURCE_VIDEO_CAPTURE` | `WKUIDelegate` `requestMediaCapturePermissionFor` returns `.grant` (iOS 15 or later) |
| Camera preview | `settings.mediaPlaybackRequiresUserGesture = false` | `allowsInlineMediaPlayback = true`, `mediaTypesRequiringUserActionForPlayback = []` |
| JavaScript and storage | `javaScriptEnabled = true`, `domStorageEnabled = true` | On by default |
| Size | `MATCH_PARENT` × `MATCH_PARENT`. Compose `AndroidView` defaults to wrap-content, which leaves the capture blank. | Constrain the `WKWebView` to its container |
| Result | The `securysign-liveness` event reaches native code through `addJavascriptInterface` | The same event through `WKScriptMessageHandler` |

Capture requests from the WebView page go through the [backend relay](#/docs/sdk-reference#add-the-pass-through). The following examples show a page-to-native bridge and the sample app's camera permissions.

For a bundled Android page, build an ES module and package it beside the HTML asset:

```bash
npm install @securysign/identity-capture@0.2.0 esbuild
npx esbuild node_modules/@securysign/identity-capture/dist/index.js \
  --bundle --format=esm --outfile=app/src/main/assets/identity-capture.js
```

#### Android

```html
<!-- app/src/main/assets/liveness.html -->
<securysign-liveness style="display:block; position:fixed; inset:0"></securysign-liveness>
<script type="module">
  import "./identity-capture.js";
  const el = document.querySelector("securysign-liveness");
  el.addEventListener("securysign-liveness", (e) => SecurySignBridge.onResult(JSON.stringify(e.detail)));

  // Called by the app with your backend's base URL and the session from /kyc/:id/liveness.
  window.startLiveness = (apiBase, url, token) => {
    el.apiBase = apiBase; // capture traffic goes to apiBase + /api/kyc/faceapi, your pass-through
    el.session = { url, token };
    el.start().catch((err) =>
      SecurySignBridge.onResult(JSON.stringify({ outcome: "stopped", reason: String(err?.message ?? err) })));
  };
  SecurySignBridge.onReady();
</script>
```

#### iOS

```html
<!-- A page on your HTTPS origin, loaded in the WKWebView -->
<securysign-liveness style="display:block; position:fixed; inset:0"></securysign-liveness>
<script type="module">
  import "https://cdn.jsdelivr.net/npm/@securysign/identity-capture@0.2.0/+esm";
  const el = document.querySelector("securysign-liveness");
  el.addEventListener("securysign-liveness", (e) => webkit.messageHandlers.securysign.postMessage(e.detail));

  window.startLiveness = (url, token) => {
    el.session = { url, token }; // same origin as your pass-through, so apiBase keeps its default
    el.start().catch((err) =>
      webkit.messageHandlers.securysign.postMessage({ outcome: "stopped", reason: String(err?.message ?? err) }));
  };
</script>
```

Load the page in a WebView with camera access and a layout that fills the container:

#### Android

```kotlin
// The app must hold the CAMERA runtime permission before this runs.
val assets = WebViewAssetLoader.Builder()
    .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
    .build()

val webView = WebView(context).apply {
    layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) // wrap-content renders blank
    settings.javaScriptEnabled = true
    settings.domStorageEnabled = true
    settings.mediaPlaybackRequiresUserGesture = false

    // Serve the bundled page over https (a secure context), never file://
    webViewClient = object : WebViewClient() {
        override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest) =
            assets.shouldInterceptRequest(request.url)
    }
    webChromeClient = object : WebChromeClient() {
        override fun onPermissionRequest(request: PermissionRequest) {
            val camera = PermissionRequest.RESOURCE_VIDEO_CAPTURE
            if (camera in request.resources) request.grant(arrayOf(camera)) else request.deny()
        }
    }
    addJavascriptInterface(object {
        @JavascriptInterface
        fun onReady() {
            val js = "startLiveness(${JSONObject.quote(BACKEND_BASE)}, " +
                "${JSONObject.quote(session.url)}, ${JSONObject.quote(session.token)})"
            post { evaluateJavascript(js, null) }
        }

        @JavascriptInterface
        fun onResult(json: String) {
            val result = JSONObject(json) // { outcome, transactionId?, reason? }
            post {
                if (result.optString("outcome") == "confirmed") submitFace(result.getString("transactionId"))
                else showRetry(result.optString("reason"))
            }
        }
    }, "SecurySignBridge")

    loadUrl("https://appassets.androidplatform.net/assets/liveness.html")
}
```

#### iOS

```swift
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
config.userContentController.add(self, name: "securysign") // results arrive in userContentController(_:didReceive:)
let webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self

// iOS 15+: let the page use the camera
func webView(_ webView: WKWebView,
             requestMediaCapturePermissionFor origin: WKSecurityOrigin,
             initiatedByFrame frame: WKFrameInfo,
             type: WKMediaCaptureType,
             decisionHandler: @escaping (WKPermissionDecision) -> Void) {
    decisionHandler(.grant)
}
```

Bundled Android pages use the origin `https://appassets.androidplatform.net`. The following CORS middleware allows that origin to reach the relay. Mount it before the relay route:

```javascript
// Express
const WEBVIEW_ORIGIN = "https://appassets.androidplatform.net";

app.use("/api/kyc/faceapi", (req, res, next) => {
  if (req.get("origin") === WEBVIEW_ORIGIN) {
    res.set({
      "Access-Control-Allow-Origin": WEBVIEW_ORIGIN,
      "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS",
      "Access-Control-Allow-Headers": "authorization, content-type, accept, x-client-key",
      Vary: "Origin",
    });
  }
  if (req.method === "OPTIONS") return res.sendStatus(204);
  next();
});
```

The WebView needs internet access to load the capture component and engine.

### Browser tab on Android

A Chrome Custom Tab opens an HTTPS capture page. The page uses the same-origin relay, then returns `transactionId` through the app's registered link.

#### Android

```kotlin
// build.gradle.kts: implementation("androidx.browser:browser:1.8.0")
suspend fun openFaceCheck(context: Context) {
    val session = backend.post("/kyc/liveness") // { url, token }
    // The fragment never reaches a server or its logs.
    val page = Uri.parse("https://app.example.com/kyc/capture").buildUpon()
        .encodedFragment("url=${Uri.encode(session.url)}&token=${Uri.encode(session.token)}")
        .build()
    CustomTabsIntent.Builder().build().launchUrl(context, page)
}

// MainActivity (launchMode="singleTop"): the page's "Back to the app" link lands here.
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    val link = intent.data ?: return
    if (link.scheme != "com.example.app" || link.host != "face-done") return
    val transactionId = link.getQueryParameter("transactionId")
    if (transactionId != null) submitFace(transactionId) else showRetry(link.getQueryParameter("reason"))
}
// Back in onResume() with no face-done link means the customer closed the tab: offer a retry.
```

#### AndroidManifest

```xml
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="com.example.app" android:host="face-done" />
    </intent-filter>
</activity>
```

At `/kyc/capture` on the application's origin, start the session and return the confirmed transaction ID:

```html
<securysign-liveness style="display:block; min-height:480px"></securysign-liveness>
<button id="start">Start face check</button>
<a id="back" hidden>Back to the app</a>
<script type="module">
  import "https://cdn.jsdelivr.net/npm/@securysign/identity-capture@0.2.0/+esm";

  const f = new URLSearchParams(location.hash.slice(1));
  const session = { url: f.get("url"), token: f.get("token") };
  history.replaceState(null, "", location.pathname); // drop the token from the address bar

  const el = document.querySelector("securysign-liveness");
  const back = document.getElementById("back");
  el.addEventListener("securysign-liveness", (e) => {
    const r = e.detail;
    back.href = r.outcome === "confirmed"
      ? `com.example.app://face-done?transactionId=${encodeURIComponent(r.transactionId)}`
      : `com.example.app://face-done?reason=${encodeURIComponent(r.reason ?? r.outcome)}`;
    back.hidden = false; // a tap opens the app; Chrome can block an automatic custom-scheme redirect
  });
  document.getElementById("start").onclick = () => el.start(session);
</script>
```

For release, use an HTTPS Android App Link whose domain is associated with the app's package.

### Hosted capture page

Hosted capture runs in the system browser. Request a handoff link on the backend and open it in the app. Without `rp_urn`:

```bash
curl -X POST "https://securysign.com/api/kyc/customers/handoff" \
  -u "$SECURYSIGN_CLIENT_ID:$SECURYSIGN_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678" }'
```

With the customer's recorded reference:

```bash
curl -X POST "https://securysign.com/api/kyc/customers/handoff" \
  -u "$SECURYSIGN_CLIENT_ID:$SECURYSIGN_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }'
```

The response contains `customer_id`, any assigned `rp_urn`, `url` and `expiresIn` (900 seconds). The URL includes the capture token in its fragment. Open it with `UIApplication.shared.open` on iOS, an `ACTION_VIEW` intent on Android, `url_launcher` on Flutter or `Linking.openURL` on React Native. The hosted page captures and submits the document and face. Meanwhile, poll `POST /api/kyc/customers/verification` every few seconds with `{ "customer_id": "+254712345678" }` or `{ "customer_id": "+254712345678", "rp_urn": "user-42" }` until `status` is `verified`, `needs_review` or `failed`. Add `"return_selfie": true` to request the capture image with a verified result. While capture is in progress, or for another verdict, `selfie` is `null`; see [Selfie rules](#/docs/kyc#when-you-get-the-selfie).

Open the returned `url` intact, including its fragment. Requesting another handoff invalidates the previous link.

### Get the verdict in the app

Native, WebView and browser-tab captures return a confirmed `transactionId` to the app. Send it to the backend, which submits it as `livenessTransactionId` and reads `status` and `reasons`. Hosted capture submits through its own page, so the backend polls the verification endpoint instead.

#### Android

```kotlin
fun submitFace(transactionId: String) = lifecycleScope.launch {
    val verdict = backend.post("/kyc/face", mapOf("transactionId" to transactionId))
    if (verdict.status == "verified") proceed(verdict.verifiedName) else showRetry(verdict.reasons)
}
```

#### iOS

```swift
func submitFace(transactionId: String) async throws {
    let verdict: Verdict = try await backend.post("/kyc/face", body: ["transactionId": transactionId])
    if verdict.status == "verified" { proceed(verdict.verifiedName) } else { showRetry(verdict.reasons) }
}
```

| `status` | `reasons` | What your app does |
|---|---|---|
| `verified` | None | Continue. This verdict is final. |
| `needs_review` | `face_match_borderline` | Offer another face capture when the customer chooses to retry. |
| `failed` | `liveness_failed` or `face_mismatch` | Offer another face capture when the customer chooses to retry. |
| `failed` | `document_not_authentic` | Offer another document capture. |

The successful outcome is `verified`. Polling requests can include `return_selfie: true` or `1` to return a retained selfie with a verified result when available. See [Selfie rules](#/docs/kyc#when-you-get-the-selfie) for `null` values and [Get the verdict](#/docs/api-identity-verification#get-verification) for the response fields.

### Session rules

| Rule | What you do |
|---|---|
| One session per attempt | Open a new session for every attempt and whenever the app returns from the background. A new session revokes the previous token, so a capture still using the old one gets `401`. |
| Storage | Hold the token in memory only, and keep it out of disk storage, logs and crash reports. |
| Expiry | On `401 Invalid or expired liveness session`, open a new session and restart the capture. |
| Focus | A call, an app switch, the notification shade or the screen lock ends the capture as `stopped` with `APP_INACTIVE`. |
| Retries | Offer a retry: your customer chooses when to start the next attempt. |

For a returning customer, capture a fresh front-camera photo with a long edge of about 1600 pixels and submit it through the backend for a [Live Check](#/docs/kyc#7-optional-run-a-live-check).

### Troubleshooting the mobile SDK

| Symptom | Cause | Fix |
|---|---|---|
| Blank face check in a WebView | The WebView has no height | Use `MATCH_PARENT` and size the component to the viewport: `securysign-liveness { position: fixed; inset: 0; }` |
| Camera does not open in a WebView | The page is not a secure context, or the page permission was not granted | Serve over `https://` or `WebViewAssetLoader`, and grant access in `onPermissionRequest` or `requestMediaCapturePermissionFor` |
| CORS error on `/api/kyc/faceapi/...` from a bundled Android page | The pass-through does not allow `https://appassets.androidplatform.net` | Add the CORS rule from the WebView section |
| CORS error on `/api/kyc/faceapi/...` | The WebView called SecurySign directly | Route through the pass-through and set `apiBase` |
| "Back to the app" does nothing | The link's scheme or host does not match the app's intent filter | Match them exactly, and keep `launchMode="singleTop"` |
| `stopped` with `APP_INACTIVE` | The app lost focus | Retry with the app in the foreground |
| `422 x-client-key Field required` | The pass-through dropped `x-client-key` | Forward `authorization`, `content-type`, `accept` and `x-client-key` |
| `401` during capture | The token expired, belongs to another verification, or a newer session revoked it | Open one new session per attempt |
| `422` on start | `customer_id` or `phone_number` is missing, or is not a phone number in use (or, for `customer_id`, a valid email address) | Read `error` for the reason, then send phone numbers with `+` and the country code, and `customer_id` as a phone number or a valid email address |
| `409` on start | The `phone_number` belongs to another customer, or this `customer_id` is on file with a different phone number | Send this customer's own registered phone number |
| `409` on start, about `rp_urn` | The `rp_urn` is assigned to another customer, or this customer already holds a different `rp_urn` | Read `error`: it states the conflict and the `rp_urn` to send |
| `409 rp_urn … does not belong to this customer_id` | `rp_urn` belongs to a different customer, or to none | Send the `rp_urn` you set for this `customer_id`, or omit it |
| `422 customer_id is required` | A later call has no `customer_id` | Send `customer_id`, with or without `rp_urn` |
| `404` from `/face` for more than a few seconds | The capture did not reach SecurySign | Check the pass-through, or the SDK's base URL |
| `409` from `/face` | No document is on file, or no portrait was found in it | Submit the document again |
| `409 This verification is already verified` | This customer has already completed verification | Use the returned verdict |
| `selfie` is `null` with `return_selfie` | Capture is unfinished, the verdict is unsuccessful, or the retained image is unavailable | Ask again after the face check, or handle `reasons`; see [what `null` means](#/docs/kyc#when-you-get-the-selfie) |

To debug Android capture, enable `WebView.setWebContentsDebuggingEnabled(true)` in the debug build and inspect the WebView at `chrome://inspect`.
