Get started

Integration guide

How to put VerifyMe KYC inside your product. This guide covers the architecture, the core concepts, the four integration paths (hosted redirect, embedded Web SDK, mobile WebView, API-only), status handling, branding, testing and a go-live checklist.

Architecture overview

VerifyMe is delivered as an API plus a hosted onboarding UI. Your backend owns the business relationship (who the applicant is, what they are applying for). VerifyMe owns the regulated, sensitive part: collecting identity data, talking to providers (DigiLocker, PAN, bank, face match), storing evidence and producing an auditable result. You never handle Aadhaar numbers, selfies or bank credentials.

Integration sequenceYour backend creates a request, receives an onboarding URL, sends the applicant to the hosted UI, the applicant completes the steps and returns, and VerifyMe notifies the backend by signed webhook, after which the backend reads the result. Your backendserver-side code Applicantyour app or browser VerifyMe hosted UI/start/vm_t_... VerifyMe API/v1 and webhooks POST /v1/onboarding-requestsIdempotency-Key, externalReference, returnUrl1 201 { requestId, onboardingUrl }status LINK_ISSUED2 Redirect, popup, iframe or WebViewopen onboardingUrl3 GET /start/vm_t_...token becomes a short-lived session4 Applicant completes OTP, DigiLocker, PAN, bank, selfie, consent Return to returnUrl?requestId=...&status=SUBMITTEDor SDK event "submitted"5 Webhook onboarding.submittedHMAC-SHA256 signed, retried until 2xx6 Webhook onboarding.approved or rejectedafter review in the portal7 GET /v1/onboarding-requests/{id}/resultnever trust the query string8 200 result: verified data, flags, decisionmasked identifiers by default9
  1. POST /v1/onboarding-requests. Idempotency-Key, externalReference, returnUrl.
  2. 201 { requestId, onboardingUrl }. status LINK_ISSUED.
  3. Redirect, popup, iframe or WebView. open onboardingUrl.
  4. GET /start/vm_t_.... token becomes a short-lived session.
  5. Return to returnUrl?requestId=...&status=SUBMITTED. or SDK event "submitted".
  6. Webhook onboarding.submitted. HMAC-SHA256 signed, retried until 2xx.
  7. Webhook onboarding.approved or rejected. after review in the portal.
  8. GET /v1/onboarding-requests/{id}/result. never trust the query string.
  9. 200 result: verified data, flags, decision. masked identifiers by default.

Three things to remember

  1. Create requests only from your server. The API key is a secret and must never reach a browser or app.
  2. The applicant only ever needs the onboardingUrl. Redirect, iframe, popup and WebView are just different ways to open it.
  3. Results come from the API and webhooks, never from the browser. The return URL query string is a hint to update your UI, not proof of anything.

Prerequisites

  • A VerifyMe workspace and a sandbox API key (vm_test_...), created in the portal under Developers, API keys.
  • A server-side runtime that can make HTTPS calls and keep a secret in environment variables or a secrets manager.
  • A way to store the requestId against your own user or order (the externalReference you send is echoed back everywhere).
  • For production: an HTTPS endpoint for webhooks, your return URL host on the tenant return URL allow-list, and your site origins on the allowed origins list if you embed.

Core concepts

Tenant
Your VerifyMe workspace. Everything is scoped to it: API keys, flows, branding, webhook endpoints, data. Sandbox and production are separate deployments.
Flow
An ordered list of steps with a purpose, notice version and review policy. Identified by a flowKey such as india-business-kyc-v1. Flows are versioned; a request is pinned to the version that was live when it was created. See Flows and steps.
Onboarding request
One verification journey for one applicant. Created by POST /v1/onboarding-requests. Identified by requestId (vm_req_...) and your externalReference.
Step
One unit of work inside a flow, such as MobileOtp, PanVerification, SelfieFaceMatch, Consent. Each step has its own status and attempt count.
Applicant session
When the applicant opens the link, the opaque token (vm_t_...) is exchanged for a short-lived session (two hours by default) bound to that browser. You never see or manage sessions.
Result
The verified data, flags, step outcomes, documents and review decision for a submitted request, available from GET /v1/onboarding-requests/{id}/result and announced by webhooks.

Request lifecycle

StatusMeaningTerminal
LINK_ISSUEDRequest created, applicant has not startedno
IN_PROGRESSAt least one step has been attemptedno
SUBMITTEDApplicant finished and submitted. Waiting for automated or manual reviewno
REVIEWINGA reviewer opened itno
REVERTEDReviewer sent specific steps back to the applicant. The same link works againno
APPROVED / REJECTEDFinal decisionyes
EXPIREDLink expired before submission (LINK_EXPIRED)yes
CANCELLEDCancelled by your API call, a reviewer or an operatoryes

SUBMITTED and REVIEWING requests never expire; they wait for a decision. The API also uses a CREATED state internally before the link is issued, but requests you create are returned as LINK_ISSUED.

Path A: Hosted redirect

The simplest and most robust path. Your backend creates the request and returns a redirect to the applicant's browser. VerifyMe runs everything on a branded page, then redirects back to your returnUrl.

1. Create the request and redirect

Pick the language of your backend. The tab you choose is remembered across this page.

