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 an X-Kora-Signature header containing an HMAC-SHA256 hash of the request body. Always verify this signature before processing the payload.
- Node.js
- Python
- Go
const crypto = require("crypto");
function verifyWebhookSignature(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(typeof body === "string" ? body : JSON.stringify(body))
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your Express handler
app.post("/webhooks/koraidv", express.json(), (req, res) => {
const signature = req.headers["x-kora-signature"];
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Process the webhook...
res.status(200).json({ received: true });
});
import hmac
import hashlib
def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
# In your Flask handler
@app.post("/webhooks/koraidv")
def webhook():
signature = request.headers.get("X-Kora-Signature", "")
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
return jsonify({"error": "Invalid signature"}), 401
# Process the webhook...
return jsonify({"received": True})
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifyWebhookSignature(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
// In your HTTP handler
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Kora-Signature")
if !verifyWebhookSignature(body, signature, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process the webhook...
w.WriteHeader(http.StatusOK)
}
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-Kora-Signatureheader before trusting the payload. - Log payloads — Store raw webhook payloads for debugging and audit trails.
- Use HTTPS — Webhook endpoints must use HTTPS in production.