arrow_backDocs/Integration guide
~30 min integration

Integration guide

Mint an API key, create a verification session, and either redirect your user or embed the flow. This guide walks through each step with copy-paste recipes for curl, Node, Python, and the JavaScript SDK.

Overview

id beyond exposes a single HTTP endpoint to create verification sessions. Once created, you decide how the end-user reaches the flow:

  1. Hosted redirect — 302 the user to the returned hostedUrl; they come back to your returnUrl when done.
  2. Embedded SDK — mount the flow in an iframe inside your own site via @uniicy/kyc-widget.
  3. QR hand-off — render the hosted URL as a QR so desktop users finish on their phone (built-in, no extra code).

Authentication is a single bearer API key minted per organization. Secret keys (sk_*) are used server-side; publishable keys (pk_*) are safe to ship in the browser bundle for SDK mount only.

Prerequisites

  • An id beyond account and organization (create one at /signup).
  • A minted API key pair (pk_* + sk_*) from /dashboard/settings/api-keys.
  • An HTTPS endpoint on your side to receive webhooks (you can use an ngrok tunnel in development).
info
Secret keys are shown once. The dashboard renders the full value of sk_* and pk_* exactly once at mint time — after that only the prefix is retained. Copy them to your secrets manager immediately.
# From the dashboard: https://verify.uniicy.com/dashboard/settings/api-keys
# Or programmatically (cookie-authenticated as a dashboard user):
curl -X POST https://verify.uniicy.com/api/dashboard/organizations/${ORG_ID}/api-keys \
  -H "Cookie: kyc_session=…" \
  -H "Content-Type: application/json" \
  -d '{ "environment": "test", "label": "backend-dev" }'

1. Create a verification session

Call POST /api/verification/sessions from your backend, signed with a secret key. All body fields are optional, but most integrations set externalUserId, returnUrl, and the webhookUrl / webhookSecret pair.

curl -X POST https://verify.uniicy.com/api/verification/sessions \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user_123",
    "returnUrl": "https://tenant.example/kyc/done",
    "webhookUrl": "https://tenant.example/webhooks/kyc",
    "webhookSecret": "change-me-min-16-chars-random",
    "metadata": { "campaign": "q3" }
  }'

Response:

{
  "ok": true,
  "sessionId": "9c2e…-b3a4",
  "token": "kvs_…",              // legacy alias for clientSecret
  "clientSecret": "kvs_…",       // pass this to the SDK
  "hostedUrl": "https://verify.uniicy.com/verification/session/9c2e…?token=kvs_…",
  "expiresAt": "2026-04-17T09:15:00.000Z"
}
warning
The response is only returned to the caller of this request — it is never served back to the browser automatically. Your backend is the broker.

2. Pick an integration mode

2a. Hosted redirect

Simplest path. Redirect the end-user to hostedUrl — they land on the id beyond flow, complete the steps, and are bounced back to your returnUrl with ?session_id=…&status=… appended.

// app/api/kyc/start/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { userId } = await req.json();

  const res = await fetch("https://verify.uniicy.com/api/verification/sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UNIICY_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      externalUserId: userId,
      returnUrl: "https://tenant.example/kyc/done",
      webhookUrl: "https://tenant.example/webhooks/kyc",
      webhookSecret: process.env.UNIICY_WEBHOOK_SECRET,
    }),
  });

  const { hostedUrl } = await res.json();
  return NextResponse.redirect(hostedUrl, { status: 302 });
}

2b. Embedded SDK

Keep the user on your site. The @uniicy/kyc-widget package mounts the flow inside an iframe and streams lifecycle events (uniicy:ready, uniicy:step, uniicy:complete, uniicy:error) via window.postMessage.

<div id="kyc"></div>

<script type="module">
  import { init } from "https://esm.sh/@uniicy/kyc-widget";

  // `sessionId` and `clientSecret` come from your backend's
  // POST /api/verification/sessions call — never mint them client-side.
  const uniicy = init({ publishableKey: "pk_live_…" });

  uniicy.mount(document.getElementById("kyc"), {
    sessionId:    "…",
    clientSecret: "…",
    onReady:    ()  => console.log("ready"),
    onStep:     (s) => console.log("step", s),
    onComplete: (r) => console.log("done", r),
    onError:    (e) => console.error(e),
  });
