# Verify a customer's identity

SecurySign reads the details and portrait from the submitted identity document. It checks the customer’s face capture for liveness and compares the captured face with the document portrait. Start a verification with `POST /api/kyc/verifications`, using the customer's phone number or email address. Capture the document in the frontend and upload the photos through the backend as base64 strings.

After the document is accepted, request a face-capture session and pass it to the frontend component. When capture finishes, submit its transaction ID through the backend. The result's `status` determines whether to continue, retry capture or review the case.

Customers who prefer their phone can use a handoff link. Request it after starting the verification and display the returned URL as a QR code. The customer completes capture on SecurySign's mobile page while the backend polls the result.

## Connect your frontend and backend

Before starting, obtain the following credentials and customer details:

- An approved **relying party (RP)** with the `signa-kyc` scope enabled by SecurySign.
- The `client_id` and `client_secret` SecurySign assigned to your RP for **OpenID Connect (OIDC)**, stored in your backend’s configuration.
- The customer’s registered phone number (`phone_number`) and identifier (`customer_id`, a phone number or email address) from your application’s customer record.
- An HTTPS page for browser camera access, with `@securysign/identity-capture@0.2.0` installed in the frontend project.

The browser sends document photos and the face-capture transaction ID to application routes such as `/kyc/document` and `/kyc/face`. These routes authenticate with the RP credentials, submit the corresponding SecurySign request and return its result. Identify the customer from the server-side session and load `customer_id`, `phone_number` and any recorded `rp_urn` from their record. Capture data comes from the frontend's JSON body.

Implement the following application routes to forward each step to the corresponding SecurySign endpoint:

| Route on your backend | Request your backend makes to SecurySign |
|---|---|
| `POST /kyc/start` | Send the customer identifiers to `POST /api/kyc/verifications`. |
| `POST /kyc/document` | Add the frontend’s `images` array to the customer identifier and send it to `POST /api/kyc/customers/document`. |
| `POST /kyc/liveness` | Send the customer identifier to `POST /api/kyc/customers/liveness-session` and relay the session JSON to your frontend. |
| `POST /kyc/face` | Map the frontend’s `transactionId` to `livenessTransactionId`, add the customer identifier and send it to `POST /api/kyc/customers/face`. |
| `POST /kyc/status` | Send the customer identifier to `POST /api/kyc/customers/verification` and relay the result. |
| `POST /kyc/handoff` | Send the customer identifier to `POST /api/kyc/customers/handoff` and relay the link JSON. |
| `POST /kyc/compare` | Add the frontend’s `image` and your application’s `checks` to the customer identifier and send them to `POST /api/kyc/customers/compare`. |

Authenticate with HTTP Basic authentication: `Authorization: Basic ` followed by the base64 encoding of `client_id:client_secret`. Send inputs as JSON with `Content-Type: application/json`, and relay SecurySign's HTTP status and JSON response to the frontend. A rejected request and a completed check with an unsuccessful verdict need different handling.

This Node.js helper uses `fetch` and reads the RP credentials from the backend's `CLIENT_ID` and `CLIENT_SECRET` environment variables. Pass the SecurySign endpoint as `path` and its JSON payload as `input`:

```javascript
export async function postKyc(path, input) {
  const credentials = `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`;
  const response = await fetch(`https://securysign.com${path}`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${Buffer.from(credentials).toString("base64")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(input),
  });
  return { httpStatus: response.status, body: await response.json() };
}
```

The following functions implement the application routes using that helper. The `customer` argument is the authenticated customer's record: `customer.customer_id` is their phone number or email address, and `customer.phone_number` is their registered number. If `rp_urn` is used, it starts as the application's own reference and is replaced with the full reference returned by the start request. Examples use `user-42` as the reference and `clientX` as the RP's `client_id`.

Without `rp_urn`:

```javascript
export function startVerification(customer) {
  return postKyc("/api/kyc/verifications", {
    customer_id: customer.customer_id,
    phone_number: customer.phone_number,
  });
}

export function submitDocument(customer, images) {
  return postKyc("/api/kyc/customers/document", {
    customer_id: customer.customer_id,
    images,
  });
}

export function openLivenessSession(customer) {
  return postKyc("/api/kyc/customers/liveness-session", {
    customer_id: customer.customer_id,
  });
}

export function submitFace(customer, transactionId) {
  return postKyc("/api/kyc/customers/face", {
    customer_id: customer.customer_id,
    livenessTransactionId: transactionId,
  });
}

export function readVerification(customer) {
  return postKyc("/api/kyc/customers/verification", {
    customer_id: customer.customer_id,
  });
}

export function createPhoneHandoff(customer) {
  return postKyc("/api/kyc/customers/handoff", {
    customer_id: customer.customer_id,
  });
}

export function compareIdentity(customer, { image, checks } = {}) {
  return postKyc("/api/kyc/customers/compare", {
    customer_id: customer.customer_id,
    image,
    checks,
  });
}

export function requestEnrolment(customer) {
  return postKyc("/api/enrolment/request", {
    customer_id: customer.customer_id,
  });
}
```

With `rp_urn` from the customer record:

```javascript
export function startVerification(customer) {
  return postKyc("/api/kyc/verifications", {
    customer_id: customer.customer_id,
    phone_number: customer.phone_number,
    rp_urn: customer.rp_urn,
  });
}

