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 ping and check the response code.

Events#

  • verification_session.verified: the user proved every requirement. data is the full session object, including result.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.
Rotating the signing secret in the console takes effect immediately. Deploy the new secret before or right after you rotate.