curl -X POST "https://kyc.example.com/v1/onboarding-requests" \
  -H "Authorization: Bearer $VERIFYME_API_KEY" \
  -H "Idempotency-Key: kyc-user-8841-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "externalReference": "USER-8841",
    "applicantType": "merchant",
    "mobile": "9876541234",
    "email": "ravi@example.com",
    "flowKey": "india-business-kyc-v1",
    "returnUrl": "https://shop.example.com/kyc/done",
    "expiresInHours": 72,
    "locale": "en",
    "metadata": { "plan": "gold" }
  }'
# Then send the user to the onboardingUrl from the response (HTTP 303 redirect).
// Express 4/5, Node 18+ (global fetch)
import express from "express";

const app = express();
const HOST = process.env.VERIFYME_HOST;          // https://kyc.example.com
const KEY = process.env.VERIFYME_API_KEY;        // vm_test_... or vm_live_...

app.post("/kyc/start", requireLogin, async (req, res) => {
  const user = req.user;
  const r = await fetch(`${HOST}/v1/onboarding-requests`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      // Stable per user action: a double click or a retry reuses the same request.
      "Idempotency-Key": `kyc-${user.id}-attempt-${user.kycAttempt}`,
    },
    body: JSON.stringify({
      externalReference: String(user.id),
      mobile: user.mobile,
      returnUrl: "https://shop.example.com/kyc/done",
    }),
  });
  if (!r.ok) {
    const { error } = await r.json();
    console.error("VerifyMe create failed", r.status, error.code, error.traceId);
    return res.status(502).send("Could not start verification. Please try again.");
  }
  const { requestId, onboardingUrl } = await r.json();
  await db.kycRequests.insert({ userId: user.id, requestId, status: "LINK_ISSUED" });
  res.redirect(303, onboardingUrl);
});
# Flask 3, requests
import os
import requests
from flask import Flask, redirect, abort, g

app = Flask(__name__)
HOST = os.environ["VERIFYME_HOST"]
KEY = os.environ["VERIFYME_API_KEY"]

@app.post("/kyc/start")
@login_required
def kyc_start():
    user = g.user
    r = requests.post(
        f"{HOST}/v1/onboarding-requests",
        headers={
            "Authorization": f"Bearer {KEY}",
            "Idempotency-Key": f"kyc-{user.id}-attempt-{user.kyc_attempt}",
        },
        json={
            "externalReference": str(user.id),
            "mobile": user.mobile,
            "returnUrl": "https://shop.example.com/kyc/done",
        },
        timeout=15,
    )
    if not r.ok:
        err = r.json().get("error", {})
        app.logger.error("VerifyMe create failed %s %s %s", r.status_code, err.get("code"), err.get("traceId"))
        abort(502)
    body = r.json()
    save_kyc_request(user.id, body["requestId"])
    return redirect(body["onboardingUrl"], code=303)
// ASP.NET Core 8 minimal API
using System.Net.Http.Json;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("verifyme", c =>
{
    c.BaseAddress = new Uri(builder.Configuration["VerifyMe:Host"]!);      // https://kyc.example.com
    c.DefaultRequestHeaders.Authorization =
        new("Bearer", builder.Configuration["VerifyMe:ApiKey"]);
    c.Timeout = TimeSpan.FromSeconds(15);
});
var app = builder.Build();

app.MapPost("/kyc/start", async (IHttpClientFactory factory, HttpContext ctx) =>
{
    var userId = ctx.User.FindFirst("sub")!.Value;
    var http = factory.CreateClient("verifyme");

    var req = new HttpRequestMessage(HttpMethod.Post, "/v1/onboarding-requests")
    {
        Content = JsonContent.Create(new
        {
            externalReference = userId,
            mobile = "9876541234",
            returnUrl = "https://shop.example.com/kyc/done",
        }),
    };
    req.Headers.Add("Idempotency-Key", $"kyc-{userId}-attempt-1");

    var res = await http.SendAsync(req);
    if (!res.IsSuccessStatusCode) return Results.StatusCode(502);

    var created = await res.Content.ReadFromJsonAsync<CreatedRequest>();
    // persist created!.RequestId against the user here
    return Results.Redirect(created!.OnboardingUrl);                      // 302
});

app.Run();

record CreatedRequest(string RequestId, string OnboardingUrl, string Status, DateTime ExpiresAt);
// Spring Boot 3, Java 17 (java.net.http + Jackson)
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.view.RedirectView;

@RestController
public class KycController {
  private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
  private final ObjectMapper json = new ObjectMapper();
  @Value("${verifyme.host}") String host;
  @Value("${verifyme.api-key}") String apiKey;

