Verificate
Menu

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

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

  1. 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
      }'
  2. The answer holds url, qr_svg and client_secret. Your signup page shows the code, with the widget or on its own. You can also send url to the customer by message.

  3. The customer opens the link on their phone, gives consent, and does each step with the camera.

  4. When everything is submitted, the checks run. Your server is told by webhook: session.approved, session.rejected or session.needs_review.

  5. 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.

  1. Take t and v1 from the header.
  2. Refuse when t is more than five minutes from now.
  3. Compute the HMAC over t, a full stop, and the raw body.
  4. 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