export function submitDocument(customer, images) {
  return postKyc("/api/kyc/customers/document", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
    images,
  });
}

export function openLivenessSession(customer) {
  return postKyc("/api/kyc/customers/liveness-session", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
  });
}

export function submitFace(customer, transactionId) {
  return postKyc("/api/kyc/customers/face", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
    livenessTransactionId: transactionId,
  });
}

export function readVerification(customer) {
  return postKyc("/api/kyc/customers/verification", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
  });
}

export function createPhoneHandoff(customer) {
  return postKyc("/api/kyc/customers/handoff", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
  });
}

export function compareIdentity(customer, { image, checks } = {}) {
  return postKyc("/api/kyc/customers/compare", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
    image,
    checks,
  });
}

export function requestEnrolment(customer) {
  return postKyc("/api/enrolment/request", {
    customer_id: customer.customer_id,
    rp_urn: customer.rp_urn,
  });
}
```

In `/kyc/document`, call `submitDocument(customer, payload.images)`. In `/kyc/face`, call `submitFace(customer, payload.transactionId)`. The remaining routes call their matching functions. Each helper returns `httpStatus` and `body`; use these for the route's HTTP status and JSON response. Customer identifiers come from the server-side record, while capture data comes from the frontend.

Browser face capture also requires a pass-through route at `/api/kyc/faceapi/*` on the application's origin. Forward `GET`, `POST` and `PUT` to the same path and query string at `https://securysign.com`, preserving the raw body and the `authorization`, `content-type`, `accept` and `x-client-key` headers. Relay the upstream status, body, `content-type` and `cache-control`. Allow capture bodies of at least 25 MB, with a 120-second timeout for `/liveness/video` and 30 seconds for other requests. Mount the route before the JSON body parser to preserve the capture body. See the [pass-through implementation](#/docs/sdk-reference#add-the-pass-through) for framework examples.

To run the curl examples, set `CLIENT_ID` and `CLIENT_SECRET` to the RP credentials in the backend environment. Use the customer's actual phone number and, for examples with `rp_urn`, the recorded reference in place of `user-42`. Image requests use `jq` to build the JSON body.

### Set the customer identifiers

Use the customer's phone number or email address as `customer_id`. The start request also requires `phone_number`. For example, an email-based identifier uses `customer_id: "jane@example.com"` with `phone_number: "+254712345678"`; a phone-based identifier uses `+254712345678` in both fields.

Phone numbers must include a country code and match that country's numbering plan. They may start with `+` or `00` and contain spaces, dashes, dots or brackets. Valid numbers are returned in E.164 format, so `+254 712 345 678` becomes `+254712345678`; email addresses are returned in lower case. Save the normalized identifier for later calls. To resume a verification, use the registered phone number. Each number is assigned to one customer.

The optional `rp_urn` field stores the application's own customer reference. It accepts a string or integer represented by 1–128 printable ASCII characters without spaces. For `client_id: "clientX"` and `rp_urn: "user-42"`, the response contains `rp_urn: "urn:securysign:clientX:user-42"`. The URN format, `urn:securysign:<client_id>:<id>`, places the reference in the RP's namespace.

After `startVerification(customer)` succeeds, save `result.body.rp_urn` in `customer.rp_urn`. Later requests still require `customer_id` and can include either the plain reference (`user-42`) or full URN (`urn:securysign:clientX:user-42`). References are case-sensitive: `user-42` and `User-42` are distinct. Within an RP, each reference belongs to one customer and each customer has one reference. JSON results include the assigned reference even when a later request omits it.

## 1. Start a verification

When the customer opens verification, call the application's `/kyc/start` route. The backend loads their record and sends `phone_number`, `customer_id` and any optional `rp_urn` to `POST /api/kyc/verifications`.

| Input | How you supply it |
|---|---|
| `phone_number` | Send the customer’s registered phone number as a string with its country code. |
| `customer_id` | Send the phone number or email address you use to identify this customer. If omitted, it defaults to `phone_number`; supply it explicitly with `rp_urn`. |
| `rp_urn` | Optionally send your system’s customer reference as a string or integer, using the format above. |

Without `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/verifications \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "phone_number": "+254712345678" }'
```

With the recorded `rp_urn`:

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

Response for a customer without an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "phone_number": "+254712345678",
  "status": "pending",
  "created": true,
  "enrolled": false
}
```

Response for a customer with an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "rp_urn": "urn:securysign:clientX:user-42",
  "phone_number": "+254712345678",
  "status": "pending",
  "created": true,
  "enrolled": false
}
```

Save the returned `customer_id` and optional `rp_urn` in the customer record and relay the result to the frontend. For `pending`, open document capture; for `document_verified`, open face capture; for `verified`, continue the customer's task. Handle `needs_review` and `failed` according to the [verdict guidance](#/docs/kyc#act-on-the-verdict).

`created: true` identifies a new verification; `created: false` means an existing verification was resumed. Repeating a start request for the same customer and RP returns that verification's current status. When identity comparison is enabled, `enrolled: true` means a verified identity is already on file, possibly from another RP. Complete the document and face steps to compare the new submission with that identity.

For invalid inputs or conflicting identifiers, use [Customer and reference errors](#/docs/kyc#customer-and-reference-errors) to correct the values before repeating the start request.

## 2. Capture, encode and submit the document

At `pending`, display document capture controls. Open the rear camera when the customer selects “Open camera”. Capture the front and then the back of a national identity document, or the photo page of a passport. The whole document should fit in the frame with some background, in good light and without glare.

Draw each captured frame onto a canvas with a long edge of about 1600 pixels and encode it as JPEG at quality `0.85`. Remove the `data:image/jpeg;base64,` prefix before uploading. The frontend sends the strings as `images` to `/kyc/document`; the backend adds the customer identifiers and forwards the array to `POST /api/kyc/customers/document`. For a single image, `image` can hold one base64 string instead.

### Capture in your web page

The example below provides a video preview, document-type selector and camera controls after the start request. “Open camera” selects the rear camera, and each capture click saves the displayed frame. Upload one image for a passport or two for a national ID.

```html
<select id="document-type" aria-label="Document type">
  <option value="2">National ID: front and back</option>
  <option value="1">Passport: photo page</option>
</select>
<video id="document-camera" autoplay muted playsinline></video>
<button id="open-camera" type="button">Open camera</button>
<button id="capture-document" type="button" disabled>Capture the front</button>
<p id="document-message" role="status"></p>
<script type="module">
  const video = document.getElementById("document-camera");
  const type = document.getElementById("document-type");
  const open = document.getElementById("open-camera");
  const capture = document.getElementById("capture-document");
  const message = document.getElementById("document-message");
  let images = [];

  function stopCamera() {
    video.srcObject?.getTracks().forEach((track) => track.stop());
    video.srcObject = null;
  }

  open.addEventListener("click", async () => {
    stopCamera();
    images = [];
    type.disabled = true;
    open.disabled = true;
    try {
      video.srcObject = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: "environment" }, audio: false,
      });
      await video.play();
      capture.textContent = type.value === "1" ? "Capture the photo page" : "Capture the front";
      capture.disabled = false;
      message.textContent = "Fit the whole document in the frame.";
    } catch (error) {
      message.textContent = "Allow camera access, then select Open camera again.";
      stopCamera();
      type.disabled = false;
      open.disabled = false;
    }
  });

  capture.addEventListener("click", async () => {
    if (!video.videoWidth || !video.videoHeight) return;
    capture.disabled = true;
    const scale = Math.min(1, 1600 / Math.max(video.videoWidth, video.videoHeight));
    const canvas = document.createElement("canvas");
    canvas.width = Math.round(video.videoWidth * scale);
    canvas.height = Math.round(video.videoHeight * scale);
    canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
    images.push(canvas.toDataURL("image/jpeg", 0.85).split(",")[1]);
    if (images.length < Number(type.value)) {
      capture.textContent = "Capture the back";
      message.textContent = "Turn the ID over and fit the whole back in the frame.";
      capture.disabled = false;
      return;
    }
    stopCamera();
    message.textContent = "Checking the document…";
    try {
      const response = await fetch("/kyc/document", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ images }),
      });
      const result = await response.json();
      if (!response.ok) throw new Error(result.error);
      message.textContent = result.status === "document_verified"
        ? "Document accepted. Continue to the face check."
        : `Document result: ${result.status}. ${result.reasons.join(", ")}`;
    } catch (error) {
      message.textContent = error.message || "Read the current verification before retrying.";
    } finally {
      type.disabled = false;
      open.disabled = false;
    }
  });
  window.addEventListener("pagehide", stopCamera);
</script>
```

A `document_verified` result proceeds to face capture in step 4. For `failed` with `document_not_authentic`, offer a document retake. Clear the previous images before collecting the required sides again, then submit the new array with the same customer identifier. Resubmitting the document resets the face step.

For native applications, [Capture the ID on the device](#/docs/sdk-reference#capture-the-id-on-the-device) produces the same base64 input.

### Submit files from your backend

For image files, base64-encode their bytes before building the JSON body. Here, `id-front.jpg` and `id-back.jpg` are the captured files; `jq` inserts their encoded contents into `images` in front-to-back order.

Without `rp_urn`:

```bash
base64 < id-front.jpg | tr -d '\n' > id-front.b64
base64 < id-back.jpg | tr -d '\n' > id-back.b64
jq -n --rawfile front id-front.b64 --rawfile back id-back.b64 \
  '{ customer_id: "+254712345678", images: [$front, $back] }' |
  curl -X POST https://securysign.com/api/kyc/customers/document \
    -u "$CLIENT_ID:$CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    --data-binary @-
```

With the recorded `rp_urn`:

```bash
base64 < id-front.jpg | tr -d '\n' > id-front.b64
base64 < id-back.jpg | tr -d '\n' > id-back.b64
jq -n --rawfile front id-front.b64 --rawfile back id-back.b64 \
  '{ customer_id: "+254712345678", rp_urn: "user-42", images: [$front, $back] }' |
  curl -X POST https://securysign.com/api/kyc/customers/document \
    -u "$CLIENT_ID:$CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    --data-binary @-
```

The [verification result](#/docs/kyc#5-submit-the-result-and-read-the-verdict) contains the extracted document details. For `422 At least one document image is required`, send at least one non-empty base64 image. For `409 This verification is already verified`, retrieve the existing result using the status request in step 6.

### Interpret the document assessment

Document processing extracts the printed details and portrait. SecurySign accepts the document when the machine-readable zone (MRZ) check passes. If that check is absent or was not performed, acceptance requires a passing overall processing result. A failed MRZ check rejects the document. Read `status` and `reasons`: an accepted document returns `document_verified`; rejection returns `failed` with `document_not_authentic`.

When additional document checks are enabled, processing requests checks of MRZ and printed-text layout, visible security patterns, barcode format, portrait embedding, screen recapture, black-and-white copies, geometry and barcode background. These checks do not change the acceptance rule above. A `document_verified` result permits the face step; the final `verified` verdict also requires the liveness and face-match checks to pass.

## 3. Request a session for the face capture

When the customer starts face capture, call `/kyc/liveness`. The backend sends `customer_id` and any matching `rp_urn` to `POST /api/kyc/customers/liveness-session`.

Without `rp_urn`:

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

With the recorded `rp_urn`:

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

Response for a customer without an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "url": "/api/kyc/faceapi",
  "token": "<token returned by SecurySign>",
  "expiresIn": 600
}
```

Response for a customer with an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "rp_urn": "urn:securysign:clientX:user-42",
  "url": "/api/kyc/faceapi",
  "token": "<token returned by SecurySign>",
  "expiresIn": 600
}
```

Relay the response to the frontend and pass its `url` and `token` to the component's `start` method in step 4. With the component's API base left at the page's origin, the relative `/api/kyc/faceapi` URL reaches the backend pass-through.

The session expires after 600 seconds, reported in `expiresIn`. Request a fresh session through `/kyc/liveness` for each attempt; its token replaces the previous one. For `401 Invalid or expired liveness session`, restart capture with a new session's `url` and `token`.

## 4. Start capture and handle its event

Import the capture package to register `<securysign-liveness>`, then mount the component and call `start(session)` with the session from step 3. The component handles the camera prompts.

The following controls belong in a frontend project that resolves `@securysign/identity-capture`. Show them after document verification succeeds. The click handler requests a new session and starts capture. A `securysign-liveness` event with `confirmed` supplies the `transactionId` to send to `/kyc/face`. For `not-live` or `stopped`, enable the button so the customer can try again.

```html
<button id="start-face" type="button">Start face check</button>
<div id="face-capture"></div>
<p id="face-message" role="status"></p>
<script type="module">
  import "@securysign/identity-capture";
  const button = document.getElementById("start-face");
  const host = document.getElementById("face-capture");
  const message = document.getElementById("face-message");

  async function post(path, body = {}) {
    const response = await fetch(path, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const result = await response.json();
    if (!response.ok) throw new Error(result.error);
    return result;
  }

  button.addEventListener("click", async () => {
    button.disabled = true;
    message.textContent = "";
    try {
      const session = await post("/kyc/liveness");
      const capture = document.createElement("securysign-liveness");
      host.replaceChildren(capture);
      capture.addEventListener("securysign-liveness", async (event) => {
        host.replaceChildren();
        if (event.detail.outcome !== "confirmed") {
          message.textContent = event.detail.outcome === "not-live"
            ? "Face the camera in even light and try again."
            : "Select Start face check to try again.";
          button.disabled = false;
          return;
        }
        message.textContent = "Matching your face to the document…";
        try {
          const result = await post("/kyc/face", { transactionId: event.detail.transactionId });
          message.textContent = result.status === "verified"
            ? "Identity verified."
            : `${result.status}: ${result.reasons.join(", ")}`;
          button.disabled = result.status === "verified";
        } catch (error) {
          message.textContent = error.message || "Read the current verification before retrying.";
          button.disabled = false;
        }
      }, { once: true });
      await capture.start(session);
    } catch (error) {
      host.replaceChildren();
      message.textContent = error.message || "Request a new session and try again.";
      button.disabled = false;
    }
  });
</script>
```

Use the result from step 5 to select the next screen. A document failure returns to document capture. A face retry requests a fresh session when the customer starts another attempt. A `verified` result completes the verification step.

The Content Security Policy must allow the application's origin and `https://securysign.com` in `connect-src`, `'wasm-unsafe-eval'` in `script-src` for WebAssembly, and `blob:` in `worker-src`. Keep the rest of the application's policy. See the [web capture SDK reference](#/docs/sdk-reference#web-capture-sdk) for the component API and customization, or the [mobile capture SDK reference](#/docs/sdk-reference#mobile-capture-sdk) for native SDKs and WebViews.

## 5. Submit the result and read the verdict

In the `/kyc/face` route, copy the capture event's `transactionId` into `livenessTransactionId`. Add `customer_id` and any matching `rp_urn` from the customer record, then send the payload to `POST /api/kyc/customers/face`. Include `return_selfie: true` to request the capture image with the result.

Replace the sample `livenessTransactionId` with the ID from the capture event. SecurySign uses it to retrieve the liveness result and compare the captured face with the portrait extracted from the submitted document.

Without `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/face \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "livenessTransactionId": "497f6eca-6276-4993-bfeb-53cbbbba6f08" }'
```

With the recorded `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/face \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "rp_urn": "user-42", "livenessTransactionId": "497f6eca-6276-4993-bfeb-53cbbbba6f08" }'
```

Response for a customer without an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "status": "verified",
  "verified_name": "JANE WANJIRU DOE",
  "doc_type": "National ID",
  "doc_number": "123456789",
  "personal_number": "12345678",
  "doc_expiry": "2031-05-14",
  "nationality": "KEN",
  "surname": "DOE",
  "given_names": "JANE WANJIRU",
  "date_of_birth": "1990-01-31",
  "sex": "F",
  "issuing_state": "KEN",
  "date_of_issue": "2021-05-14",
  "face_match_score": 0.91,
  "liveness_score": 1.0,
  "reasons": []
}
```

Response for a customer with an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "rp_urn": "urn:securysign:clientX:user-42",
  "status": "verified",
  "verified_name": "JANE WANJIRU DOE",
  "doc_type": "National ID",
  "doc_number": "123456789",
  "personal_number": "12345678",
  "doc_expiry": "2031-05-14",
  "nationality": "KEN",
  "surname": "DOE",
  "given_names": "JANE WANJIRU",
  "date_of_birth": "1990-01-31",
  "sex": "F",
  "issuing_state": "KEN",
  "date_of_issue": "2021-05-14",
  "face_match_score": 0.91,
  "liveness_score": 1.0,
  "reasons": []
}
```

Save the result in the customer record and return it to the frontend. Use `status` and `reasons` to choose the next step; the remaining fields contain the identity details. Document submissions and status requests return the same result format. Dates and country values retain the document processor’s extracted representation; parse them according to that representation rather than assuming a normalized output format. Unavailable details are `null`, as are scores for steps that have not run.

| Result fields | How you use them |
|---|---|
| `customer_id`, optional `rp_urn` | Associate the result with your customer record. |
| `status`, `reasons` | Decide which step to take next using the verdict table below. You may receive several reasons. |
| `verified_name`, `surname`, `given_names` | Read the holder’s full name and its extracted parts. |
| `doc_type`, `doc_number` | Read the document class and the document’s own number. On a Kenyan ID, `doc_number` is the card serial number and changes when the card is replaced. |
| `personal_number` | Read the person’s national ID number. For a passport, you receive a personal number when one was extracted, or `null`. |
| `doc_expiry`, `date_of_issue` | Read the document’s expiry and issue dates. Use the expiry date to apply your document-validity policy. |
| `date_of_birth`, `sex` | Read the holder’s date of birth and sex. |
| `nationality`, `issuing_state` | Read the holder’s nationality and the document’s issuing country. |
| `face_match_score`, `liveness_score` | Read the face similarity and liveness scores, each on a scale from `0` to `1`. |
| Optional `enrolled`, `matches` | Read the comparison with an existing identity when identity comparison is enabled, as described below. |
| Optional `selfie` | Read the capture image when you requested it; see [When you get the selfie](#/docs/kyc#when-you-get-the-selfie). |

### Act on the verdict

More than one reason may appear in a result. If any reason identifies a document failure, the retry must begin with a new document submission before another face capture.

| Status | Reason | How you continue |
|---|---|---|
| `pending` | None | Show the document camera controls, capture the required sides and submit `images` through `/kyc/document`. |
| `document_verified` | None | Enable the face-capture control; its click requests `/kyc/liveness` and starts the component with the returned session. |
| `verified` | None | Save the verified result and let the customer continue the task that required verification. |
| `needs_review` | `face_match_borderline` | When your customer retries, request a fresh liveness session for their new capture. Alternatively, send the result to your manual review process. |
| `failed` | `document_not_authentic` | Clear the previous document images, capture new ones and submit them with the same `customer_id`. |
| `failed` | `liveness_failed` | Ask the customer to face the camera in even light, request a new session and submit the next confirmed capture’s transaction ID. |
| `failed` | `face_mismatch` | Repeat face capture with a new session. If the mismatch persists, establish whether the document belongs to the person presenting it. |
| `failed` | `identity_mismatch` | Check the `customer_id` against your customer record and confirm it identifies the person presenting the document before retrying. |

Use the verdict for the decision and the scores to interpret it. With the default thresholds, face similarity of `0.75` or higher passes. If the document is accepted and confirmed liveness scores at least `0.50`, face similarity from `0.65` up to, but not including, `0.75` produces `needs_review`; lower similarity produces `failed`. Confirm the deployment's configured thresholds with SecurySign.

| Face-submission error | Your next action |
|---|---|
| `422 A liveness transaction id is required` | Supply the completed capture’s `transactionId` as `livenessTransactionId`. |
| `409 Submit the document before the liveness step` | Submit the document, then capture the face after `document_verified`. |
| `409 Resubmit the document before the liveness step` | Submit a new document capture before retrying the face step. |
| `409 No document portrait on file for this session` | Submit a clear document photo with its portrait visible, then retry face capture. |
| `409 This verification is already verified` | Read the existing verification using the request in step 6. |

### Compare the submission with an identity on file

When identity comparison is enabled, `enrolled` and `matches` describe how the submission compares with an identity already on file. For `enrolled: true`, check the field comparisons after document processing and `matches.face` after face capture.

```json
{
  "enrolled": true,
  "matches": {
    "face": true,
    "full_name": true,
    "surname": true,
    "given_names": true,
    "document_number": false,
    "personal_number": true,
    "document_type": true,
    "nationality": true,
    "issuing_state": true,
    "date_of_birth": true,
    "date_of_expiry": false,
    "date_of_issue": false,
    "sex": true
  }
}
```

In `matches`, `true` means a match, `false` means a difference, and `null` means a value is unavailable or the comparison is unfinished. A renewed or replacement document can change `document_number`, `date_of_expiry` and `date_of_issue`; review those differences together. The submitted values appear as `doc_number`, `doc_expiry` and `date_of_issue` in the same result.

If an existing identity portrait is available, the captured face is also compared with it. For `failed` with `identity_mismatch`, check that `customer_id` belongs to the person being captured before retrying.

### When you get the selfie

Include `return_selfie: true` in a face submission or verification-status request to obtain the capture image. For a verified customer with an available capture, `selfie.content_type` is `image/png` and `selfie.data` contains the base64-encoded image bytes. Other statuses and unavailable captures return `selfie: null`.

To download retained evidence, send `customer_id` and any matching `rp_urn` to `POST /api/kyc/customers/selfie` or `POST /api/kyc/customers/video`. Successful responses contain binary PNG or `video/mp4` data. The examples below save the files as `selfie.png` and `liveness.mp4`.

Without `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/selfie \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678" }' \
  -o selfie.png
```

With the recorded `rp_urn`:

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

Without `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/video \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678" }' \
  -o liveness.mp4
```

With the recorded `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/video \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }' \
  -o liveness.mp4
```

For `403 The selfie is available only once the verification is verified.`, complete the document and face steps before requesting it again. For `404 Evidence not found`, check capture availability and retention with SecurySign; an unavailable evidence file does not replace the verification verdict. Document images, portraits and captures are encrypted at rest. Agree the capture-retention period with SecurySign for the deployment.

## 6. (Optional) Continue on a phone

When the customer chooses to capture on their phone, call the application's `/kyc/handoff` route. The backend requests a link from `POST /api/kyc/customers/handoff`, including `customer_id` and any matching `rp_urn` for the started verification.

Without `rp_urn`:

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

With the recorded `rp_urn`:

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

Response for a customer without an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "url": "https://securysign.com/kyc-mobile.html#t=<token returned by SecurySign>",
  "expiresIn": 900
}
```

Response for a customer with an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "rp_urn": "urn:securysign:clientX:user-42",
  "url": "https://securysign.com/kyc-mobile.html#t=<token returned by SecurySign>",
  "expiresIn": 900
}
```

Display the returned `url` as a QR code. The customer scans it, opens the link on their phone and completes document and live face capture on SecurySign's hosted page. Track expiry using `expiresIn`; the link lasts 900 seconds.

Poll `/kyc/status` every few seconds while capture is in progress. The backend calls `POST /api/kyc/customers/verification` with `customer_id`, adding the matching `rp_urn` and `return_selfie: true` if those options are used.

Without `rp_urn`:

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

With the recorded `rp_urn`:

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

Interpret the result as described in step 5. Keep polling while `status` is `pending` or `document_verified`. Stop at `verified`, `needs_review` or `failed` and handle the verdict. The same request can check progress after a timeout or when the customer returns, before repeating any submission.

For `401 Invalid or expired handoff session`, request another link through `/kyc/handoff` and replace the QR code with the new `url`. Issuing a new link invalidates the previous token. A failed face submission also retires its token, so a phone retry needs a new handoff link.

## 7. (Optional) Run a Live Check

`POST /api/kyc/customers/compare` checks a photo or identity details against a verified identity. Use `customer_id` and any matching `rp_urn` from the customer record. A face comparison takes a fresh customer photo; detail comparisons take the values held by the application.

Send the photo in `image` as base64 without a `data:` prefix. Send details in a `checks` object using the attribute names below. A request can contain a photo, details or both. To establish a new verified identity, use the document and live face flow first.

Comparison uses the verified identity on file. With cross-RP comparison enabled, the identity may come from another RP. Otherwise, it uses the latest verified identity at the requesting RP.

### Submit a photo for comparison

Base64-encode the captured photo and insert it in `image`. This example reads the customer's photo from `customer-photo.jpg`.

Without `rp_urn`:

```bash
base64 < customer-photo.jpg | tr -d '\n' > customer-photo.b64
jq -n --rawfile photo customer-photo.b64 \
  '{ customer_id: "+254712345678", image: $photo }' |
  curl -X POST https://securysign.com/api/kyc/customers/compare \
    -u "$CLIENT_ID:$CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    --data-binary @-
```

With the recorded `rp_urn`:

```bash
base64 < customer-photo.jpg | tr -d '\n' > customer-photo.b64
jq -n --rawfile photo customer-photo.b64 \
  '{ customer_id: "+254712345678", rp_urn: "user-42", image: $photo }' |
  curl -X POST https://securysign.com/api/kyc/customers/compare \
    -u "$CLIENT_ID:$CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    --data-binary @-
```

The face result is in `face.match`, with the score in `face.similarity` and the configured pass threshold in `face.threshold`. For example, `{"match": true, "similarity": 0.90, "threshold": 0.75}` indicates a match with the identity's document portrait. A details-only request returns `face: null`.

### Submit details for comparison

Use the attribute names below in `checks` with non-empty string values; numeric inputs are read as strings. To compare fields from a previous result, map `doc_number` to `checks.document_number`, `doc_type` to `checks.document_type`, `doc_expiry` to `checks.date_of_expiry`, and `verified_name` to `checks.full_name`.

| Attributes | Accepted values and comparison rules |
|---|---|
| `full_name`, `surname`, `given_names` | Names in any word order or case, with punctuation: `Doe, Jane` matches `JANE DOE`. |
| `document_number`, `personal_number` | Document or personal numbers; spaces, dots, dashes and slashes are ignored during comparison. |
| `document_type` | The document class you want to compare. |
| `nationality`, `issuing_state` | International Organization for Standardization (ISO) 3166 alpha-3 country codes, such as `KEN`, in either case. |
| `date_of_birth`, `date_of_expiry`, `date_of_issue` | Dates as `YYYY-MM-DD`, `DD.MM.YYYY`, `DD/MM/YYYY`, `DD-MM-YYYY`, `YYYYMMDD` or `YYYY/MM/DD`. |
| `sex` | `M`, `F`, `MALE` or `FEMALE`. |

Without `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/compare \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "+254712345678",
    "checks": {
      "personal_number": "12345678",
      "date_of_birth": "31/01/1990",
      "full_name": "Doe, Jane Wanjiru"
    }
  }'
```

With the recorded `rp_urn`:

```bash
curl -X POST https://securysign.com/api/kyc/customers/compare \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "+254712345678", "rp_urn": "user-42",
    "checks": {
      "personal_number": "12345678",
      "date_of_birth": "31/01/1990",
      "full_name": "Doe, Jane Wanjiru"
    }
  }'
```

Response for a customer without an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "enrolled": true,
  "matches": {
    "customer_id": true,
    "personal_number": true,
    "date_of_birth": true,
    "full_name": true
  },
  "face": null
}
```

Response for a customer with an assigned `rp_urn`:

```json
{
  "customer_id": "+254712345678",
  "rp_urn": "urn:securysign:clientX:user-42",
  "enrolled": true,
  "matches": {
    "customer_id": true,
    "personal_number": true,
    "date_of_birth": true,
    "full_name": true
  },
  "face": null
}
```

`matches.customer_id` indicates whether a verified identity is available. Each submitted attribute appears in `matches` as `true`, `false` or `null`. The response also contains `customer_id`, any recorded `rp_urn`, `enrolled` and `face`.

With `enrolled: false`, the response has `matches.customer_id: false`, `null` for each submitted check and `face: null`. Start a verification and complete document and live face capture before comparing again.

| Live Check error | Your next action |
|---|---|
| `422 checks must be an object of attribute names and values, e.g. {"personal_number": "12345678"}` | Supply an object using the attribute names above. |
| `422 checks contains "…", which SecurySign does not check` | Replace the named attribute with one from the table; the error also lists accepted names. |
| `422 checks.<attribute> must be a non-empty string` | Supply a non-empty string or numeric value for that attribute. |
| `422 image must be a base64-encoded photo` | Supply the base64 image as a string, or omit `image` to compare details. |
| `409 No document portrait is on file for this customer: send the checks only, or verify the customer again` | Send `checks` without `image`, or run a new full verification. |

## 8. (Optional) Carry the verification into enrolment

Once verification returns `status: "verified"`, request an enrolment reference through `POST /api/enrolment/request`. Authenticate with the same RP credentials and send `customer_id` with any matching `rp_urn`.

Without `rp_urn`:

```bash
curl -X POST https://securysign.com/api/enrolment/request \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678" }'
```

With the recorded `rp_urn`:

```bash
curl -X POST https://securysign.com/api/enrolment/request \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "+254712345678", "rp_urn": "user-42" }'
```

```json
{
  "request_uri": "urn:securysign:request:<reference returned by SecurySign>",
  "expires_in": 600
}
```

URL-encode the returned `request_uri` and add it to the [hosted enrolment link](#/docs/enrolment#enrol-through-securysign-directly), in the query string before `#/enrol`. The customer must open the link within `expires_in` seconds. The flow reuses the verification instead of repeating identity capture, and the certificate uses `verified_name` from the document. See [Reuse a verification you ran yourself](#/docs/enrolment#reuse-a-verification-you-ran-yourself) for the authorization parameters and callbacks.

If a `request_uri` has expired or been claimed by another person, obtain a new reference and build a new link. This endpoint allows 60 requests per minute per client IP address; after `429`, wait for the window to reset. Other identifier and authentication failures use the fixes below.

## Resolve request errors

A failed request is identified by its HTTP status and JSON `error`, while a completed check returns a verification verdict. For example, omitting `customer_id` from a later request returns HTTP `422` with `{"error": "customer_id is required"}`. Relay both the status and JSON to the frontend so it can handle the specific failure.

### Customer and reference errors

Correct invalid identifiers in the customer record before retrying. `customer_id` must be a string of at most 255 characters without control characters. Email identifiers have a maximum of 254 characters overall and 64 before `@`.

| Example input | HTTP status and error | Your next action |
|---|---|---|
| Omitted `customer_id` in a later request | `422 customer_id is required` | Include your customer’s phone number or email address. |
| `rp_urn: "user-42"` without `customer_id` in the start request | `422 customer_id is required when you send rp_urn` | Include `customer_id` explicitly in the start request. |
| Omitted `phone_number` in the start request | `422 phone_number is required` | Include your customer’s phone number in the start request. |
| `jane@example.com` as `phone_number` | `422 phone_number must be a phone number in international format, e.g. +254712345678` | Supply a phone number with its country code. |
| `user-4711` as `customer_id` | `422 customer_id must be a phone number in international format, e.g. +254712345678, or an email address` | Supply a phone number or email address; use `rp_urn` for your system’s identifier. |
| `0712345678` as `customer_id` | `422 customer_id looks like a phone number without a country code: send it in international format, e.g. +254712345678` | Add the country code to the number. |
| `+2547123456789` or `+254 300 000 000` as `customer_id` | `422` naming the country’s number length or assigned ranges | Correct the number using that country’s full country and area codes, number length and assigned number range. |
| `jane@localhost` as `customer_id` | `422` naming an email’s local part or domain | Supply a complete email address with a local part and a domain with a top-level domain, such as `jane@example.com`. |
| `user 42` as `rp_urn` | `422 rp_urn must be printable ASCII without spaces, e.g. 42, a UUID or urn:yourapp:user:42` | Supply a reference using printable ASCII characters without spaces, such as `user-42` or a universally unique identifier (UUID). |
| A value longer than 128 characters as `rp_urn` | `422 rp_urn is limited to 128 characters` | Shorten your reference to at most 128 characters. |
| Another RP’s full URN as `rp_urn` | `403 rp_urn belongs to another relying party: send an rp_urn your RP set` | Supply a reference assigned under your own RP’s credentials. |
| A reference assigned to a different customer as `rp_urn` in a later request | `409 rp_urn … does not belong to this customer_id: send the rp_urn recorded for this customer, or omit rp_urn` | Supply the customer’s recorded reference, or omit `rp_urn`. |
| A number assigned to another customer as `phone_number` | `409 phone_number … already belongs to another customer: send this customer's own phone number` | Supply the phone number assigned to this customer. |
| A number different from the customer’s registered number as `phone_number` | `409 This customer_id is on file with a different phone_number: send the phone number registered for this customer` | Supply the customer’s registered phone number. |
| A reference already assigned to another customer as `rp_urn` in the start request | `409 rp_urn … is already assigned to another customer: send that customer's customer_id with it, or use a different rp_urn for this customer` | Use the matching customer identifier, or assign a different reference to this customer. |
| A different reference as `rp_urn` for a customer with a recorded reference | `409 This customer_id already has rp_urn …: send that rp_urn, or omit rp_urn` | Supply the reference in the error, or omit `rp_urn`. |
| A reference assigned concurrently to another customer as `rp_urn` | `409 rp_urn … was just assigned to another customer: start again with a different rp_urn` | Repeat the start request with a different reference. |

### Authentication, payloads and retries

| HTTP status and error | Your next action |
|---|---|
| `400 Invalid JSON body` | Supply a valid JSON object. |
| `400 Unable to read request body` | Repeat the request with a complete JSON body. |
| `403 Call this endpoint with your client credentials (HTTP Basic)` | Authenticate your Live Check request with your RP’s `client_id` and `client_secret`. |
| `401 invalid_client` | Use the `client_id` and current `client_secret` assigned to your RP. |
| `403 This relying party is not enabled for Identity Verification` | Ask SecurySign to enable identity verification for your approved RP. |
| `404 No verification for this customer: start one with POST /kyc/verifications` | Start a verification with the same customer identifier and RP credentials. |
| `413 Request body exceeds 50MB limit` | Reduce the complete JSON body below 50 megabytes (MB), including base64 image data. |
| `415 Content-Type must be application/json` | Set `Content-Type: application/json`. |
| `422 return_selfie must be true or false (1 or 0 also work).` | Supply a JSON boolean, `1` or `0`, or the strings `"true"`, `"false"`, `"1"` or `"0"`. String values for `return_selfie` are case-insensitive. |
| `408` or a request timeout | Read the current verification, then retry the unfinished step. |
| `429` | Wait for the 60-second rate-limit window to reset, then retry. |
| `5xx` | Read the current verification and try again. |

After a timeout or `5xx`, check `POST /api/kyc/customers/verification` with the same `customer_id` before resubmitting. Continue from the recorded status if the step completed; repeat its request if it remains unfinished.

Limits apply per client IP address in 60-second windows: 20 requests per endpoint group for start, document, liveness-session, face, compare and handoff creation; 60 for evidence retrieval; and 120 for capture requests and phone handoff requests. After `429`, wait for the window to reset before retrying.

Before deploying, test a document retake and confirm that the new images reach `/api/kyc/customers/document` with the same `customer_id`. Check that a face retry obtains a new session and submits its transaction ID. Complete a phone handoff and check that polling stops at the verdict. Confirm that the pass-through forwards `x-client-key` and that `needs_review` enters the application's review process.
