Documentation

Ship identity verification, faster.

Kaluta KYC gives you one API for document verification, facial biometrics, liveness detection, and webhooks. Follow the quickstart or jump straight to the reference.

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

  1. Create a business account. You get 5 free verifications to start.
  2. Open Dashboard → API Keys and generate a key. The full key is shown once. Store it as a secret on your server.
  3. 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)

bash
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)

html
<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)

javascript
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.

http
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.

json
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" }
}
json
{
  "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.

bash
GET /v1/sessions/{id}
Authorization: Bearer <jwt>

Session lifecycle

A session moves through these states. Use webhook events to track them.

StatusMeaning
createdSession created. User has not started yet.
document_submittedDocument uploaded and OCR ran.
face_submittedSelfie uploaded and face match ran.
under_reviewAutomated checks flagged it. Awaiting manual decision.
approvedFinal decision approved. Webhook fired.
rejectedFinal decision rejected. Webhook fired.
expired24 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.

html
<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

OptionTypeDescription
urlstringThe verification_url returned by POST /v1/sessions.
sessionIdstringAlternative to url. Combined with baseUrl.
baseUrlstringOverride the app host. Defaults to https://kalutakyc.com.
onReady() => voidFires when the widget iframe has loaded.
onStep(step: string) => voidFires on every step change.
onComplete({status, score}) => voidFires when the final result is known. Always treat the webhook as source of truth.
onClose(result?) => voidFires 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.

javascript
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.

ts
// 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 }>;
}
tsx
// 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
<?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;
html
<!-- 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

javascript
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

python
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

python
# 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

php
// 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.
html
<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.

http
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.created
  • session.document_submitted
  • session.face_submitted
  • session.under_review
  • session.approved
  • session.rejected
  • session.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.

json
{
  "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.

json
{ "detail": "Invalid API key" }
StatusMeaning
400Bad request. Required field missing or invalid.
401Invalid API key or expired JWT.
402Insufficient balance. Top up to continue.
403Action not allowed on this resource.
404Resource not found.
409Conflict, for example session already approved or rejected.
410Session expired.
413Uploaded file is too large (max 20 MB).
429Rate limited. Retry after a short backoff.
500Server 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_url and 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.

MethodEndpointDescription
POST/v1/sessionsCreate a verification session
GET/v1/sessionsList your sessions
GET/v1/sessions/{id}Retrieve a session with results
POST/v1/sessions/{id}/approveManually approve a session
POST/v1/sessions/{id}/rejectManually reject a session
GET/v1/sessions/{id}/publicPublic read
GET/v1/api-keysList API keys
POST/v1/api-keysCreate a new API key
DELETE/v1/api-keys/{id}Revoke a key
GET/v1/webhooksList webhook endpoints
POST/v1/webhooksCreate a webhook endpoint
DELETE/v1/webhooks/{id}Delete a webhook endpoint
GET/v1/billing/summaryAccount balance and usage
POST/v1/billing/topupAdd credits to your balance

Need a hand?

Email support@kalutakyc.com for integration questions, security disclosures, or a DPA request.