Webhooks
Webhooks tell your server when something happens, such as a verification reaching a verdict or a cheque being read, so you don't have to keep asking.
Add an endpoint#
On verisec.dev, open Webhooks and add the HTTPS URL that should receive events, then pick the events it wants. KYC and Financial each have their own webhooks page, and an endpoint receives only its own product's events. You get a signing secret that starts with whsec_; keep it like a password.
Events#
KYC
verification.created- A verification session was created.
verification.started- The applicant opened the session and started.
verification.step_completed- The applicant finished one step of the workflow.
verification.completed- Every step is done and a verdict is ready.
verification.approved- The verification was approved.
verification.needs_review- The verification needs a manual review.
verification.declined- The verification was declined.
verification.updated- A reviewer changed the verdict or the status.
verification.redacted- The personal data of a verification was deleted.
face.liveness.completed- A liveness check finished.
face.match.completed- A face match finished.
aml.screening.completed- An AML screening finished.
aml.hit- An AML screening found a possible match.
Financial
cheque.read- A cheque was read.
cheque.failed- A cheque could not be read.
card.read- A card was read.
card.failed- A card could not be read.
The payload#
Every delivery has the same four fields. data carries ids, statuses, verdicts, scores and counts only, never personal data, so a webhook log is safe to keep.
{
"type": "verification.completed",
"timestamp": "2026-10-17T14:02:15.221Z",
"environment": "test",
"data": {
"id": "ses_7Kq2W9xLm3Ab",
"reference_id": "u_10422",
"status": "completed",
"verdict": "approved",
"score": 94
}
}When a verdict is reached you get verification.completed first, then the event for the verdict itself (verification.approved, verification.needs_review or verification.declined). reference_id is the reference you gave the verification, so you can match it to your own user.
Verify the signature#
Deliveries are signed the Standard Webhooks way, with three headers: webhook-id, webhook-timestamp and webhook-signature. Always check the signature before you trust a delivery, and pass the raw body, before any JSON parsing.
import express from "express";
import { VeriSec } from "@veriseclabs/node";
const client = new VeriSec();
const app = express();
app.post("/webhooks/verisec", express.raw({ type: "application/json" }), (req, res) => {
const event = client.webhooks.constructEvent(req.body, req.headers, process.env.VERISEC_WEBHOOK_SECRET);
if (event.type === "verification.completed") console.log(event.data);
res.sendStatus(204);
});By hand#
The signature is v1, followed by the base64 HMAC-SHA256 of "<webhook-id>.<webhook-timestamp>.<raw body>". The key is the base64-decoded part of your secret after whsec_. Several v1, values separated by spaces may appear while a secret is being rotated; any one matching is enough.
import crypto from "node:crypto";
export function verify(rawBody: string, headers: Record<string, string>, secret: string) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // reject stale deliveries
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
return headers["webhook-signature"].split(" ").some((part) => {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) return false;
const given = Buffer.from(sig, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Answering a delivery#
Answer with any 2xx status within a few seconds, and do slow work afterwards. A delivery that doesn't get a 2xx is retried. The same delivery can arrive more than once, so use webhook-id to skip ones you've already handled.