Get started

Quickstart

Go from a sandbox key to a verified result in about five minutes. You will create an onboarding request, open the link, complete the flow with test data, receive a signed webhook and fetch the result.

What you need

A VerifyMe workspace (free sandbox), a terminal with curl, and optionally a public HTTPS URL for webhooks. In this guide https://kyc.example.com stands for your VerifyMe deployment host. Use the one shown in your portal; you can type it into Your VerifyMe host in the sidebar and every sample on every docs page will update.

  1. Get a sandbox API key

    1. Sign in to the portal and open Developers, API keys.
    2. Choose Create key, name it, keep the default scopes (onboarding.create, onboarding.read, onboarding.result, onboarding.cancel) and add webhook.manage if you want to manage endpoints through the API. Confirm with your password (step-up).
    3. Copy the key now. It is shown once and only a hash is stored.

    Sandbox keys start with vm_test_. Export it so the samples can use it:

    export VERIFYME_API_KEY="vm_test_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    export VERIFYME_HOST="https://kyc.example.com"
    
    # Sanity check: should print your tenant and scopes
    curl -s "$VERIFYME_HOST/v1/ping" -H "Authorization: Bearer $VERIFYME_API_KEY"
    {
      "ok": true,
      "environment": "test",
      "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
      "scopes": ["onboarding.create", "onboarding.read", "onboarding.result", "onboarding.cancel"]
    }

    Keys are bound to an environment

    A vm_live_ key sent to the sandbox, or a vm_test_ key sent to production, returns 401 VERIFYME_UNAUTHENTICATED. See Authentication.

  2. Create an onboarding request

    curl -X POST "$VERIFYME_HOST/v1/onboarding-requests" \
      -H "Authorization: Bearer $VERIFYME_API_KEY" \
      -H "Idempotency-Key: order-1001-kyc" \
      -H "Content-Type: application/json" \
      -d '{
        "externalReference": "ORD-1001",
        "applicantType": "merchant",
        "mobile": "9876541234",
        "email": "ravi@example.com",
        "returnUrl": "https://shop.example.com/kyc/done",
        "expiresInHours": 48,
        "metadata": { "plan": "gold" }
      }'
    const res = await fetch(`${process.env.VERIFYME_HOST}/v1/onboarding-requests`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.VERIFYME_API_KEY}`,
        "Idempotency-Key": "order-1001-kyc",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        externalReference: "ORD-1001",
        applicantType: "merchant",
        mobile: "9876541234",
        email: "ravi@example.com",
        returnUrl: "https://shop.example.com/kyc/done",
        expiresInHours: 48,
        metadata: { plan: "gold" },
      }),
    });
    if (!res.ok) throw new Error((await res.json()).error.message);
    const request = await res.json();
    console.log(request.onboardingUrl);
    import os, requests
    
    r = requests.post(
        f"{os.environ['VERIFYME_HOST']}/v1/onboarding-requests",
        headers={
            "Authorization": f"Bearer {os.environ['VERIFYME_API_KEY']}",
            "Idempotency-Key": "order-1001-kyc",
        },
        json={
            "externalReference": "ORD-1001",
            "applicantType": "merchant",
            "mobile": "9876541234",
            "email": "ravi@example.com",
            "returnUrl": "https://shop.example.com/kyc/done",
            "expiresInHours": 48,
            "metadata": {"plan": "gold"},
        },
        timeout=15,
    )
    r.raise_for_status()
    print(r.json()["onboardingUrl"])
    {
      "requestId": "vm_req_01M3TRRXRBKXXX2JCEEX",
      "externalReference": "ORD-1001",
      "status": "LINK_ISSUED",
      "onboardingUrl": "https://kyc.example.com/start/vm_t_XWRW-DmQVxZwTXYHcqQzODYAgtyf8Z-tF0tMI9HfCNQ",
      "expiresAt": "2026-10-03T03:40:18.571Z"
    }

    Store requestId against your own record. If you pass a mobile or email, the applicant does not have to type it and it is locked in the OTP step.

    Idempotency

    Re-sending the same Idempotency-Key with the same body returns the original response and adds the header Idempotent-Replayed: true. The same key with a different body returns 409 IDEMPOTENCY_KEY_REUSED. Keys are kept for 24 hours.

  3. Open the onboarding URL

    Paste onboardingUrl into a browser. You land on the VerifyMe hosted experience, branded with your tenant logo and colours. In production you would redirect the user, or open the link in the embedded Web SDK or a mobile WebView (see the integration guide).

    The link is a one-time capability: the token is exchanged for a short-lived applicant session. Anyone holding the link can complete the request until it expires, so treat it like a password reset link and send it only to the applicant.

  4. Complete the flow in the sandbox

    The default flow is India Business KYC. In the sandbox every provider is simulated, and the values you type decide the outcome. The OTP is returned on screen as a sandbox code (and appears in the portal's sandbox inbox), so you never need a real phone.

    StepEnterResult
    Mobile OTPAny valid mobile (10 digits starting 6 to 9). Mobile ending 1234OTP is always 123456. Other numbers get a random code, still shown on screen
    Mobile OTPMobile ending 0000Simulated SMS gateway timeout. Delivery is queued and the step shows a delayed state
    Email OTPEmail starting fail@Simulated SMTP outage
    DigiLocker (Aadhaar)Click Allow access on the simulator pageCompletes with name TEST APPLICANT, date of birth 1990-05-14, Bengaluru address and a random Aadhaar last four. Deny produces a retryable failure
    PANValid format, for example ABCPK1234FVerified, holder name matches
    PANEnds with X, for example ABCPK1234XPAN_INVALID (registry says not found)
    PAN5th character T, for example ABCPT1234FSimulated registry outage: PROVIDER_UNAVAILABLE, attempt not consumed, never a false rejection
    PAN5th character Z, for example ABCPZ1234FVerified but holder is DIFFERENT PERSON: flag PAN_AADHAAR_MISMATCH (or PAN_NAME_MISMATCH without DigiLocker)
    BankAccount 9 to 18 digits and IFSC such as HDFC0001234Verified. Bank name comes from the IFSC prefix (HDFC, SBIN, ICIC)
    BankAccount ending 9999Verified with holder SOME OTHER HOLDER: flag BANK_NAME_MISMATCH
    BankAccount starting 0000BANK_ACCOUNT_INVALID
    BankAccount ending 1111Simulated gateway timeout: PROVIDER_UNAVAILABLE
    SelfieJPEG, PNG or WebP of at least 1,500 bytesFace match score 72 to 98, passes the default threshold of 60
    SelfieImage smaller than 1,500 bytesScore 38: FACE_MISMATCH, retry; after the attempt limit the step completes with flag FACE_BELOW_THRESHOLD for review

    More triggers (liveness, uploads, pincode, malware scan) are in Sandbox and testing.

  5. Receive the webhook

    VerifyMe sends a signed POST to your endpoint for every event. For a quick start use the sandbox sink, a built-in receiver that verifies the signature exactly like your server should and lists the last 30 deliveries in the portal.

    1. Portal, Developers, Webhooks, choose Create sandbox sink (or POST /api/portal/webhooks/sandbox-sink). It subscribes to all events at https://kyc.example.com/demo/sink/<endpointId>.
    2. Or register your own HTTPS endpoint through the API:
    curl -X POST "$VERIFYME_HOST/v1/webhook-endpoints" \
      -H "Authorization: Bearer $VERIFYME_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://shop.example.com/hooks/verifyme", "events": ["onboarding.submitted", "onboarding.approved", "onboarding.rejected"] }'

    The response contains the signing secret (whsec_...) once. Each delivery looks like this:

    POST /hooks/verifyme HTTP/1.1
    Content-Type: application/json
    User-Agent: VerifyMe-Webhooks/1.0
    X-VerifyMe-Event-Id: evt_01M3TRSBZ95445F69Z4C
    X-VerifyMe-Timestamp: 1790826033
    X-VerifyMe-Signature: v1=5f1c0e9b4d7a...e2
    X-VerifyMe-Delivery: del_01M3TRSBZB1QX4GK6C0V
    X-VerifyMe-Attempt: 1
    {
      "eventId": "evt_01M3TRSBZ95445F69Z4C",
      "eventType": "onboarding.submitted",
      "schemaVersion": "1",
      "tenantId": "tnt_01M3TRRAAAS88X5T0X1F",
      "requestId": "vm_req_01M3TRSBVVT2WJ6MNSSS",
      "externalReference": "ORD-2001",
      "occurredAt": "2026-10-01T03:40:33.129Z",
      "environment": "test",
      "data": { "status": "SUBMITTED", "resubmission": false }
    }

    Respond with any 2xx within 8 seconds. Always verify the signature before trusting the body: see Webhooks.

  6. Fetch the result

    After the applicant submits, read the result with the onboarding.result scope. Never trust the query string on your return URL; always read the result from the API or the webhook.

    curl -s "$VERIFYME_HOST/v1/onboarding-requests/vm_req_01M3TRSBZTTWMHGGTTGP/result" \
      -H "Authorization: Bearer $VERIFYME_API_KEY"
    {
      "requestId": "vm_req_01M3TRSBZTTWMHGGTTGP",
      "externalReference": "EMP-77",
      "status": "SUBMITTED",
      "decision": null,
      "verified": {
        "fullName": "TEST APPLICANT",
        "dateOfBirth": "1990-05-14",
        "gender": "M",
        "address": { "line": "12, MG Road", "city": "Bengaluru", "state": "Karnataka", "pincode": "560001" },
        "mobile": "98XXXXXX34",
        "email": null,
        "pan": "ABXXXXXX4F",
        "aadhaar": "XXXX XXXX 3354",
        "bankAccount": null,
        "bankAccountHolder": null,
        "faceMatchScore": 78,
        "faceMatchPassed": true,
        "livenessPassed": null
      },
      "flags": [],
      "steps": [
        { "key": "mobileOtp", "type": "MobileOtp", "status": "COMPLETED", "attempts": 1, "completedAt": "2026-10-01T03:40:33.192Z" },
        { "key": "pan", "type": "PanVerification", "status": "COMPLETED", "attempts": 1, "completedAt": "2026-10-01T03:40:33.237Z" }
      ],
      "documents": [
        { "documentId": "doc_01M3TRSCCSKFRSR85W59", "type": "SELFIE", "contentType": "image/jpeg", "sizeBytes": 2504, "scanStatus": "CLEAN", "uploadedAt": "2026-10-01T03:40:33.563Z" }
      ],
      "submittedAt": "2026-10-01T03:40:33.604Z",
      "completedAt": null
    }

    (The steps list is shortened here.) Identifiers are masked by default. When a reviewer approves or rejects in the portal, status becomes APPROVED or REJECTED, decision is filled in and you receive onboarding.approved or onboarding.rejected.

    Result is not ready until submit

    Calling /result before the applicant submits returns 409 RESULT_NOT_READY. Use GET /v1/onboarding-requests/{requestId} for progress while the applicant is still working.

What next

Docs version 1.0.0API version v1Last updated 1 Oct 2026