Skip to main content

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:

  1. Create verifications — call the Kora IDV API to create a session, then pass the verification ID to your mobile app
  2. Receive webhooks — process verification results asynchronously when they complete
The app must RESUME the session your server created

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.

See Handing the verification to your app.

┌─────────────┐          ┌─────────────┐          ┌──────────────┐
│ 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.

// 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)")
}
}

When to use which​

Who creates the sessionApp callsNotes
Your server (this guide)resumeVerification(verificationId)Use the ID your server returned
The app, directlystartVerification(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:

  • status stays pending
  • attemptCount is 0
  • updatedAt is identical to createdAt
  • 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 resumeVerification with the verification ID from your server (not startVerification — see the caution above)
  • A completed test verification moves off pending and 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