Webhooks, signed
Konnect ID pushes an event to your server when a session needs a decision or has one. Every delivery is signed so you can prove it is ours.
Events
| Event | Sent when |
|---|---|
verification.completed | Automatic checks all passed. Session status is FINISHED. |
verification.review_required | Something needs a human. Session status is VERIFICATION. data.reasons explains why. |
verification.approved | A reviewer approved the session. |
verification.declined | A reviewer declined the session. |
Automatic events are sent only on a real status transition, so re-evaluations do not repeat them. A verification.status_updated event may also be sent for other status changes; ignore event types you do not handle.
Payload
{
"id": "0d9a6c1e-6f55-4e0c-9a77-2f8c1f3f4b10",
"event": "verification.review_required",
"created": "2026-09-30T10:42:11.000Z",
"data": {
"sessionId": "b7c1…",
"applicationId": "order-10293",
"clientId": "acme",
"flow": "KYC",
"status": "VERIFICATION",
"decision": "MANUAL_REVIEW",
"riskScore": 55,
"reasons": ["front: Editing software trace found (Adobe Photoshop)"],
"source": "auto_verification"
}
}| Field | Description |
|---|---|
id | Unique delivery id (same value as X-Konnect-Delivery). Use it to deduplicate. |
event | Event name. |
created | ISO 8601 creation time. |
data.sessionId / applicationId / clientId / flow | Identify the session and your own references. |
data.status | Session status: FINISHED, VERIFICATION, APPROVED, DECLINED. |
data.decision | AUTO_APPROVED, MANUAL_REVIEW, APPROVED or DECLINED. |
data.riskScore | 0-100, lower is safer (automatic events only). |
data.reasons | Human-readable reasons for manual review (automatic events only). |
data.source | auto_verification, reviewer or result_link. |
/info for extracted data.Headers
Content-Type: application/json
User-Agent: KonnectID-Webhooks/1
X-Konnect-Event: verification.review_required
X-Konnect-Delivery: 0d9a6c1e-6f55-4e0c-9a77-2f8c1f3f4b10
X-Konnect-Signature: t=1790764931,v1=5b1c…e9Verify the signature
The signature header is t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<rawBody>"). The secret is the App's secret key; if the session has no app, the company API key is used instead. If neither is available the signature header is omitted.
import crypto from "node:crypto";
import express from "express";
const app = express();
const seen = new Set(); // use Redis or your database in production
// IMPORTANT: sign over the RAW body, not re-serialized JSON.
app.post("/webhooks/konnect", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Konnect-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const raw = req.body.toString("utf8");
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.KONNECT_APP_SECRET)
.update(`${t}.${raw}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const deliveryId = req.get("X-Konnect-Delivery");
if (seen.has(deliveryId)) return res.sendStatus(200); // duplicate: already handled
seen.add(deliveryId);
res.sendStatus(200); // answer fast, then work
const event = JSON.parse(raw);
queueMicrotask(() => handle(event));
});Replay protection
- Reject the request if
|now − t| > 5 minutes. - Store
X-Konnect-Deliveryids you have processed and ignore repeats. Retries reuse the same id. - Make handlers idempotent: updating a record to the same status twice must be harmless.
Delivery and retries
Each event is attempted up to four times: immediately, then after 2 s, 10 s and 60 s. Attempts time out after 8 seconds and redirects are not followed.
- Any
2xxresponse ends the delivery. - A
4xxresponse other than408or429is treated as a permanent rejection and is not retried. 408,429,5xx, network errors and timeouts are retried.
Respond with 2xx as soon as the signature is verified and the event is stored, then process asynchronously. Delivery is in-process and best effort: after the last attempt it is not queued again, so reconcile occasionally by calling /info for sessions that stay open for too long.