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_idwith it and get your reference back inextra_data. - Webhooks can repeat and arrive in any order: handle them idempotently.
What you build
Section titled “What you build”| Your endpoint | Does |
|---|---|
POST /checkout/verify | Creates the verification for the order and redirects the browser to it. |
POST /webhooks/zetaid | Receives webhooks, checks the signature, updates the order. |
GET /checkout/verified | The 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.
The example
Section titled “The example”Node.js with Express. orders stands for your own data access.
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"; }}1. Start the verification
Section titled “1. Start the verification”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.
2. Receive the webhooks
Section titled “2. Receive the webhooks”The signature is computed over the raw body, so read it raw for this route only:
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});3. The return page
Section titled “3. The return page”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.
The checklist
Section titled “The checklist”- The API key and webhook secret are in your server’s secret store, not in code, not in the browser.
- Each verification is created by your server for one record, with your
reference in
extra_dataandreturn_url. - The webhook route checks the signature on the raw body and the
timestamp, answers
2xxfast, and ignores repeats and late webhooks of older attempts. - The return page shows what your server knows and falls back to
GET /api/v1/verifications/{id}. rejected,needs_manual_reviewandexpiredeach have a clear next step for the person.- 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.