Get the result
There are two ways to learn how a verification went. Use both:
- Webhooks (recommended): ZetaID posts to your key’s webhook URL as soon as something happens.
GET /api/v1/verifications/{id}: your server asks whenever it needs to — on the return page, or to catch up after an outage.
Webhooks
Section titled “Webhooks”ZetaID sends a POST with a JSON body to the key’s webhook URL:
| Event | When |
|---|---|
verification.completed | The checks have decided: verified, rejected or needs_manual_review. Carries the result (export). |
verification.reviewed | A manual review decided a needs_manual_review verification: verified or rejected. |
verification.expired | The person didn’t finish before the deadline, or stopped. |
{ "event": "verification.completed", "verification_id": "3f6c2a9e-7d41-4b8a-9c2e-5a1f0d7b3e64", "status": "verified", "reason_code": null, "export": { "…": "see Reference → The result" }, "extra_data": { "order_id": "A-17" }, "completed_at": "2026-11-01T10:19:42Z"}All fields per event: Reference → Webhooks.
Answer quickly
Section titled “Answer quickly”Answer with any 2xx within 20 seconds, then do the work. Anything else (a
timeout, 4xx, 5xx) is retried up to 8 times over about two hours, with
growing gaps. The same delivery can therefore arrive more than once: use the
X-Webhook-Delivery header (or verification_id + event) to ignore
repeats.
Check the signature
Section titled “Check the signature”Your webhook URL is public, so anyone could post a fake “verified” to it.
Every webhook is signed with your key’s webhook signing secret
(whsec_…, shown once when the key was created):
| Header | |
|---|---|
X-Webhook-Timestamp | Unix time (seconds) when the delivery was sent. |
X-Webhook-Signature | sha256= + hex HMAC-SHA256 of timestamp + "." + raw body, with your signing secret. |
X-Webhook-Event | The event name. |
X-Webhook-Delivery | The delivery’s id, the same on every retry. |
Compute the HMAC over the raw request body exactly as received (before any JSON parsing), compare in constant time, and refuse timestamps older than five minutes:
import crypto from "node:crypto";
// rawBody: a Buffer of the request body as received (e.g. express.raw()).export function validWebhook(secret, headers, rawBody) { const ts = headers["x-webhook-timestamp"]; const sig = headers["x-webhook-signature"] ?? ""; if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex"); return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));}import hashlib, hmac, time
def valid_webhook(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool: if not timestamp or abs(time.time() - int(timestamp)) > 300: return False mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256) return hmac.compare_digest("sha256=" + mac.hexdigest(), signature or "")function valid_webhook(string $secret, string $ts, string $rawBody, string $sig): bool { if ($ts === '' || abs(time() - (int)$ts) > 300) return false; $expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $rawBody, $secret); return hash_equals($expected, $sig);}// $rawBody = file_get_contents('php://input');Refuse a webhook whose signature doesn’t match with 401, and don’t act on
it.
Read a verification
Section titled “Read a verification”GET https://verify.zetaid.com/api/v1/verifications/{verification_id}X-SDK-Key: vk_live_…200 OK with the same fields as the completion webhook, plus where the
verification stands:
{ "verification_id": "3f6c2a9e-7d41-4b8a-9c2e-5a1f0d7b3e64", "status": "verified", "terminal": true, "reason_code": null, "export": { "…": "the result, as in the webhook" }, "extra_data": { "order_id": "A-17" }, "created_at": "2026-11-01T10:15:00Z", "updated_at": "2026-11-01T10:19:42Z"}exportisnulluntil the checks have run, and again once the key’s personal-data retention has deleted it.- Any key of your organization can read its verifications; another
organization’s id answers
404 VERIFICATION_NOT_FOUND. - Call it from your server only, and not in a tight loop: every few seconds while a person waits on your return page is plenty.
What the status means
Section titled “What the status means”status | Meaning | Your site |
|---|---|---|
generated | Created; the person hasn’t opened the link. | Waiting. |
initiated, in_progress | The person is going through the steps. | Waiting. |
validating | Everything is captured; the checks are running (seconds). | Waiting. |
verified | Passed. | Continue. |
rejected | Failed; reason_code says why. | Explain, offer a new attempt or another way. |
needs_manual_review | A person at the organization reviews it (operator console). | Tell the person you’ll confirm; wait for verification.reviewed. |
expired | Not finished in time (COMPLETION_TIMEOUT) or abandoned (ABANDONED). | Offer to start again. |
terminal: true means the checks have finished. Only a manual review still
changes a needs_manual_review verification, to verified or rejected
(with a verification.reviewed webhook). Treat any other status you don’t
know as “not decided”.
What the export contains: Reference → The result.