  @PostMapping("/kyc/start")
  public RedirectView start() throws Exception {
    String userId = "8841";
    String body = json.writeValueAsString(java.util.Map.of(
        "externalReference", userId,
        "mobile", "9876541234",
        "returnUrl", "https://shop.example.com/kyc/done"));
    HttpRequest req = HttpRequest.newBuilder(URI.create(host + "/v1/onboarding-requests"))
        .timeout(Duration.ofSeconds(15))
        .header("Authorization", "Bearer " + apiKey)
        .header("Idempotency-Key", "kyc-" + userId + "-attempt-1")
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();
    HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 201) throw new ResponseStatusException(HttpStatus.BAD_GATEWAY);
    JsonNode created = json.readTree(res.body());
    // persist created.get("requestId").asText() against the user here
    RedirectView redirect = new RedirectView(created.get("onboardingUrl").asText());
    redirect.setStatusCode(HttpStatus.SEE_OTHER);
    return redirect;
  }
}
<?php
// Plain PHP 8 with the curl extension (works in Laravel/Symfony controllers too)
function createVerifyMeRequest(string $userId): array {
    $ch = curl_init(getenv('VERIFYME_HOST') . '/v1/onboarding-requests');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('VERIFYME_API_KEY'),
            'Idempotency-Key: kyc-' . $userId . '-attempt-1',
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'externalReference' => $userId,
            'mobile' => '9876541234',
            'returnUrl' => 'https://shop.example.com/kyc/done',
        ]),
    ]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code !== 201) {
        error_log("VerifyMe create failed: HTTP $code $body");
        http_response_code(502);
        exit('Could not start verification.');
    }
    return json_decode($body, true);
}

$created = createVerifyMeRequest($currentUser->id);
// persist $created['requestId'] against the user here
header('Location: ' . $created['onboardingUrl'], true, 303);
exit;

The response contains onboardingUrl and expiresAt. The create call is idempotent: if your server times out and retries with the same Idempotency-Key, you get the same requestId and onboardingUrl back (with the header Idempotent-Replayed: true).

2. Handle the return URL

When the applicant submits, the hosted page redirects the browser to your returnUrl with three query parameters appended:

https://shop.example.com/kyc/done?requestId=vm_req_01M3TRSBVVT2WJ6MNSSS&status=SUBMITTED&externalReference=USER-8841

Use it only to find the applicant's record and show a "we are reviewing" page. The query string can be edited by the user, so never grant access or mark KYC complete from it.

app.get("/kyc/done", requireLogin, async (req, res) => {
  const { requestId } = req.query;
  const record = await db.kycRequests.findOne({ requestId, userId: req.user.id });
  if (!record) return res.sendStatus(404);               // not this user's request
  // Do not trust ?status. The authoritative state arrives by webhook.
  res.render("kyc-pending", { state: record.status });
});
@app.get("/kyc/done")
@login_required
def kyc_done():
    request_id = request.args.get("requestId", "")
    record = find_kyc_request(user_id=g.user.id, request_id=request_id)
    if record is None:
        abort(404)
    # Do not trust ?status. The authoritative state arrives by webhook.
    return render_template("kyc_pending.html", state=record.status)
app.MapGet("/kyc/done", async (string requestId, HttpContext ctx, KycStore store) =>
{
    var record = await store.FindAsync(ctx.User.FindFirst("sub")!.Value, requestId);
    if (record is null) return Results.NotFound();
    // Do not trust ?status. The authoritative state arrives by webhook.
    return Results.Content($"Verification {record.Status}. We will notify you shortly.", "text/plain");
});
<?php
$requestId = $_GET['requestId'] ?? '';
$record = findKycRequest($currentUser->id, $requestId);
if (!$record) { http_response_code(404); exit; }
// Do not trust $_GET['status']. The authoritative state arrives by webhook.
echo 'Verification ' . htmlspecialchars($record['status']) . '. We will notify you shortly.';

The return URL must pass validation when the request is created: HTTPS only in production, no credentials in the URL, no javascript: or data: schemes, and the host must be on your return URL allow-list if you configured one. Custom app schemes such as myapp://kyc/done are accepted for mobile hand-off.

3. Verify the result

On onboarding.submitted (and again on onboarding.approved or onboarding.rejected) read the result from your server:

curl -s "https://kyc.example.com/v1/onboarding-requests/vm_req_01M3TRSBZTTWMHGGTTGP/result" \
  -H "Authorization: Bearer $VERIFYME_API_KEY"
async function fetchResult(requestId) {
  const r = await fetch(`${HOST}/v1/onboarding-requests/${requestId}/result`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (r.status === 409) return null;              // RESULT_NOT_READY: applicant has not submitted yet
  if (!r.ok) throw new Error(`VerifyMe ${r.status}`);
  const result = await r.json();
  if (result.status === "APPROVED") await markVerified(result.externalReference);
  if (result.status === "REJECTED") await markRejected(result.externalReference, result.decision.reasonCode);
  return result;
}
def fetch_result(request_id):
    r = requests.get(
        f"{HOST}/v1/onboarding-requests/{request_id}/result",
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=15,
    )
    if r.status_code == 409:            # RESULT_NOT_READY
        return None
    r.raise_for_status()
    result = r.json()
    if result["status"] == "APPROVED":
        mark_verified(result["externalReference"])
    elif result["status"] == "REJECTED":
        mark_rejected(result["externalReference"], result["decision"]["reasonCode"])
    return result
async Task<JsonDocument?> FetchResultAsync(HttpClient http, string requestId)
{
    var res = await http.GetAsync($"/v1/onboarding-requests/{requestId}/result");
    if (res.StatusCode == HttpStatusCode.Conflict) return null;           // RESULT_NOT_READY
    res.EnsureSuccessStatusCode();
    return await JsonDocument.ParseAsync(await res.Content.ReadAsStreamAsync());
}
JsonNode fetchResult(String requestId) throws Exception {
  HttpRequest req = HttpRequest.newBuilder(URI.create(host + "/v1/onboarding-requests/" + requestId + "/result"))
      .header("Authorization", "Bearer " + apiKey).GET().build();
  HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
  if (res.statusCode() == 409) return null;            // RESULT_NOT_READY
  if (res.statusCode() != 200) throw new IllegalStateException("VerifyMe " + res.statusCode());
  return json.readTree(res.body());
}
<?php
function fetchResult(string $requestId): ?array {
    $ch = curl_init(getenv('VERIFYME_HOST') . "/v1/onboarding-requests/$requestId/result");
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('VERIFYME_API_KEY')]]);
    $body = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code === 409) return null;      // RESULT_NOT_READY
    if ($code !== 200) throw new RuntimeException("VerifyMe HTTP $code");
    return json_decode($body, true);
}

