Server Integration
This guide covers the server-side integration: creating verifications, passing IDs to your mobile app, and handling webhook results.
Architecture
Your server handles two things:
- Create verifications — call the Kora IDV API to create a session, then pass the verification ID to your mobile app
- Receive webhooks — process verification results asynchronously when they complete
When your server creates the verification, the app has to adopt that specific
session. Call resumeVerification with the ID your server returned — not
startVerification.
startVerification ignores any ID you hold and creates a brand-new session of
its own. The one your server created is then never used: it stays pending
with an attempt count of 0 forever, your webhook never fires, and polling it
returns pending indefinitely.
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ Mobile App │◀────────│ Your Server │◀────────│ Kora IDV │
│ │ ver ID │ │ webhook │ API │
└──────┬───────┘ └──────┬───────┘ └──────────────┘
│ │
│ resumeVerification() │ POST /verifications
│ → doc + selfie │
└─────────────────────────┘
Handing the verification to your app
Your server returns a verification ID. The app passes that ID to the SDK with
resumeVerification, which adopts the existing session and runs the capture
flow against it.
- iOS (Swift)
- Android (Kotlin)
- React Native
// verificationId came from YOUR server, which created the session.
KoraIDV.resumeVerification(
verificationId: verificationId,
from: self
) { result in
switch result {
case .success(let verification):
print("Completed: \(verification.status)")
case .failure(let error):
print("Failed: \(error.localizedDescription)")
}
}
// Register the resume contract, then launch it with the ID from your server.
private val resumeVerification = registerForActivityResult(
KoraIDV.ResumeVerificationContract()
) { result ->
when (result) {
is VerificationResult.Success -> Log.d("Kora", "Completed: ${result.verification.status}")
is VerificationResult.Failure -> Log.e("Kora", "Failed: ${result.error}")
is VerificationResult.Cancelled -> Log.d("Kora", "Cancelled")
}
}
resumeVerification.launch(verificationId)
const { resumeVerification } = useKoraIDV();
// verificationId came from your server.
await resumeVerification(verificationId);
When to use which
| Who creates the session | App calls | Notes |
|---|---|---|
| Your server (this guide) | resumeVerification(verificationId) | Use the ID your server returned |
| The app, directly | startVerification(externalId) | No server call needed; the SDK creates the session |
Both are supported. What does not work is creating on the server and then
calling startVerification in the app — the server's session is orphaned and
never completes.
Diagnosing an orphaned session
A session created by your server and never adopted by the app looks like this:
statusstayspendingattemptCountis0updatedAtis identical tocreatedAt- no webhook is ever delivered
If you see that, the app is calling startVerification instead of
resumeVerification, or the SDK is not being launched at all.
Each example reads its configuration from environment variables. Sandbox and production use separate base URLs, so set KORAIDV_BASE_URL to match your API key's environment:
export KORAIDV_API_KEY="kora_abc123..."
export KORAIDV_TENANT_ID="your-tenant-uuid"
export KORAIDV_WEBHOOK_SECRET="whsec_..."
# Production base URL. For sandbox testing use:
# https://koraidv-identity-sandbox-626704085312.us-central1.run.app/api/v1
export KORAIDV_BASE_URL="https://idv.korastratum.com/api/v1/idv"
Express.js (Node.js)
const express = require("express");
const crypto = require("crypto");
const app = express();
// Capture the raw request body so webhook signatures can be verified over the exact bytes
app.use(express.json({
limit: "10mb",
verify: (req, res, buf) => { req.rawBody = buf; },
}));
const API_BASE = process.env.KORAIDV_BASE_URL;
const API_KEY = process.env.KORAIDV_API_KEY;
const TENANT_ID = process.env.KORAIDV_TENANT_ID;
const WEBHOOK_SECRET = process.env.KORAIDV_WEBHOOK_SECRET;
// Create a verification — called by your mobile app before launching the SDK
app.post("/api/verifications", async (req, res) => {
const { userId, tier } = req.body;
const response = await fetch(`${API_BASE}/verifications`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"X-Tenant-ID": TENANT_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({
externalId: userId,
tier: tier || "standard",
callbackUrl: "https://your-server.com/webhooks/koraidv",
}),
});
const verification = await response.json();
res.json({ verificationId: verification.id });
});
// Webhook handler — receives verification results
app.post("/webhooks/koraidv", (req, res) => {
// Verify signature — HMAC over "{timestamp}.{rawBody}"
const signature = req.headers["x-signature"];
const timestamp = req.headers["x-timestamp"];
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.${req.rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
return res.status(401).json({ error: "Invalid signature" });
}
const { eventType, data } = req.body;
switch (eventType) {
case "verification.completed":
handleVerificationCompleted(data);
break;
case "verification.verified":
handleVerificationApproved(data);
break;
case "verification.rejected":
handleVerificationRejected(data);
break;
case "fraud_alert.created":
handleFraudAlert(data);
break;
}
res.status(200).json({ received: true });
});
function handleVerificationCompleted(data) {
console.log(`Verification ${data.verificationId}: ${data.decision}`);
// Update your user record based on data.decision
}
function handleVerificationApproved(data) {
// Activate the user's account
}
function handleVerificationRejected(data) {
// Notify the user, offer retry
}
function handleFraudAlert(data) {
// Alert your compliance team
}
app.listen(3000);
Flask (Python)
import hmac
import hashlib
import json
import os
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
API_BASE = os.environ["KORAIDV_BASE_URL"]
API_KEY = os.environ["KORAIDV_API_KEY"]
TENANT_ID = os.environ["KORAIDV_TENANT_ID"]
WEBHOOK_SECRET = os.environ["KORAIDV_WEBHOOK_SECRET"]
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"X-Tenant-ID": TENANT_ID,
"Content-Type": "application/json",
}
@app.post("/api/verifications")
def create_verification():
data = request.json
response = requests.post(
f"{API_BASE}/verifications",
headers=HEADERS,
json={
"externalId": data["userId"],
"tier": data.get("tier", "standard"),
"callbackUrl": "https://your-server.com/webhooks/koraidv",
},
)
verification = response.json()
return jsonify({"verificationId": verification["id"]})
@app.post("/webhooks/koraidv")
def webhook_handler():
# Verify signature — HMAC over "{timestamp}.{raw_body}"
signature = request.headers.get("X-Signature", "")
timestamp = request.headers.get("X-Timestamp", "")
signed_payload = f"{timestamp}.{request.get_data(as_text=True)}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({"error": "Invalid signature"}), 401
payload = request.json
event_type = payload["eventType"]
data = payload["data"]
if event_type == "verification.completed":
# Update user record based on data["decision"]
print(f"Verification {data['verificationId']}: {data['decision']}")
elif event_type == "fraud_alert.created":
# Alert compliance team
print(f"Fraud alert: {data['alertType']} - {data['severity']}")
return jsonify({"received": True})
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
)
var (
apiBase = os.Getenv("KORAIDV_BASE_URL")
apiKey = os.Getenv("KORAIDV_API_KEY")
tenantID = os.Getenv("KORAIDV_TENANT_ID")
webhookSecret = os.Getenv("KORAIDV_WEBHOOK_SECRET")
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
// Verify signature — HMAC over "{timestamp}.{rawBody}"
signature := r.Header.Get("X-Signature")
timestamp := r.Header.Get("X-Timestamp")
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(timestamp + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expected)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var payload struct {
EventType string `json:"eventType"`
Data json.RawMessage `json:"data"`
}
json.Unmarshal(body, &payload)
switch payload.EventType {
case "verification.completed":
log.Printf("Verification completed: %s", payload.Data)
case "fraud_alert.created":
log.Printf("Fraud alert: %s", payload.Data)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/koraidv", webhookHandler)
log.Fatal(http.ListenAndServe(":3000", nil))
}
Checklist
Before going live, verify:
- Verification creation works with your production API key
- Mobile SDK calls
resumeVerificationwith the verification ID from your server (notstartVerification— see the caution above) - A completed test verification moves off
pendingand delivers a webhook - Webhook endpoint receives and processes events
- Webhook signature verification is enabled
- You handle all three decisions:
auto_approve,auto_reject,manual_review - Error responses (400, 401, 429, 500) are handled gracefully
- Rate limits are respected with exponential backoff