Overview
id beyond exposes a single HTTP endpoint to create verification sessions. Once created, you decide how the end-user reaches the flow:
- Hosted redirect — 302 the user to the returned
hostedUrl; they come back to yourreturnUrlwhen done. - Embedded SDK — mount the flow in an iframe inside your own site via
@uniicy/kyc-widget. - 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).
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"
}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, orpending_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");
},
);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 reachedcompletecapture 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 401expand_more
401 API_KEY_INVALIDThe create-session call returns 401no webhookWebhook never firesexpand_more
no webhookWebhook never firesblank iframeEmbedded SDK iframe is blankexpand_more
blank iframeEmbedded SDK iframe is blankno redirectUser is never redirected backexpand_more
no redirectUser is never redirected back