Path B: Embedded Web SDK

Keep the applicant on your page. The Web SDK opens the same hosted flow in a modal iframe (or a popup), and tells your page what is happening through events. Your backend still creates the request.

1. Load the SDK

<button id="verify-btn" type="button">Verify identity</button>

<script src="https://kyc.example.com/sdk/v1/verifyme.js"></script>
<script src="/static/kyc.js"></script>

Because strict Content Security Policies forbid inline scripts, keep your own code in an external file such as /static/kyc.js.

2. Initialise and open

VerifyMe.init({
  publicKey: "vm_pk_test_xxxxxxxxxxxxxxxxxx",     // publishable, safe in the browser
  onEvent(e) { console.log("VerifyMe event", e.type, e.data); },
  onComplete(result) {
    // The applicant submitted. Update your UI, then wait for the webhook.
    showPending(result.requestId);
  },
  onClose() { /* the applicant closed the modal */ },
  onError(err) { console.error(err.code, err.message); },
});

document.getElementById("verify-btn").addEventListener("click", async () => {
  // Your backend creates the request and returns { onboardingUrl }.
  const r = await fetch("/api/kyc/start", { method: "POST", credentials: "same-origin" });
  const { onboardingUrl } = await r.json();
  VerifyMe.open({ url: onboardingUrl, mode: "modal" });
});

mode can be modal (accessible full-screen dialog with an iframe), inline (the same iframe inside a container you provide), popup (separate window) or redirect (full page). If the iframe does not report ready within 8 seconds, for example because framing is blocked, the SDK falls back to a popup and then to a full-page redirect so the applicant is never stuck. The full API, event payloads and the postMessage protocol are in the SDK reference.

3. Allowed origins and the publishable key

  • Allowed origins. In the portal under Settings, add every origin that will embed the flow, as https://app.example.com (scheme and host, no path, no wildcards, at most 20). VerifyMe sends Content-Security-Policy: frame-ancestors with exactly that list when the link is opened with ?embed=1; any other site is refused. In production an empty list means framing is not allowed at all. The list is resolved from the host the link is served on, so for production embedding serve links from your tenant domain (see Branding and custom domains); in the sandbox, framing is open when the list is empty.
  • Custom domain and host. The SDK only opens links whose origin equals its configured host (by default the origin the script was loaded from). If onboardingUrl is on your custom domain, call VerifyMe.init({ host: "https://verify.shop.example.com" }) or load the script from that domain.
  • Publishable key (vm_pk_test_... or vm_pk_live_...). It identifies your workspace to the SDK and can be shown in page source. It cannot create requests or read results. Never put a vm_test_ or vm_live_ secret key in front-end code.
  • Return URL allow-list. Even when embedded, a returnUrl is still validated at creation. For a pure SDK flow you can omit it and rely on the submitted event.

4. iframe permissions

Camera (selfie, liveness), geolocation (geo-tagged photos) and microphone must be delegated to the iframe. The SDK sets this for you:

<iframe src="https://kyc.example.com/start/vm_t_...?embed=1"
        allow="camera; geolocation; microphone; clipboard-write"
        referrerpolicy="no-referrer"
        title="Identity verification"></iframe>

If you create the iframe yourself, include the allow attribute exactly like this. Your own site must not send a Permissions-Policy header that blocks camera, geolocation or microphone for the VerifyMe origin.

5. Content Security Policy for your site

If your page sends a CSP, allow the SDK script and the frame:

Content-Security-Policy:
  script-src 'self' https://kyc.example.com;
  frame-src  https://kyc.example.com;
  connect-src 'self';

The SDK talks to the iframe with postMessage only, so you do not need connect-src for VerifyMe. If you use a custom domain for the hosted flow (for example verify.shop.example.com), use that host in frame-src instead.

6. Popup mode and fallbacks

Some browsers restrict third-party cookies and iframes, and some in-app browsers block cameras in frames. Use mode: "redirect" to leave your page entirely (set returnUrl on the request), or mode: "popup" for a window-based flow; open popups from a click handler so they are not blocked. Because the hosted page isolates its browsing context (Cross-Origin-Opener-Policy: same-origin), a popup may not be able to report events back; the SDK then emits popup-detached and you should rely on webhooks or the redirect return. Prefer modal or redirect.

VerifyMe.open({ url: onboardingUrl, mode: isWebViewOrOldBrowser() ? "redirect" : "modal" });

7. Framework snippets

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

