Integration
Webhooks
Integration
Webhooks
LKP POSTs a signed JSON event to your server when a session is verified or fails, so you don't have to poll.
Set up an endpoint#
- In the console, open your app → Settings → Webhooks and enter an https URL. Its hostname must resolve to a public IP address; private, loopback and link-local addresses are refused, both when saving and at delivery.
- Copy the signing secret (it starts with
whsec_) into your server's environment. - Click Send test event to deliver a
pingand check the response code.
Events#
verification_session.verified: the user proved every requirement.datais the full session object, includingresult.nullifier.verification_session.failed: the session ended without success, for example a reused one-time nullifier.ping: sent from the console's test button.
POST https://your-server.example/webhooks/lkp
{
"type": "verification_session.verified",
"created": 1793968512,
"data": {
"id": "vs_3f9c2a…",
"object": "verification_session",
"livemode": true,
"status": "verified",
"reference": "order_1042",
"claims": ["Age >= 18"],
"result": { "nullifier": "1489…", "verifiedClaims": ["Age >= 18"], "idToken": "eyJhbGciOiJSUzI1NiIs…" },
"…": "…"
}
}Verify the signature#
Each request has an lkp-signature header of the form t=1793968512,v1=5257a8…. v1 is the hex HMAC-SHA256 of `${t}.${rawBody}` with your signing secret. Compute it over the raw request body, compare in constant time, and reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
app.post("/webhooks/lkp", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("lkp-signature") ?? "";
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = createHmac("sha256", process.env.LKP_WEBHOOK_SECRET)
.update(`${t}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const valid = v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!fresh || !valid) return res.status(400).send("invalid signature");
const event = JSON.parse(req.body);
if (event.type === "verification_session.verified") {
markVerified(event.data.reference, event.data.result.nullifier);
}
res.sendStatus(200);
});Delivery#
- Respond with a 2xx within 5 seconds. Redirects are not followed.
- Each event is delivered once, without automatic retries. If your endpoint was down, reconcile with
GET /v1/verification-sessions?status=verified. - Handle events idempotently using
data.id.