Verificate API
For the products that verify their customers. Everything here is called from a product’s server. The customer’s browser gets two values and nothing else: the link, and the client secret for the widget.
Base address: https://api.verificate.io. Version of this reference: 2026-09-29.
- Keys
- A verification from start to end
- Sessions
- What can be asked of a customer
- The session object
- Timing
- Checks and reasons
- Webhooks
- Test mode
- Errors
- Limits
Keys
Every request carries an API key:
Authorization: Bearer vk_live_…
Keys are made at the review desk, under Settings, on the tenant’s API keys tab. A key is shown once. Keep it on the server: a key in a web page or an app can be read by anyone.
| Key starts with | Sessions it creates |
|---|---|
vk_live_ |
Checked for real |
vk_test_ |
Run through the same steps, decided by a mock. See Test mode |
A live key cannot see test sessions, and a test key cannot see live ones.
A verification from start to end
-
Your server creates a session and says what it needs.
curl https://api.verificate.io/v1/sessions \ -H "Authorization: Bearer $VERIFICATE_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-8841" \ -d '{ "reference": "signup-8841", "requirements": [ { "type": "id_document", "accept": ["id_card", "passport"] }, { "type": "selfie" }, { "type": "video_statement" } ], "expected": { "first_name": "Jane", "last_name": "Doe" }, "locale": "en", "max_completion_seconds": 900 }' -
The answer holds
url,qr_svgandclient_secret. Your signup page shows the code, with the widget or on its own. You can also sendurlto the customer by message. -
The customer opens the link on their phone, gives consent, and does each step with the camera.
-
When everything is submitted, the checks run. Your server is told by webhook:
session.approved,session.rejectedorsession.needs_review. -
Your server reads the session for the details: what was read from the documents, what each check found, and how long each part took.
Sessions
Create a session
POST /v1/sessions
| Field | ||
|---|---|---|
requirements |
required | What to ask of the customer, in order. 1 to 6 entries. See below |
reference |
Your own identifier for the signup, up to 200 characters. Returned on every event | |
expected |
What you already know about the customer. It is compared with what is read. See below | |
locale |
en, he or es. The language shown first. The customer can change it. Default en |
|
expires_in |
Seconds until the link stops working. 300 to 2,592,000 (30 days). Default 86,400 (a day) | |
max_completion_seconds |
Seconds the customer has from opening the link to submitting. 60 to 86,400. Left out, there is no limit but the link’s own | |
return_url |
Where the phone goes when the customer has finished. https only |
|
webhook_url |
Sends this session’s events here in place of the tenant’s endpoint. https only, and the host must be on the tenant’s list of allowed hosts |
|
metadata |
Up to 20 pairs of your own. Keys up to 40 characters, values up to 500. Do not put personal data here: it is shown to everyone at the review desk |
Answers 201 with the session and three more fields:
| Field | |
|---|---|
url |
The link for the customer’s phone. The part after # is the secret, and is never sent to a server by a browser that only loads the page |
qr_svg |
A QR code of url, as an SVG document |
client_secret |
For the widget. It gives progress and nothing else, and can be given to the browser |
Doing it twice by accident. Send an Idempotency-Key header, 1 to 255
printable characters. The same key with the same body answers 200 with the
session that was made the first time. The same key with another body answers
409, code idempotency_key_reused.
Read a session
GET /v1/sessions/{id}
The session, with extracted and checks. While the
customer can still use the link, url, qr_svg and client_secret are there
too, so that the code can be shown again.
List sessions
GET /v1/sessions
| Parameter | |
|---|---|
status |
One status |
reference |
Exactly this reference |
created_after, created_before |
Moments, such as 2026-09-29T00:00:00Z |
limit |
1 to 100. Default 20 |
starting_after |
The id of the last session of the page before |
Newest first. The answer is { "object": "list", "data": [...], "has_more": true }.
Sessions in a list come without extracted and without checks.
Cancel a session
POST /v1/sessions/{id}/cancel
For a session the customer has not submitted yet. The link stops working.
Answers 409 for a session that was submitted.
Let the customer try again
POST /v1/sessions/{id}/reopen
{ "steps": [0], "expires_in": 86400 }
For a session that was rejected or waits for review. The steps named are
opened again, on the same link. With steps empty or left out, every step
that did not pass is opened. The decision is withdrawn, and the checks run
again when the customer submits. The answer holds url, qr_svg and
client_secret.
Erase a session
POST /v1/sessions/{id}/redact
Deletes the files, what was read from them, what you expected, the results of the checks and the reviewers’ notes. It cannot be undone. What remains is the record that the session took place, when, and how it was decided. Use it to answer a customer’s request to be erased.
Answers 409 with code legal_hold when the session is held at the review
desk, and with code processing while the checks are running.
Download a file
GET /v1/sessions/{id}/artifacts/{artifact_id}
The file as it was received. Every download is written to the audit log with the key that made it.
What can be asked of a customer
Each entry of requirements has a type and its own settings. Pictures and
recordings are taken with the camera on the page. Only a proof of address may
be a file the customer already has.
id_document
accept |
Kinds the customer may choose from: passport, id_card, driving_license, residence_permit. Default: all |
countries |
Issuing countries that are accepted, as two-letter codes. Left out, any country |
The front is photographed, and the back for every kind but a passport.
proof_of_address
accept |
utility_bill, telecom_bill, bank_statement, government_letter, lease_agreement. Default: all |
max_age_days |
How old the document may be. 1 to 3650. Default 90 |
allow_upload |
Whether a PDF or a picture may be uploaded, as well as photographed. Default true |
Up to five pages. At most two such steps in a session.
selfie
head_turns |
true: after the still, the customer is asked to turn their head three times in an order chosen at that moment, and a short clip is recorded; a ring around the face fills as each turn is seen. false: a few frames taken around the still show whether anything moved. Left out: the tenant’s own setting at the review desk decides (off unless changed) |
Head turns are harder to fake than a still or a burst: a photograph cannot turn, and a recording cannot follow an order it did not know. They take the customer ten seconds more, and the page fetches a few megabytes to follow the face, so ask for them where the risk warrants it.
selfie_with_document
holding |
id_document (default): the identity document of this session. custom: something else, which instructions describes |
instructions |
What to hold, in your own words, 3 to 300 characters. Required when holding is custom. Shown to the customer as it is, so write it in their language |
For businesses that ask for a picture of the customer holding their card.
video_statement
sentence |
"random" (default): a sentence made up when the customer presses record. Or { "text": "…", "append_random_code": true } for wording that your rules prescribe |
language |
en, he or es. Default: the language the customer is using |
min_seconds, max_seconds |
Length of the recording. Defaults 3 and 15. Limits 2 to 30, and 3 to 60 |
hold_document |
Ask the customer to hold their identity document while they speak. Default false |
The sentence is shown only when recording starts, and the recording must
arrive soon after. A recording made earlier cannot contain the sentence. With
fixed wording, keep append_random_code on: it adds digits to the end, for the
same reason.
expected
first_name, last_name |
Or full_name, when you hold the name as one field |
date_of_birth |
YYYY-MM-DD |
document_number |
|
nationality |
Two-letter country code |
address |
line1, line2, city, region, postal_code, country |
Names are compared with their spelling in other scripts in mind: a name printed in Arabic, Cyrillic or Greek letters matches its Latin spelling. A difference sends the session to review. It never rejects on its own.
The session object
{
"id": "vs_7X11LVCKb7GxwD5j6GMZHn",
"object": "verification_session",
"api_version": "2026-09-29",
"mode": "live",
"status": "approved",
"reference": "signup-8841",
"locale": "en",
"requirements": [{ "type": "id_document", "accept": ["id_card", "passport"] }, { "type": "selfie" }],
"steps": [
{
"index": 0,
"type": "id_document",
"status": "completed",
"verification": "passed",
"document_type": "id_card",
"document_country": "NL",
"attempts": 1,
"started_at": "2026-09-29T09:41:53.000Z",
"submitted_at": "2026-09-29T09:42:31.000Z",
"duration_ms": 38000,
"retry_reasons": [],
"challenge": null,
"artifacts": [
{
"id": "art_5D8e1n4F3t7ab82TEsv4vg",
"kind": "document_front",
"seq": 0,
"content_type": "image/jpeg",
"size": 1482113,
"capture_method": "camera",
"attempt": 1,
"created_at": "2026-09-29T09:42:12.000Z"
}
]
}
],
"timing": {
"created_at": "2026-09-29T09:40:00.000Z",
"first_opened_at": "2026-09-29T09:40:42.000Z",
"started_at": "2026-09-29T09:40:50.000Z",
"consented_at": "2026-09-29T09:41:05.000Z",
"submitted_at": "2026-09-29T09:43:06.000Z",
"decided_at": "2026-09-29T09:43:14.000Z",
"time_to_open_ms": 42000,
"open_to_submit_ms": 144000,
"total_ms": 186000,
"processing_ms": 8000
},
"decision": {
"outcome": "approved",
"source": "auto",
"decided_at": "2026-09-29T09:43:14.000Z",
"reason_codes": [],
"risk_score": 0.04
},
"extracted": {
"document": {
"type": "id_card",
"country": "NL",
"first_name": "JANE",
"last_name": "DOE",
"full_name": "JANE DOE",
"date_of_birth": "1990-05-17",
"sex": "F",
"nationality": "NL",
"document_number": "012345678",
"personal_number": null,
"issue_date": "2021-03-02",
"expiry_date": "2031-03-01",
"address": null
},
"proof_of_address": []
},
"checks": [],
"metadata": {},
"return_url": null,
"customer_country": "NL",
"created_at": "2026-09-29T09:40:00.000Z",
"expires_at": "2026-09-30T09:40:00.000Z",
"retention_until": "2031-09-28T09:40:00.000Z",
"redacted": false
}
Status
status |
|
|---|---|
created |
The link exists. Nobody has opened it |
opened |
The page has loaded on a device that can capture |
in_progress |
The customer has started |
processing |
Everything is submitted and the checks are running |
approved |
|
rejected |
|
needs_review |
A person at the review desk will decide. session.approved or session.rejected follows |
expired |
The link ran out, or the time allowed to finish did |
canceled |
A session can go from processing back to in_progress. That happens when a
step failed for a reason the customer can fix, such as a blurred photo: the
customer is asked to do that step again on the same link, and
session.retry_requested is sent. How often this happens before the session
is rejected is a setting of the tenant, two times unless changed.
steps[].verification is pending, passed, failed or needs_review.
decision.source is auto for the checks and reviewer for a person.
Timing
Every moment is taken on the server.
| Field | From | To |
|---|---|---|
time_to_open_ms |
The session being created | The page opening on a device that can capture |
open_to_submit_ms |
The page opening | The customer submitting the last step |
total_ms |
The session being created | The customer submitting the last step |
processing_ms |
Submission | The decision |
steps[].duration_ms |
The latest start of the step | Its submission |
A link opened on a computer shows a code to carry it to a phone. That does not count as opening: the clock stops when a device that can capture loads the page.
On video steps, and on selfie steps with head turns, challenge holds what
kind of challenge was given (kind: sentence or head_turns), when it was
shown (issued_at), until when it was valid (expires_at), and when the
recording arrived (received_at). language is that of the sentence, and null
for head turns. The sentence and the order of turns are not in the API.
Reviewers see them at the review desk.
Checks and reasons
checks holds what each check found in the latest run:
{
"id": "chk_…",
"type": "face_match",
"step_index": 1,
"status": "review",
"score": 0.38,
"reason_codes": ["face_match_uncertain"],
"summary": "The face may match the portrait on the identity document, but not clearly.",
"details": { "portrait_found": true, "portrait_width": 96, "face_found": true },
"provider": "inference",
"model": "…",
"started_at": "2026-09-29T09:43:07.000Z",
"finished_at": "2026-09-29T09:43:09.000Z"
}
status is pass, fail, review, error or skipped. A check that could
not run (error) sends the session to review. It never approves and never
rejects.
Reason codes come in two kinds.
Facts. The customer can fix them by trying again, and they are the only reasons a session is rejected without a person:
document_expired, document_type_not_accepted,
document_country_not_accepted, document_unreadable,
document_side_missing, not_a_document, poa_too_old,
poa_type_not_accepted, poa_unreadable, face_not_found, multiple_faces,
face_quality_low, document_not_held, no_audio, speech_mismatch,
video_too_short, video_too_long, challenge_expired, media_unreadable,
under_minimum_age.
Judgements. Something the checks suspect. They send the session to a reviewer, who may reject with them:
face_mismatch, face_match_uncertain, spoof_suspected,
synthetic_media_suspected, screen_recapture_suspected,
document_copy_suspected, no_motion, head_turns_not_followed,
lip_sync_mismatch,
tampering_suspected, provenance_ai_generated, provenance_edited,
duplicate_media, mrz_checksum_failed, mrz_mismatch, name_mismatch,
date_of_birth_mismatch, address_mismatch, document_number_mismatch,
cross_document_name_mismatch, held_document_mismatch,
virtual_camera_suspected, automation_suspected, device_changed,
timing_anomaly, extraction_low_confidence, check_failed_to_run.
Do not show judgement codes to the customer. They describe suspicion, and telling a fraudster what gave them away helps them the next time.
Webhooks
Set the endpoint at the review desk, under Settings, on the tenant’s Webhooks tab, and issue a signing secret there.
| Event | When |
|---|---|
session.opened |
The link was opened on a device that can capture |
session.started |
The customer pressed start |
session.step_completed |
A step was submitted. data.step_index says which |
session.submitted |
Everything was submitted |
session.retry_requested |
A step failed for a reason the customer can fix, and they were asked to do it again |
session.approved |
|
session.rejected |
|
session.needs_review |
A person will decide |
session.expired |
|
session.canceled |
|
session.redacted |
The session was erased |
{
"id": "evt_Ky68rWq0PaL3mVb2cTx9Zd",
"object": "event",
"api_version": "2026-09-29",
"type": "session.approved",
"created": "2026-09-29T09:43:14.000Z",
"livemode": true,
"data": { "session": { "id": "vs_…", "status": "approved", "reference": "signup-8841" } }
}
data.session is the session without extracted and
without checks. Events carry no personal data beyond what you sent when you
created the session. Read the session for the rest.
Answering
Answer with any 2xx status within 10 seconds. Do the work afterwards.
Anything else is tried again: after 10 seconds, 30 seconds, 2 minutes, 10
minutes, 30 minutes, 1 hour, 3 hours and 6 hours. That is nine attempts over
about eleven hours. 410 Gone stops the attempts at once. Redirects are not
followed.
The same event can arrive more than once, and events can arrive out of order.
Remember the id of the events you have handled, and take the state of the
session from data.session.status of the newest event, by created.
Checking the signature
Every request carries:
Verificate-Signature: t=1790761394,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verificate-Event-Id: evt_Ky68rWq0PaL3mVb2cTx9Zd
Verificate-Delivery-Id: whd_…
v1 is the HMAC-SHA256, in hexadecimal, of <t>.<body> with your signing
secret, where <body> is the request body exactly as it arrived.
- Take
tandv1from the header. - Refuse when
tis more than five minutes from now. - Compute the HMAC over
t, a full stop, and the raw body. - Compare with
v1, in constant time.
Check before you parse. A body that was parsed and written out again is not the body that was signed.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
// Express: the raw body, not the parsed one.
app.post("/hooks/verificate", express.raw({ type: "application/json" }), (request, response) => {
const body = request.body.toString("utf8");
if (!verify(process.env.VERIFICATE_WEBHOOK_SECRET, body, request.get("Verificate-Signature") ?? "")) {
return response.sendStatus(400);
}
response.sendStatus(204);
handle(JSON.parse(body));
});
Python:
import hashlib
import hmac
import re
import time
def verify(secret: str, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool:
parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
timestamp, signature = parts.get("t", ""), parts.get("v1", "")
if not timestamp.isdigit() or not re.fullmatch(r"[0-9a-f]{64}", signature):
return False
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
PHP:
function verify(string $secret, string $rawBody, string $header, int $toleranceSeconds = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$name, $value] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$name] = $value;
}
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
if (!ctype_digit($timestamp) || !preg_match('/^[0-9a-f]{64}$/', $signature)) {
return false;
}
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false;
}
return hash_equals(hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret), $signature);
}
Replacing the signing secret
A new secret replaces the old one at once. Until your server has the new one it will refuse the events it receives, and they will be tried again for about eleven hours. Have the new secret in place within that time and nothing is lost.
Test mode
Sessions created with a vk_test_ key run through the same steps, send the
same events, and are decided by a mock that looks at nothing. Say what the
outcome should be:
{ "requirements": [{ "type": "selfie" }], "metadata": { "test_outcome": "needs_review" } }
test_outcome |
|
|---|---|
approved |
Default |
rejected |
Rejected, with media_unreadable |
needs_review |
Sent to the review desk, where it can be decided by hand |
retry |
The first step fails for a fixable reason on the first run and passes on the second |
Test sessions never reach the AI that checks live sessions, and what is “read” from documents is made up.
Errors
{
"error": {
"type": "invalid_request",
"message": "The request is not valid.",
"code": "validation_failed",
"issues": [{ "path": "requirements.0.accept.1", "message": "Invalid option" }]
}
}
| Status | type |
|
|---|---|---|
| 400 | invalid_request |
Something about the request is wrong. issues says what, field by field |
| 401 | authentication_error |
The key is missing, mistyped or revoked |
| 403 | permission_error |
The tenant is disabled |
| 404 | not_found |
No such session, or a session of another tenant or mode |
| 409 | conflict |
The session is not in a state that allows this. code says which |
| 413 | payload_too_large |
|
| 429 | rate_limited |
Wait for the time in Retry-After, then try again |
| 500 | api_error |
Our fault. Trying again is safe with an Idempotency-Key |
code is for programs and stays the same between releases. message is for
people and may change.
Limits
| Requests | 600 a minute for each key |
| Steps in a session | 6 |
| Attempts at a step | 5 |
| Pictures | 12 MB |
| Proof of address as a file | 15 MB, up to 5 pages |
| Recordings | 60 MB, up to 60 seconds |
| Body of a request | 64 KB |