export function VerifyButton({ startUrl = "/api/kyc/start" }) {
  const [state, setState] = useState("idle");
  const ready = useRef(false);

  useEffect(() => {
    if (ready.current || !window.VerifyMe) return;
    window.VerifyMe.init({
      publicKey: import.meta.env.VITE_VERIFYME_PUBLIC_KEY,
      onComplete: () => setState("submitted"),
      onClose: () => setState((s) => (s === "submitted" ? s : "idle")),
      onError: () => setState("error"),
    });
    ready.current = true;
    return () => window.VerifyMe.destroy();
  }, []);

  async function start() {
    setState("opening");
    const r = await fetch(startUrl, { method: "POST", credentials: "same-origin" });
    const { onboardingUrl } = await r.json();
    window.VerifyMe.open({ url: onboardingUrl, mode: "modal" });
  }

  if (state === "submitted") return <p role="status">Thanks. We are verifying your details.</p>;
  return <button onClick={start} disabled={state === "opening"}>Verify identity</button>;
}
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";

const state = ref("idle");

onMounted(() => {
  window.VerifyMe.init({
    publicKey: import.meta.env.VITE_VERIFYME_PUBLIC_KEY,
    onComplete: () => (state.value = "submitted"),
    onError: () => (state.value = "error"),
  });
});
onBeforeUnmount(() => window.VerifyMe.destroy());

async function start() {
  const r = await fetch("/api/kyc/start", { method: "POST", credentials: "same-origin" });
  const { onboardingUrl } = await r.json();
  window.VerifyMe.open({ url: onboardingUrl, mode: "modal" });
}
</script>

<template>
  <p v-if="state === 'submitted'" role="status">Thanks. We are verifying your details.</p>
  <button v-else type="button" @click="start">Verify identity</button>
</template>
// kyc.component.ts (standalone component, Angular 17+)
import { Component, OnDestroy, OnInit, signal } from "@angular/core";
import { HttpClient } from "@angular/common/http";

declare const VerifyMe: any;   // loaded by <script src=".../sdk/v1/verifyme.js"> in index.html

@Component({
  selector: "app-kyc",
  standalone: true,
  template: `
    @if (submitted()) { <p role="status">Thanks. We are verifying your details.</p> }
    @else { <button type="button" (click)="start()">Verify identity</button> }
  `,
})
export class KycComponent implements OnInit, OnDestroy {
  submitted = signal(false);
  constructor(private http: HttpClient) {}

  ngOnInit() {
    VerifyMe.init({ publicKey: "vm_pk_test_xxxxxxxxxxxxxxxxxx", onComplete: () => this.submitted.set(true) });
  }
  ngOnDestroy() { VerifyMe.destroy(); }

  start() {
    this.http.post<{ onboardingUrl: string }>("/api/kyc/start", {}).subscribe(({ onboardingUrl }) =>
      VerifyMe.open({ url: onboardingUrl, mode: "modal" }));
  }
}

For native apps, open onboardingUrl inside a WebView (or a custom tab) and use a custom-scheme returnUrl such as myapp://kyc/done. When the flow finishes, the hosted page navigates to myapp://kyc/done?requestId=...&status=SUBMITTED&externalReference=..., your app intercepts the navigation, closes the WebView and refreshes its state. As always, the result itself arrives at your backend by webhook.

{
  "externalReference": "USER-8841",
  "mobile": "9876541234",
  "returnUrl": "myapp://kyc/done"
}

A WebView needs camera and location access

The hosted flow uses the camera (selfie, liveness, document photos), location (geo-tagged premises photos) and file upload. A WebView does not grant these automatically: the app must declare the OS permissions, ask the user at runtime, and then grant the WebView's own permission request. Samples follow.

// AndroidManifest.xml
// <uses-permission android:name="android.permission.CAMERA" />
// <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
// <uses-permission android:name="android.permission.INTERNET" />

class KycActivity : AppCompatActivity() {
  private lateinit var web: WebView
  private var pendingPermission: PermissionRequest? = null
  private var pendingGeo: Pair<String, GeolocationPermissions.Callback>? = null
  private var filePathCallback: ValueCallback<Array<Uri>>? = null

  private val askPermissions =
    registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
      pendingPermission?.let { if (granted[Manifest.permission.CAMERA] == true) it.grant(it.resources) else it.deny() }
      pendingGeo?.let { (origin, cb) -> cb.invoke(origin, granted[Manifest.permission.ACCESS_FINE_LOCATION] == true, false) }
      pendingPermission = null; pendingGeo = null
    }

  private val pickFile =
    registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
      filePathCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(r.resultCode, r.data))
      filePathCallback = null
    }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    web = WebView(this).also { setContentView(it) }
    web.settings.javaScriptEnabled = true
    web.settings.domStorageEnabled = true
    web.settings.mediaPlaybackRequiresUserGesture = false

    web.webViewClient = object : WebViewClient() {
      override fun shouldOverrideUrlLoading(v: WebView, req: WebResourceRequest): Boolean {
        val uri = req.url
        if (uri.scheme == "myapp" && uri.host == "kyc") {            // returnUrl hand-off
          onKycReturned(uri.getQueryParameter("requestId"), uri.getQueryParameter("status"))
          finish(); return true
        }
        return false
      }
    }
    web.webChromeClient = object : WebChromeClient() {
      override fun onPermissionRequest(request: PermissionRequest) {
        pendingPermission = request
        askPermissions.launch(arrayOf(Manifest.permission.CAMERA))
      }
      override fun onGeolocationPermissionsShowPrompt(origin: String, cb: GeolocationPermissions.Callback) {
        pendingGeo = origin to cb
        askPermissions.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
      }
      override fun onShowFileChooser(v: WebView, cb: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
        filePathCallback?.onReceiveValue(null); filePathCallback = cb
        pickFile.launch(params.createIntent()); return true
      }
    }
    web.loadUrl(intent.getStringExtra("onboardingUrl")!!)
  }
}
// Info.plist: NSCameraUsageDescription, NSMicrophoneUsageDescription, NSLocationWhenInUseUsageDescription
import SwiftUI
import WebKit

