Overview
Kaluta KYC is an identity verification API. Your backend creates a session per end user, then your frontend opens that session in one of three ways:
- Embedded widget runs inline on your site in a modal. No redirect.
- Full page redirect hands the user to a hosted page and back.
- Mobile handoff shows a QR code on desktop. Your user finishes on their phone.
The final decision and extracted data arrive on your server through a signed webhook.
Setup
- Create a business account. You get 5 free verifications to start.
- Open Dashboard → API Keys and generate a key. The full key is shown once. Store it as a secret on your server.
- Configure at least one webhook in Dashboard → Webhooks pointing to your backend.
Keep funds on your account from Dashboard → Billing. Pay as you go at $1.00 per completed verification.
Quickstart
From zero to first verification in three steps.
1. Create a session (server side)
curl -X POST https://kalutakyc.com/v1/sessions \
-H "X-API-Key: klt_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "external_id": "user_42" }'
# → { "session_id": "abc123",
# "verification_url": "https://kalutakyc.com/verify/abc123",
# "expires_at": "2026-06-02T00:00:00Z" }2. Open the verifier (client side)
<script src="https://kalutakyc.com/embed.js"></script>
<button onclick="open()">Verify my identity</button>
<script>
function open() {
KalutaKYC.open({
url: "https://kalutakyc.com/verify/abc123",
onComplete: (r) => console.log(r.status, r.score),
});
}
</script>3. Handle the webhook (server side)
app.post("/hooks/kaluta", (req, res) => {
if (!isSignatureValid(req)) return res.status(401).end();
if (req.body.event === "session.approved") {
// unlock the user account
}
res.sendStatus(200);
});Authentication
All server to server requests authenticate with your secret API key in theX-API-Key header. Dashboard endpoints use a JWT inAuthorization: Bearer. Keys start withklt_ and are bcrypt hashed in storage. The raw value is shown only at creation time.
X-API-Key: klt_live_xxxxxxxxxxxxxxxxxxxxxxxx
Never ship your API key to the browser
Always proxy session creation through your own backend. Anything visible in DevTools is public.Create a session
Call this from your backend when a user needs to be verified.
POST /v1/sessions
X-API-Key: klt_live_xxx
{
"external_id": "user_42",
"document_type": "passport",
"redirect_url": "https://yourapp.com/kyc/done",
"webhook_url": "https://yourapp.com/hooks/kyc",
"metadata": { "plan": "premium" }
}{
"session_id": "abc123",
"verification_url": "https://kalutakyc.com/verify/abc123",
"expires_at": "2026-06-02T00:00:00Z"
}Sessions expire 24 hours after creation. You are billed at session creation, not at completion.
Retrieve a session
Full session with extracted identity, scores, and short lived signed image URLs.
GET /v1/sessions/{id}
Authorization: Bearer <jwt>Session lifecycle
A session moves through these states. Use webhook events to track them.
| Status | Meaning |
|---|---|
created | Session created. User has not started yet. |
document_submitted | Document uploaded and OCR ran. |
face_submitted | Selfie uploaded and face match ran. |
under_review | Automated checks flagged it. Awaiting manual decision. |
approved | Final decision approved. Webhook fired. |
rejected | Final decision rejected. Webhook fired. |
expired | 24 hour window elapsed without completion. |
Auto approval kicks in when the combined score is at least 80 and liveness passed. Otherwise the session lands in under_review for a manual decision.
Embedded widget
Run the flow inline on your website in a secure modal. Your users never leave your page.
<script src="https://kalutakyc.com/embed.js"></script>
<button id="verify-btn">Verify my identity</button>
<script>
document.getElementById("verify-btn").addEventListener("click", () => {
KalutaKYC.open({
url: "https://kalutakyc.com/verify/abc123",
onReady: () => console.log("widget loaded"),
onStep: (s) => console.log("step:", s),
onComplete: (r) => console.log(r.status, r.score),
onClose: () => console.log("widget closed"),
});
});
</script>KalutaKYC.open options
| Option | Type | Description |
|---|---|---|
| url | string | The verification_url returned by POST /v1/sessions. |
| sessionId | string | Alternative to url. Combined with baseUrl. |
| baseUrl | string | Override the app host. Defaults to https://kalutakyc.com. |
| onReady | () => void | Fires when the widget iframe has loaded. |
| onStep | (step: string) => void | Fires on every step change. |
| onComplete | ({status, score}) => void | Fires when the final result is known. Always treat the webhook as source of truth. |
| onClose | (result?) => void | Fires when the modal closes. |
Redirect flow
Prefer a full page hand off? Send the user to theverification_url. When they finish, they are returned to yourredirect_url.
window.location.href = verificationUrl; // or KalutaKYC.redirect(verificationUrl);
Mobile handoff
Desktop users often have low quality webcams or no rear camera. The verification flow includes a built in handoff. On the welcome screen and in every camera error fallback, the user can click Continue on my phone. A QR code appears. They scan it with their phone camera and finish the document and selfie there.
No integration work needed. The desktop page polls the session status and automatically advances to the final screen when the phone is done.
Next.js (App Router)
Server action creates the session. Client component opens the widget.
// app/actions/verify.ts
"use server";
export async function createVerificationSession(externalId: string) {
const res = await fetch("https://kalutakyc.com/v1/sessions", {
method: "POST",
headers: {
"X-API-Key": process.env.KALUTA_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_id: externalId,
redirect_url: `${process.env.NEXT_PUBLIC_APP_URL}/kyc/done`,
}),
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to create session");
return res.json() as Promise<{ session_id: string; verification_url: string }>;
}// app/kyc/page.tsx
"use client";
import Script from "next/script";
import { useState } from "react";
import { createVerificationSession } from "@/app/actions/verify";
declare global { interface Window { KalutaKYC: any } }
export default function KycPage() {
const [status, setStatus] = useState("");
const start = async () => {
const { verification_url } = await createVerificationSession("user_42");
window.KalutaKYC.open({
url: verification_url,
onComplete: (r: any) => setStatus(`${r.status} (score ${r.score})`),
});
};
return (<>
<Script src="https://kalutakyc.com/embed.js" strategy="afterInteractive" />
<button onClick={start} className="bg-blue-600 text-white px-5 py-3 rounded-lg">
Verify my identity
</button>
<p>{status}</p>
</>);
}PHP and HTML
<?php // create-session.php
header("Content-Type: application/json");
$ch = curl_init("https://kalutakyc.com/v1/sessions");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: " . getenv("KALUTA_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"external_id" => $_POST["user_id"] ?? "anon",
"redirect_url" => "https://example.com/kyc/done",
]),
]);
$resp = curl_exec($ch);
http_response_code(curl_getinfo($ch, CURLINFO_HTTP_CODE));
echo $resp;<!-- verify.html -->
<button id="go">Verify my identity</button>
<script src="https://kalutakyc.com/embed.js"></script>
<script>
document.getElementById("go").addEventListener("click", async () => {
const r = await fetch("/create-session.php", { method: "POST" });
const { verification_url } = await r.json();
KalutaKYC.open({
url: verification_url,
onComplete: (result) => alert("Status: " + result.status),
});
});
</script>Node.js Express
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post("/api/create-session", express.json(), async (req, res) => {
const r = await fetch("https://kalutakyc.com/v1/sessions", {
method: "POST",
headers: {
"X-API-Key": process.env.KALUTA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ external_id: req.body.userId }),
});
res.status(r.status).json(await r.json());
});
app.post("/hooks/kaluta", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body;
const sig = req.get("x-kaluta-signature") || "";
const [ts, v1] = sig.split(",").map(p => p.split("=")[1]);
const expected = crypto
.createHmac("sha256", process.env.KALUTA_WEBHOOK_SECRET)
.update(`${ts}.${raw}`).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return res.sendStatus(401);
}
const { event } = JSON.parse(raw.toString());
if (event === "session.approved") { /* unlock account */ }
res.sendStatus(200);
});Python FastAPI
import os, hmac, hashlib, httpx
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
API_KEY = os.environ["KALUTA_API_KEY"]
SECRET = os.environ["KALUTA_WEBHOOK_SECRET"].encode()
@app.post("/api/create-session")
async def create_session(payload: dict):
async with httpx.AsyncClient() as client:
r = await client.post(
"https://kalutakyc.com/v1/sessions",
headers={"X-API-Key": API_KEY},
json={"external_id": payload["user_id"]},
)
return r.json()
@app.post("/hooks/kaluta")
async def webhook(request: Request):
raw = await request.body()
sig = request.headers.get("x-kaluta-signature", "")
ts, v1 = (p.split("=")[1] for p in sig.split(","))
expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
raise HTTPException(status_code=401)
return {"ok": True}Python Django
# views.py
import os, json, hmac, hashlib, requests
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
API_KEY = os.environ["KALUTA_API_KEY"]
SECRET = os.environ["KALUTA_WEBHOOK_SECRET"].encode()
@require_POST
def create_session(request):
body = json.loads(request.body)
r = requests.post(
"https://kalutakyc.com/v1/sessions",
headers={"X-API-Key": API_KEY},
json={"external_id": body["user_id"]},
timeout=10,
)
return JsonResponse(r.json(), status=r.status_code)
@csrf_exempt
@require_POST
def kaluta_webhook(request):
raw = request.body
sig = request.headers.get("X-Kaluta-Signature", "")
ts, v1 = (p.split("=")[1] for p in sig.split(","))
expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
return HttpResponse(status=401)
return HttpResponse("ok")PHP Laravel
// routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Http, Route};
Route::post("/create-session", function (Request $r) {
$resp = Http::withHeaders(["X-API-Key" => config("services.kaluta.key")])
->post("https://kalutakyc.com/v1/sessions", [
"external_id" => $r->input("user_id"),
]);
return response()->json($resp->json(), $resp->status());
});
Route::post("/hooks/kaluta", function (Request $r) {
$raw = $r->getContent();
$sig = $r->header("X-Kaluta-Signature", "");
[$ts, $v1] = array_map(fn($p) => explode("=", $p)[1], explode(",", $sig));
$expected = hash_hmac("sha256", "$ts.$raw", config("services.kaluta.webhook_secret"));
abort_unless(hash_equals($expected, $v1), 401);
return response("ok");
})->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);Plain HTML
Never call POST /v1/sessions from the browser
That would expose your secret API key to every visitor. Always proxy through a backend.<button id="go">Verify my identity</button>
<p id="result"></p>
<script src="https://kalutakyc.com/embed.js"></script>
<script>
document.getElementById("go").onclick = async () => {
const res = await fetch("/api/create-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: "user_42" }),
});
const { verification_url } = await res.json();
KalutaKYC.open({
url: verification_url,
onComplete: (r) => {
document.getElementById("result").textContent =
"Final status: " + r.status + " (score " + r.score + ")";
},
});
};
</script>Webhooks
Configure endpoints in Dashboard → Webhooks. Every event is signed with HMAC SHA256 in theX-Kaluta-Signature header. Always verify the signature before trusting the payload.
Signature format
The header uses a Stripe like scheme so receivers can detect replays.
X-Kaluta-Signature: t=1722070800,v1=8f4b3a...
To verify: recompute HMAC over t.raw_body and compare with v1. Reject if t is older than 5 minutes.
Events
session.createdsession.document_submittedsession.face_submittedsession.under_reviewsession.approvedsession.rejectedsession.expired
Idempotency
We retry failed deliveries with exponential backoff. Return a 2xx within 10 seconds and make your handler idempotent.Result schema
A completed session looks like this.
{
"id": "abc123",
"external_id": "user_42",
"status": "approved",
"document_type": "passport",
"first_name": "Ada",
"last_name": "Lovelace",
"date_of_birth": "1815-12-10",
"nationality": "GBR",
"document_number": "P12345678",
"document_expiry": "2030-01-01",
"document_score": 92,
"face_score": 96,
"liveness_score": 90,
"overall_score": 93,
"risk_level": "low",
"extracted_data": { /* full OCR/MRZ payload */ },
"checks": { "mrz_detected": true, "face_match": true, "liveness": true },
"metadata": { "plan": "premium" },
"created_at": "2026-06-01T12:00:00Z",
"completed_at": "2026-06-01T12:01:42Z"
}Scoring
Overall score is a weighted blend.
- Document quality 40%. Blur, brightness, contrast, OCR success.
- Face match 35%. Selfie vs document photo similarity.
- Liveness 25%. Anti spoofing checks.
80 and up = low risk · 60 to 79 = medium · below 60 = high
Errors
The API returns standard HTTP status codes with a JSON body.
{ "detail": "Invalid API key" }| Status | Meaning |
|---|---|
| 400 | Bad request. Required field missing or invalid. |
| 401 | Invalid API key or expired JWT. |
| 402 | Insufficient balance. Top up to continue. |
| 403 | Action not allowed on this resource. |
| 404 | Resource not found. |
| 409 | Conflict, for example session already approved or rejected. |
| 410 | Session expired. |
| 413 | Uploaded file is too large (max 20 MB). |
| 429 | Rate limited. Retry after a short backoff. |
| 500 | Server error. Retry. Contact support if persistent. |
Testing
Two easy ways to try the flow without writing code.
- The embed demo page lets you paste any
verification_urland open the inline widget with live event logs. - Replay a webhook delivery from Dashboard → Webhooks → Endpoint → Deliveries.
New accounts receive 5 free verifications so you can run a real end to end test without committing to billing.
API reference
Every endpoint at a glance.
| Method | Endpoint | Description |
|---|---|---|
| POST | /v1/sessions | Create a verification session |
| GET | /v1/sessions | List your sessions |
| GET | /v1/sessions/{id} | Retrieve a session with results |
| POST | /v1/sessions/{id}/approve | Manually approve a session |
| POST | /v1/sessions/{id}/reject | Manually reject a session |
| GET | /v1/sessions/{id}/public | Public read |
| GET | /v1/api-keys | List API keys |
| POST | /v1/api-keys | Create a new API key |
| DELETE | /v1/api-keys/{id} | Revoke a key |
| GET | /v1/webhooks | List webhook endpoints |
| POST | /v1/webhooks | Create a webhook endpoint |
| DELETE | /v1/webhooks/{id} | Delete a webhook endpoint |
| GET | /v1/billing/summary | Account balance and usage |
| POST | /v1/billing/topup | Add credits to your balance |
Need a hand?
Email support@kalutakyc.com for integration questions, security disclosures, or a DPA request.