Skip to content

Integrate a website

This guide builds the whole integration for a typical case: an online shop that must verify a customer’s identity (or age) before an order is completed. The same shape fits a sign-up, a booking or an account recovery.

The rules that make it safe:

  • The API key and the webhook secret stay on your server.
  • Your server decides, from a signed webhook or its own API call — never from what the browser says (a return URL can be opened by anyone).
  • Every verification belongs to one of your records (order, booking, account): you store the verification_id with it and get your reference back in extra_data.
  • Webhooks can repeat and arrive in any order: handle them idempotently.
Your endpointDoes
POST /checkout/verifyCreates the verification for the order and redirects the browser to it.
POST /webhooks/zetaidReceives webhooks, checks the signature, updates the order.
GET /checkout/verifiedThe return page: shows what the server knows, asks ZetaID if it doesn’t know yet.

Your order record gets three fields: verification_id, verification_status (none, pending, verified, rejected, review, expired) and the time it was last updated.

Node.js with Express. orders stands for your own data access.

server.js
import express from "express";
import crypto from "node:crypto";
const API = "https://verify.zetaid.com/api/v1";
const API_KEY = process.env.ZETAID_API_KEY; // vk_live_…
const WEBHOOK_SECRET = process.env.ZETAID_WEBHOOK_SECRET; // whsec_…
const app = express();
async function zetaid(path, init = {}) {
const res = await fetch(API + path, {
...init,
headers: { "X-SDK-Key": API_KEY, "Content-Type": "application/json", ...init.headers },
signal: AbortSignal.timeout(20_000),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(body.error?.code ?? `HTTP ${res.status}`), { status: res.status });
return body;
}
// Map ZetaID's status onto the order.
function orderStatus(zetaidStatus) {
switch (zetaidStatus) {
case "verified": return "verified";
case "rejected": return "rejected";
case "needs_manual_review": return "review";
case "expired": return "expired";
default: return "pending";
}
}
server.js
app.post("/checkout/verify", async (req, res) => {
const order = await orders.forSession(req); // your session → order
if (order.verification_status === "verified") return res.redirect(303, "/checkout/pay");
try {
const v = await zetaid("/verifications", {
method: "POST",
body: JSON.stringify({
return_url: `https://shop.example/checkout/verified?order=${encodeURIComponent(order.id)}`,
extra_data: { order_id: order.id },
}),
});
await orders.update(order.id, { verification_id: v.verification_id, verification_status: "pending" });
res.redirect(303, v.url);
} catch (err) {
// 503: checks can't start right now; 402: prepaid balance used up.
res.status(503).send("Identity check unavailable right now. Please try again in a minute.");
}
});

A new click on “verify” after an expired or rejected result simply creates a new verification and replaces the stored verification_id.

The signature is computed over the raw body, so read it raw for this route only:

server.js
app.post("/webhooks/zetaid", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
const ts = req.get("X-Webhook-Timestamp") ?? "";
const sig = req.get("X-Webhook-Signature") ?? "";
const expected = "sha256=" +
crypto.createHmac("sha256", WEBHOOK_SECRET).update(`${ts}.`).update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!fresh || sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
const order = await orders.get(event.extra_data?.order_id);
// Only the order's current verification counts (an older attempt may report late).
if (order && order.verification_id === event.verification_id) {
await orders.update(order.id, {
verification_status: orderStatus(event.status),
// For verified customers, keep only what you need — e.g. the date of birth for an age check.
date_of_birth: event.export?.personal_info?.date_of_birth ?? order.date_of_birth,
});
}
res.status(204).end(); // answer fast; a repeat of the same event changes nothing
});
server.js
app.get("/checkout/verified", async (req, res) => {
const order = await orders.forSession(req);
if (!order || order.id !== req.query.order) return res.redirect(303, "/checkout");
// The webhook may not have arrived yet: ask ZetaID once.
if (order.verification_status === "pending" && order.verification_id) {
try {
const v = await zetaid(`/verifications/${order.verification_id}`);
await orders.update(order.id, { verification_status: orderStatus(v.status) });
order.verification_status = orderStatus(v.status);
} catch { /* keep pending; the page refreshes */ }
}
switch (order.verification_status) {
case "verified": return res.redirect(303, "/checkout/pay");
case "review": return res.render("verify-review"); // "We'll confirm by email."
case "rejected": return res.render("verify-failed"); // offer a new attempt
case "expired": return res.render("verify-expired"); // offer to start again
default: return res.render("verify-waiting"); // spinner, reload every 3 s
}
});

The waiting page reloads itself (<meta http-equiv="refresh" content="3">) and gives up after about a minute with “We’ll email you when your check is done”. Checks normally finish within seconds.

  1. The API key and webhook secret are in your server’s secret store, not in code, not in the browser.
  2. Each verification is created by your server for one record, with your reference in extra_data and return_url.
  3. The webhook route checks the signature on the raw body and the timestamp, answers 2xx fast, and ignores repeats and late webhooks of older attempts.
  4. The return page shows what your server knows and falls back to GET /api/v1/verifications/{id}.
  5. rejected, needs_manual_review and expired each have a clear next step for the person.
  6. You keep only the personal data you need, for as long as you need it — and set the key’s retention to match (Configure a key).

Then: Going live.