struct KycWebView: UIViewRepresentable {
  let url: URL
  var onReturn: (String?, String?) -> Void

  func makeCoordinator() -> Coordinator { Coordinator(onReturn: onReturn) }

  func makeUIView(context: Context) -> WKWebView {
    let cfg = WKWebViewConfiguration()
    cfg.allowsInlineMediaPlayback = true
    cfg.mediaTypesRequiringUserActionForPlayback = []
    let web = WKWebView(frame: .zero, configuration: cfg)
    web.navigationDelegate = context.coordinator
    web.uiDelegate = context.coordinator
    web.load(URLRequest(url: url))
    return web
  }
  func updateUIView(_ web: WKWebView, context: Context) {}

  final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
    let onReturn: (String?, String?) -> Void
    init(onReturn: @escaping (String?, String?) -> Void) { self.onReturn = onReturn }

    func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
      if let u = action.request.url, u.scheme == "myapp", u.host == "kyc" {     // returnUrl hand-off
        let q = URLComponents(url: u, resolvingAgainstBaseURL: false)?.queryItems
        onReturn(q?.first { $0.name == "requestId" }?.value, q?.first { $0.name == "status" }?.value)
        decisionHandler(.cancel); return
      }
      decisionHandler(.allow)
    }

    // iOS 15+: the system prompt appears once per origin; grant so the web page can use the camera.
    @available(iOS 15.0, *)
    func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
                 decisionHandler: @escaping (WKPermissionDecision) -> Void) {
      decisionHandler(.prompt)
    }
  }
}
import React from "react";
import { PermissionsAndroid, Platform } from "react-native";
import { WebView } from "react-native-webview";

async function ensureAndroidPermissions() {
  if (Platform.OS !== "android") return;
  await PermissionsAndroid.requestMultiple([
    PermissionsAndroid.PERMISSIONS.CAMERA,
    PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
  ]);
}

export function KycScreen({ route, navigation }) {
  const { onboardingUrl } = route.params;
  React.useEffect(() => { ensureAndroidPermissions(); }, []);

  return (
    <WebView
      source={{ uri: onboardingUrl }}
      javaScriptEnabled
      domStorageEnabled
      allowsInlineMediaPlayback
      mediaPlaybackRequiresUserAction={false}
      geolocationEnabled
      onShouldStartLoadWithRequest={(req) => {
        if (req.url.startsWith("myapp://kyc/")) {          // returnUrl hand-off
          const q = new URL(req.url).searchParams;
          navigation.replace("KycPending", { requestId: q.get("requestId") });
          return false;
        }
        return true;
      }}
    />
  );
}
// iOS: add NSCameraUsageDescription and NSLocationWhenInUseUsageDescription to Info.plist.
// pubspec: webview_flutter, webview_flutter_android, permission_handler
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';

class KycPage extends StatefulWidget {
  const KycPage({super.key, required this.onboardingUrl});
  final String onboardingUrl;
  @override
  State<KycPage> createState() => _KycPageState();
}

class _KycPageState extends State<KycPage> {
  late final WebViewController controller;

  @override
  void initState() {
    super.initState();
    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(NavigationDelegate(
        onNavigationRequest: (req) {
          final uri = Uri.parse(req.url);
          if (uri.scheme == 'myapp' && uri.host == 'kyc') {          // returnUrl hand-off
            Navigator.of(context).pop(uri.queryParameters['requestId']);
            return NavigationDecision.prevent;
          }
          return NavigationDecision.navigate;
        },
      ));
    final platform = controller.platform;
    if (platform is AndroidWebViewController) {
      platform.setMediaPlaybackRequiresUserGesture(false);
      platform.setOnPlatformPermissionRequest((request) async {
        final statuses = await [Permission.camera, Permission.locationWhenInUse].request();
        statuses[Permission.camera]!.isGranted ? request.grant() : request.deny();
      });
    }
    controller.loadRequest(Uri.parse(widget.onboardingUrl));
  }