</script>

Full API reference in the package README. Verify an unreleased fix against your integration by pinning the canary channel: npm install @uniicy/kyc-widget@canary.

2c. QR mobile hand-off

Zero-code. The hosted flow renders a QR pointing to the same hostedUrl on mobile — users scan from their desktop and finish capture on their phone. The same session continues server-side.

3. Receive webhooks

Two events fire to your webhookUrl:

  • verification.extraction — fires as soon as a document is analysed (ID front / back / POA), including OCR fields and any heatmap references.
  • verification.completed — fires when the session reaches a terminal state (approved, rejected, or pending_review).

Each payload echoes the metadata you passed at session create, plus the fields relevant to the event. When a webhookSecret is set, every request carries an X-Verification-Signature header with the raw HMAC-SHA256 hex digest of the body.

import crypto from "node:crypto";
import express from "express";

const app = express();

// The webhook secret is what you passed as `webhookSecret` at session create.
const WEBHOOK_SECRET = process.env.UNIICY_WEBHOOK_SECRET!;

app.post(
  "/webhooks/kyc",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const received = req.header("X-Verification-Signature") ?? "";
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    const ok =
      received.length === expected.length &&
      crypto.timingSafeEqual(
        Buffer.from(received, "utf8"),
        Buffer.from(expected, "utf8"),
      );

    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // event.event:  "verification.completed" | "verification.extraction"
    // event.verificationSessionId
    // event.metadata (echoed back from session create)
    // event.decision / reviewStatus / extracted fields…

    res.status(200).send("ok");
  },
);
info
Always compare signatures in constant time (e.g. crypto.timingSafeEqual in Node or hmac.compare_digest in Python) to prevent timing side-channel attacks.

4. Handle the return

When a session is created with returnUrl, the final page of the hosted flow issues a 302 to:

{returnUrl}?session_id={uuid}&status={reviewStatus}

reviewStatus can be one of:

  • pending_review — most common post-submit outcome; awaiting an operator or automated risk decision.
  • approved — terminal success (manual or automated).
  • rejected — terminal failure (e.g. automated age block or operator rejection).
  • none — session reached complete capture phase but no review has been performed yet.

Do not trust status from the URL for gating critical actions — treat it as a UX hint and confirm the decision via webhook or by polling the session.

Test vs live

Keys are suffixed with their environment: sk_test_…, sk_live_…, pk_test_…, pk_live_…. Both environments currently route to the same runtime; the distinction is a labeling primitive so your code paths, CI secrets, and analytics can stay clean.

Revoking a key is a soft-delete — the row stays in organization_api_keys with a revoked_at timestamp so audit logs keep referencing it. Mint a replacement pair first, swap your secrets, then revoke.

Troubleshooting

401 API_KEY_INVALIDThe create-session call returns 401
expand_more
Double-check the Authorization header is literally `Bearer sk_<env>_<hex>` (space between `Bearer` and the key). Publishable keys are rejected here — use pk_* only with the SDK. Revoked keys also return this code.
no webhookWebhook never fires
expand_more
Confirm that webhookUrl is set on the session and is publicly reachable from the server (tunnels like ngrok work). If you omit webhookSecret, requests are sent unsigned — some tenants' WAFs drop unsigned POSTs. Add a secret and verify the signature.
blank iframeEmbedded SDK iframe is blank
expand_more
Camera / microphone permissions require the parent page to allow them (we set allow=camera;microphone;fullscreen;autoplay on the iframe). Ensure the host origin you passed to init() matches the mounted iframe's origin and that your CSP's frame-src permits it.
no redirectUser is never redirected back
expand_more
The completion redirect only fires when returnUrl is set at session create time. Without it, users land on the built-in completion screen. Also verify returnUrl uses an http:/https: scheme — others are ignored.

Next steps