Webhooks
Webhooks deliver verification results to your server in real time. When a verification completes, is approved, is rejected, or triggers a fraud alert, Kora IDV sends an HTTP POST to your configured endpoint.
Setup
You can configure webhooks in two ways:
- Global endpoint — Set a webhook URL in your dashboard settings to receive all events for your tenant
- Per-verification callback — Pass a
callbackUrlwhen creating a verification to receive events for that specific verification
If both are configured, events are delivered to both endpoints.
Payload format
All webhook payloads follow the same structure:
{
"id": "evt_def456",
"eventType": "verification.completed",
"resourceType": "verification",
"resourceId": "ver_abc123",
"tenantId": "your-tenant-uuid",
"timestamp": "2025-01-15T10:35:00Z",
"data": {
"verificationId": "ver_abc123",
"externalId": "user-123",
"status": "verified",
"decision": "auto_approve",
"decisionReason": "All checks passed",
"overallScore": 96.3
}
}
Signature verification
Every webhook includes the following headers:
| Header | Value |
|---|---|
X-Signature | Hex-encoded HMAC-SHA256 of "{timestamp}.{body}" |
X-Signature-Algorithm | sha256 |
X-Timestamp | Unix-seconds timestamp at which the event was signed |
X-Webhook-ID | Unique event ID (matches the body's id field) |
X-Event-Type | The event type (e.g. verification.completed) |
The signed payload is the literal concatenation "{timestamp}.{body}" (timestamp, a literal period, then the raw request body). The timestamp is bound into the signature to prevent replay attacks — reject any webhook whose timestamp is older than your tolerance window (5 minutes is a sensible default).
Use the official server SDKs whenever possible — they implement the signature scheme and timestamp tolerance correctly:
- Node.js (@koraidv/node)
- Python (koraidv)
- Go (koraidv-go)
- Manual (any language)
import express from "express";
import { verifyWebhookSignature, parseWebhookPayload } from "@koraidv/node";
app.post(
"/webhooks/koraidv",
express.raw({ type: "application/json" }), // raw body required
(req, res) => {
const isValid = verifyWebhookSignature(
req.body, // raw body (Buffer)
req.headers["x-signature"], // hex signature
req.headers["x-timestamp"], // unix seconds
process.env.KORAIDV_WEBHOOK_SECRET,
);
if (!isValid) return res.status(401).end();
const event = parseWebhookPayload(req.body.toString("utf-8"));
// ... process event.eventType / event.data
res.status(200).end();
}
);
The verifier rejects webhooks older than 5 minutes by default. Override with { toleranceSeconds: 600 } as the 5th argument if you need a wider window.
from flask import Flask, request, jsonify
from koraidv import verify_webhook_signature, parse_webhook_payload
import os
app = Flask(__name__)
@app.post("/webhooks/koraidv")
def webhook():
if not verify_webhook_signature(
request.data, # raw body bytes
request.headers.get("X-Signature", ""),
request.headers.get("X-Timestamp", ""),
os.environ["KORAIDV_WEBHOOK_SECRET"],
):
return jsonify({"error": "Invalid signature"}), 401
event = parse_webhook_payload(request.data)
# ... process event.event_type / event.data
return jsonify({"received": True})
Override the 5-minute replay window with tolerance_seconds=600 keyword if needed.
import (
"io"
"net/http"
"os"
koraidv "github.com/badedokun/koraidv-go"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
if !koraidv.VerifyWebhookSignature(
body,
r.Header.Get("X-Signature"),
r.Header.Get("X-Timestamp"),
os.Getenv("KORAIDV_WEBHOOK_SECRET"),
) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
event, err := koraidv.ParseWebhookPayload(body)
if err != nil {
http.Error(w, "parse body", http.StatusBadRequest)
return
}
_ = event
// ... process event.EventType / event.Data
w.WriteHeader(http.StatusOK)
}
Pass koraidv.VerifyOptions{ToleranceSeconds: 600} as a final argument to widen the replay window.
If you're not using one of our SDKs, the verification is straightforward:
1. Read X-Signature, X-Timestamp from headers
2. Reject if abs(now - X-Timestamp) > 300 seconds // replay protection
3. Compute HMAC-SHA256(secret, "{X-Timestamp}.{raw_body}")
4. Hex-encode the digest
5. Constant-time compare against X-Signature
Note the literal period (.) between the timestamp and the body. The body is the raw request bytes — do not re-serialize the JSON; use the exact bytes you received.
Always use timing-safe comparison functions (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal) to prevent timing attacks.
Event types
| Event | Description | Trigger |
|---|---|---|
verification.created | New verification session created | POST /verifications |
verification.completed | All verification steps processed | POST /verifications/{id}/complete |
verification.verified | Verification approved (auto or manual) | Score above threshold or manual approval |
verification.rejected | Verification rejected | Critical failure, sanctions hit, or manual rejection |
verification.expired | Session expired before completion | 24-hour timeout |
document.uploaded | Document image uploaded and processed | POST /verifications/{id}/document |
document.verified | Document authenticity checks completed | After document processing |
liveness.completed | Liveness session finished | All challenges submitted |
fraud_alert.created | Fraud signal detected | Duplicate document, spoof, tampering |
Example payloads
verification.completed
{
"id": "evt_def456",
"eventType": "verification.completed",
"resourceType": "verification",
"resourceId": "ver_abc123",
"tenantId": "your-tenant-uuid",
"timestamp": "2025-01-15T10:35:00Z",
"data": {
"verificationId": "ver_abc123",
"externalId": "user-123",
"status": "verified",
"decision": "auto_approve",
"decisionReason": "All checks passed",
"overallScore": 96.3
}
}
fraud_alert.created
{
"id": "evt_yza567",
"eventType": "fraud_alert.created",
"resourceType": "fraud_alert",
"resourceId": "fa_ghi789",
"tenantId": "your-tenant-uuid",
"timestamp": "2025-01-15T10:32:00Z",
"data": {
"alertId": "fa_ghi789",
"verificationId": "ver_abc123",
"alertType": "document_tampered",
"severity": "critical",
"description": "Document tampering detected: PHOTO_REPLACED"
}
}
Retry behavior
Failed deliveries (non-2xx response or timeout) are retried up to 5 times with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 24 hours |
After 3 consecutive failures, you'll receive an email notification. Failed deliveries can also be manually retried from the webhook logs in your dashboard.
Best practices
- Return 200 quickly — Process webhooks asynchronously. Return a 200 status immediately, then process the event in a background job.
- Handle duplicates — Use the event
idfield for idempotency. The same event may be delivered more than once. - Verify signatures — Always verify the
X-Signatureheader (and reject webhooks older than your tolerance window) before trusting the payload. - Log payloads — Store raw webhook payloads for debugging and audit trails.
- Use HTTPS — Webhook endpoints must use HTTPS in production.