  @override
  Widget build(BuildContext context) => Scaffold(body: SafeArea(child: WebViewWidget(controller: controller)));
}
  • Custom scheme (myapp://kyc/done): accepted for any returnUrl. It is not subject to the host allow-list, so make sure only your app registers the scheme.
  • HTTPS universal link / app link (https://shop.example.com/kyc/done): must be on your return URL allow-list and, on production, HTTPS. Prefer this when you want the same URL to work on web and app.
  • Always create the request from your backend, never from the app, and pass only onboardingUrl to the app.

Path D: API only, server to server

Choose this when you do not want any VerifyMe UI inside your product, for example when you send the link by SMS, email, WhatsApp or QR code and run back office on events.

  1. Your backend creates the request (same call as Path A, returnUrl optional).
  2. You deliver onboardingUrl to the applicant. VerifyMe does not send the link itself; the mobile and email you pass are used for OTP verification inside the flow.
  3. React to webhooks (onboarding.started, onboarding.step.completed, onboarding.submitted, onboarding.approved, onboarding.rejected, onboarding.expired).
  4. Use GET /v1/onboarding-requests and GET /v1/onboarding-requests/{id} as a reconciliation fallback if a webhook was missed.
  5. Fetch the result (and signed document URLs) with the onboarding.result scope.
# Reconcile: everything submitted in the last day that you have not yet processed
curl -s "https://kyc.example.com/v1/onboarding-requests?status=SUBMITTED,REVIEWING&from=2026-09-30T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $VERIFYME_API_KEY"

The applicant steps are not an API

The applicant journey (OTP, DigiLocker, selfie and so on) runs on the VerifyMe-hosted UI and is protected by a session bound to the applicant's browser. It is intentionally not exposed under /v1. API-only means no UI embedded in your product, not that you submit KYC data on the applicant's behalf.

Return URL and status handling

What to show your user depends on the status you read from your own store (kept up to date by webhooks):

StatusShow the userYour next action
LINK_ISSUED / IN_PROGRESS"Continue verification" button reopening the same onboardingUrlNothing. Optionally remind after some hours
SUBMITTED / REVIEWING"We are reviewing your details"Wait for onboarding.approved or rejected
REVERTED"A few details need your attention"Ask the applicant to reopen the same link. See data.steps and data.message in the event
APPROVEDVerifiedUnlock the product, store the result reference
REJECTEDVerification unsuccessfulShow a neutral message, route to support. Use decision.reasonCode internally
EXPIRED"Your link expired"Create a new request
CANCELLEDNothing, or "request cancelled"Create a new request if needed

If the applicant reopens a link whose request is already submitted or finished, the hosted page shows the final state and offers a button back to your returnUrl.

Retrieving results

  • Webhook first. Treat onboarding.submitted as "result is ready to read" and approved/rejected as the decision.
  • Poll as a safety net. GET /v1/onboarding-requests/{id} is cheap. Poll with backoff only for requests you expect to be done.
  • Field-scoped results. GET .../result?fields=fullName,pan returns only the listed keys in verified.
  • Masked by default. PAN, Aadhaar, mobile, email and bank account are masked. Names, date of birth and address are returned in full. Treat the result as sensitive personal data.
  • Documents are separate resources: GET .../documents/{documentId} returns a signed URL valid for 120 seconds, only for files that passed the malware scan. Fetch the URL immediately and do not store it.
  • 409 RESULT_NOT_READY means the applicant has not submitted yet.

Expiry, re-issue and cancel

  • Expiry. expiresInHours (1 to 720) overrides the tenant default (72 hours). When a link expires before submission the request becomes EXPIRED and onboarding.expired is sent. Create a new request for the same applicant. If your tenant uses the block_active duplicate policy, only *active* requests block a new one with the same externalReference.
  • Re-issue. POST /v1/onboarding-requests/{id}/reissue-link rotates the token, invalidates the old link and revokes open applicant sessions, keeping completed evidence. Use it if a link was leaked or sent to the wrong channel. Not allowed once SUBMITTED, REVIEWING or terminal (409 VERIFYME_INVALID_STATE).
  • Cancel. POST /v1/onboarding-requests/{id}/cancel works until the request is final, is idempotent, revokes sessions and emits onboarding.cancelled. Cancelling an APPROVED, REJECTED or EXPIRED request returns 409.
  • Reverts. When a reviewer asks for corrections, the request becomes REVERTED, the expiry is extended by the tenant TTL and the same link works again for the listed steps.

Idempotency

POST /v1/onboarding-requests requires an Idempotency-Key header of 8 to 120 characters (letters, digits and _ . : -). Same key plus same body returns the stored response with Idempotent-Replayed: true. Same key with a different body returns 409 IDEMPOTENCY_KEY_REUSED. A concurrent duplicate returns 409 IDEMPOTENCY_IN_PROGRESS. Keys are scoped to your workspace and expire after 24 hours. A failed attempt is not stored, so retrying with the same key re-executes it.

Derive the key from your own business action (for example kyc-{userId}-attempt-{n}), not from a random value generated per HTTP attempt.

Error handling and retries

Every error has the same envelope (see Errors and limits). Retry only what is safe:

SituationRetry?How
Network error, timeoutYesSame Idempotency-Key, exponential backoff with jitter
429 VERIFYME_RATE_LIMITEDYesWait for the Retry-After seconds
5xx, 503 VERIFYME_DEPENDENCY_UNAVAILABLEYesBackoff, up to a few attempts
400, 403, 404, 409 (except IDEMPOTENCY_IN_PROGRESS)NoFix the request. Log error.code and error.traceId
401NoCheck the key, environment and expiry. For OAuth tokens, fetch a new token
async function withRetry(fn, { tries = 4, baseMs = 400 } = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fn();
      if (res.status !== 429 && res.status < 500) return res;
      if (attempt >= tries) return res;
      const wait = Number(res.headers.get("retry-after")) * 1000 || baseMs * 2 ** (attempt - 1);
      await new Promise((r) => setTimeout(r, wait * (0.8 + Math.random() * 0.4)));
    } catch (e) {
      if (attempt >= tries) throw e;
      await new Promise((r) => setTimeout(r, baseMs * 2 ** (attempt - 1)));
    }
  }
}

Always log error.traceId (also in the X-Request-Id response header) and quote it to support.

Branding and custom domains

The hosted UI is rendered with your tenant branding: display name, logo, favicon, accent colours and legal links, configured in the portal under Branding. Colour pairs are checked for accessible contrast before they can be published. Branding is versioned, so a change applies to new sessions only.

For a white-label experience, add a custom domain under Domains (for example verify.shop.example.com), create the CNAME record shown, and wait for verification and certificate issue. Once the domain is Active, onboardingUrl is returned on your domain. Links are only valid on the tenant host they were issued for, so a token cannot be replayed on another tenant's domain.

Set locale (en, hi, ta, te, kn, mr, bn, gu) on the create call to start the applicant in their language.

Flows and step configuration

A request runs a flow. Omit flowKey to use your default. GET /v1/flows lists live flows with their steps:

{ "flows": [ { "flowKey": "employee-verification-v1", "name": "Employee Verification", "version": 1,
  "steps": [ { "key": "mobileOtp", "type": "MobileOtp", "required": true },
             { "key": "pan", "type": "PanVerification", "required": true } ] } ] }

Flows are authored and published in the portal's flow builder: step order, required or optional steps, per-step configuration (OTP expiry, face-match threshold, document types, geo requirement), purpose codes, notice version and review policy. See Flows and steps.

Testing in the sandbox

Use vm_test_ keys against the sandbox deployment. OTPs are returned in the UI, providers are simulated by deterministic test values, and a webhook sink verifies signatures for you. Mobile numbers ending 1234 always get OTP 123456. The full table of triggers is in Sandbox and testing.

Go-live checklist

  • Switch keys and host. Use a vm_live_ key on the production host from a secrets manager. Sandbox keys are rejected in production (401).
  • HTTPS everywhere. Webhook URL and returnUrl must be HTTPS in production. Private and loopback webhook targets are blocked.
  • Webhook receiver hardened. Verify the HMAC signature on the raw body, enforce the 5-minute timestamp window, dedupe on eventId, respond 2xx quickly, process asynchronously.
  • Allow-lists configured. Return URL hosts and, if embedding, allowed origins are saved in the portal for the production tenant.
  • Idempotency keys in place for every create call, and retries use backoff.
  • IP allow-list on the production API key if your egress IPs are fixed, plus the minimum scopes.
  • Monitoring. Alert on 5xx, 429, webhook endpoint disabled (20 consecutive failures disables an endpoint), and growth in SUBMITTED requests waiting for review. Log traceId.
  • Reconciliation job that lists non-final requests and fetches results you missed.
  • DPDP responsibilities. You are the data fiduciary. Publish a privacy notice that names VerifyMe as processor, keep the purpose codes and notice version in your flow accurate, honour access and erasure requests (the portal has a privacy request workflow), and do not copy KYC data into systems that lack your retention controls.
  • Data retention. Review the retention class on your flows and configure retention overrides in the portal. Store only the fields you need from the result.
  • Rotate the sandbox credentials you shared during development and delete the demo credentials file in any shared environment.

Troubleshooting and FAQ

I get 401 VERIFYME_UNAUTHENTICATED

The header must be Authorization: Bearer <key>. Check that the key is not revoked or expired, that you are not sending a vm_test_ key to production (or the reverse), that the key was copied completely, and that your server IP is on the key's allow-list if you set one. Repeated authentication failures from one IP address are rate limited and flagged.

400 "A valid Idempotency-Key header (8-120 chars) is required"

Add the header to POST /v1/onboarding-requests. Only letters, digits, underscore, dot, colon and hyphen are allowed.

returnUrl is rejected with HOST_NOT_ALLOWED or HTTPS_REQUIRED

Add the host (for example shop.example.com, subdomains included) to Settings, Return URL allow-list, and use https:// in production.

The iframe is blank or shows "refused to connect"

Add your page origin to Allowed origins (https://app.example.com, exact, no trailing slash), make sure the link is opened with ?embed=1 (the SDK does this), and check your own CSP frame-src. The SDK falls back to redirect when framing is blocked.

Camera or location does not work inside the iframe or WebView

The iframe needs allow="camera; geolocation; microphone". In a native WebView you must request the OS permission and then grant the WebView's permission request. See Path C. Browsers require HTTPS for camera and location.

I never receive webhooks

Check that the endpoint is ACTIVE (it is disabled after 20 consecutive failures), that the URL is public HTTPS, that you return 2xx within 8 seconds, and look at deliveries with GET /v1/webhook-events. Use Send test in the portal. See Webhooks.

Signature verification fails

Verify against the raw request body bytes, not re-serialised JSON. Use the exact secret for that endpoint, join with timestamp + "." + body, and compare with v1= prefix. See Webhooks.

The same request was created twice

You probably sent different Idempotency-Key values for retries of the same action. Derive the key from your own stable identifier. Or turn on the block_active duplicate policy to reject a second active request for the same externalReference with 409 DUPLICATE_EXTERNAL_REFERENCE.

Can I skip steps or change the flow for one applicant?

Choose a different flowKey per request. Step requirements are defined in the flow; per-request step overrides are not available.

Docs version 1.0.0API version v1Last updated 1 Oct 2026