Identity API

Verify that a person is who they claim to be, and that they are not on a sanctions or PEP list — from one API call. Document reading, liveness, face matching and screening return a single decision your system can act on.

There are three ways to use the platform, and most integrations use the first:

SurfaceUse it when
Hosted flow You create a verification, we return a link. Send it by SMS or email, or open it in a WebView inside your app. We handle camera, cropping, the liveness challenge and consent. You get a webhook when it finishes. No image ever touches your servers, which keeps biometric data out of your compliance scope.
Direct checks You already hold the images, or you only need one check — a face comparison, or a name screened against sanctions lists.
Compliance console Your compliance team reviews flagged cases, dispositions sanctions hits, and pulls the evidence pack for an audit. No integration work.
Base URL https://api.toolzy.net/api/v1 — all requests over HTTPS.

Quickstart

A working verification in three calls.

1. Create the verification

curl -X POST https://api.toolzy.net/api/v1/verifications \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "applicant": {
      "first_name": "Islam",
      "last_name": "Inshassi",
      "email": "islam@example.com"
    },
    "checks": ["document", "liveness", "face_match", "screening"],
    "metadata": { "your_user_id": "12345" }
  }'
{
  "id": "ver_01kyp4rkgqmetdde62tj0464bg",
  "object": "verification",
  "status": "created",
  "applicant_id": "app_01kyp4rkgpxq2s7v1x8y3n5m0q",
  "required_checks": ["document", "liveness", "face_match", "screening"],
  "hosted_url": "https://verify.toolzy.net/s/uH2TMcoYdcADBy4BqMvWwkBJK4pmJqXT",
  "expires_at": "2026-07-30T07:21:44+00:00"
}
The declared name matters We compare the name you send against the name on the document. If they disagree the verification returns review with a name_mismatch reason rather than approving. Send the name you actually hold for that person — if you have none, omit it.

2. Send the applicant to hosted_url

Open it in a WebView, or send the link. It works on any modern mobile browser and expires after 24 hours by default. Pass redirect_url when creating the verification and we will return the applicant to it when they finish.

3. Receive the result

Register a webhook endpoint and we will POST the finished verification to it. You can also poll GET /verifications/{id}.

{
  "id": "ver_01kyp4rkgqmetdde62tj0464bg",
  "object": "verification",
  "status": "completed",
  "decision": "approved",
  "checks": {
    "document": {
      "result": "pass",
      "type": "passport",
      "issuing_country": "PSE",
      "number": "62•••64",
      "expiry": "2030-02-15",
      "mrz": { "present": true, "checksum_valid": true },
      "extracted": {
        "surname": "INSHASSI",
        "given_names": "ISLAM M. M.",
        "dob": "1979-11-28",
        "gender": "male"
      }
    },
    "liveness": {
      "result": "pass",
      "score": 0.8499,
      "threshold": 0.8,
      "mode": "active_challenge",
      "spoof_signals": []
    },
    "face_match": {
      "result": "pass",
      "score": 1.0,
      "threshold": 0.85,
      "reference": "id_document"
    },
    "screening": {
      "result": "clear",
      "hits": 0,
      "highest_severity": null,
      "list_snapshot": "ls_2026-07-28_220642",
      "categories": []
    }
  },
  "reasons": [],
  "policy_version": "v1",
  "list_snapshot": "ls_2026-07-28_220642",
  "completed_at": "2026-07-29T07:22:10+00:00"
}

Authentication

Every request carries an API key as a bearer token. Keys are shown once, at creation, and stored only as a hash — we cannot recover one for you, so a lost key is replaced rather than retrieved.

Authorization: Bearer sk_test_14KomlitgRtgcXZWPnSdEPsE

Environments

The key prefix decides the environment. sk_test_ keys never bill and never touch live watchlist decisions; sk_live_ keys do both. Test and live data are separated at the database level, not by a flag on the row.

Scopes

Each key carries only the scopes you grant it, so a leaked read key cannot start a verification.

ScopeAllows
verifications:readFetch and list verifications
verifications:writeCreate and cancel verifications
applicants:read / applicants:writeRead or create applicants
checks:writeRun a direct face match
screening:read / screening:writeRead or run screenings and monitors
events:read / events:writeRead events, replay a webhook
usage:readRead usage counters

Versioning

The API is versioned by date. A key is pinned to the version it was created with, and keeps receiving that response shape until you opt in to a newer one — a breaking change on our side never arrives unannounced in your production traffic.

X-API-Version: 2026-07-28

Send the header to override the key's pin for a single request. Every response echoes the version that served it. Adding a field is not a breaking change and can happen within a version, so parse defensively and ignore what you do not recognise.

Current version: 2026-07-28. Supported: 2026-07-28.

Verifications

POST /v1/verifications
FieldTypeNotes
applicantobjectRequired unless applicant_id is given. Accepts external_id, first_name, last_name, dob, nationality (ISO-3), email, phone.
applicant_idstringRe-verify an existing applicant.
checksarraydocument, liveness, face_match, screening. Defaults to all four.
redirect_urlstringWhere to send the applicant when they finish.
metadataobjectReturned verbatim on every response and webhook. Use it to carry your own IDs.
GET /v1/verifications/{id}

The full result, including every check and the reasons behind the decision.

GET /v1/verifications

List, newest first. Filter with status, decision, applicant_id, created_after.

POST /v1/verifications/{id}/cancel

Abandon a session the applicant never completed. The link stops working immediately.

Decisions & reasons

A completed verification carries exactly one decision.

DecisionMeaningWhat to do
approved Every required check passed the policy in force. Proceed. Nothing needs a human.
review Something needs a person: a name that does not match, a possible sanctions hit, a document that reads oddly. Hold the account. Your compliance team resolves it in the console; you get a second webhook when they do.
declined A check failed outright — a spoofed selfie, a face that does not match the document. Do not proceed. Offer a retry if you believe it was a capture problem.

reasons is a list, and every entry names the check that produced it. These are written to be shown to a compliance officer, not parsed into an excuse.

"reasons": [
  {
    "code": "name_mismatch",
    "message": "The declared name \"John Smith\" does not closely match \"ISLAM M. M. INSHASSI\" on the document.",
    "check": "name_consistency"
  }
]
Reason codeRaised when
name_mismatchThe name you declared is not the name on the document.
liveness_below_thresholdThe challenge did not demonstrate a live person.
face_match_below_thresholdThe selfie and the document portrait are not the same face.
no_face_in_selfie / no_face_in_documentOne of the images has no usable face. Usually a retake fixes it.
document_expiredThe expiry date on the document has passed.
document_authenticity_suspectThe document contradicts itself — a printed field disagreeing with a check-digit-verified MRZ, for example.
screening_potential_matchThe name matched a sanctions, PEP or watchlist entry above threshold.
Scores always arrive with their threshold A bare 0.79 cannot be explained to a regulator, and gives you no way to notice that we moved the bar. Every score in this API is returned next to the threshold it was judged against, and every decision records the policy version that set it.

Applicants

POST /v1/applicants
GET /v1/applicants/{id}
GET /v1/applicants

An applicant is the person; a verification is one attempt to verify them. Re-verifying annually, or after a change of document, creates a new verification against the same applicant so the history stays in one place. Pass your own identifier as external_id and we will match on it.

Face match

Compare two faces directly, when you already hold both images.

POST /v1/checks/face-match
curl -X POST https://api.toolzy.net/api/v1/checks/face-match \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "reference_image": "data:image/jpeg;base64,/9j/4AAQ...",
    "probe_image": "data:image/jpeg;base64,/9j/4AAQ...",
    "check_liveness": true
  }'
FieldNotes
reference_imageThe document portrait. Send the whole document page — the engine finds the face more reliably than a crop does.
probe_imageThe selfie.
thresholdOverride the policy threshold for this call.
check_livenessAlso assess the selfie. See the caveat under Engines.
store_resultAttach the result to an applicant for the audit trail.
{
  "object": "face_match",
  "match": { "result": "pass", "score": 1.0, "threshold": 0.85 },
  "liveness": { "result": "pass", "score": 0.91, "mode": "heuristic" },
  "engine": { "provider": "aws_rekognition", "version": "rekognition-eu-central-1" }
}

Sanctions & PEP screening

Screen a name against 1,477,520 entities — global sanctions, politically exposed persons, debarment and law-enforcement lists, refreshed daily. Arabic and Latin script are matched against each other, so a name written in Arabic finds a Latin-script listing and the reverse.

POST /v1/screenings
curl -X POST https://api.toolzy.net/api/v1/screenings \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Vladimir Putin",
    "dob": "1952-10-07",
    "nationality": "RUS"
  }'
{
  "id": "scr_01kypaxvnrgg6eg6w6yeg40646",
  "object": "screening",
  "result": "potential_match",
  "query_name": "Vladimir Putin",
  "hits": 7,
  "highest_severity": "critical",
  "threshold": 0.85,
  "list_snapshot": "ls_2026-07-28_220642",
  "screened_at": "2026-07-29T07:02:53+00:00"
}
Send the date of birth and nationality if you have them They are the difference between a usable alert queue and an unusable one. A common name with no corroborating detail matches many people; the same name with a date of birth usually matches one or none.

result is clear, potential_match or confirmed_match. Matched individuals' details are deliberately not returned to the API — that is case data for the compliance console, not something to broadcast into an application log. The API tells you a decision is needed; the console is where the decision is made and recorded.

GET /v1/screenings/{id}

Ongoing monitoring

A person who was clear when you onboarded them can be designated tomorrow. Register a monitor and we re-screen them against every new list version, raising screening.threshold when something appears. Entries your team has already dismissed stay dismissed, so the same false positive does not return every day.

POST /v1/monitors
{ "applicant_id": "app_01kyp4rkgpxq2s7v1x8y3n5m0q" }

Webhooks

Register an endpoint in the console. We deliver these events:

EventFires when
verification.completedAll checks finished and a decision was reached.
verification.reviewedA human resolved a case that was in review — either your compliance team, or ours under managed review.
screening.hit_foundA monitored person newly matched a list.

Headers

Identity-Signaturet=1785309347,v1=4e58606f…
Identity-Event-IdStable per event — use it to deduplicate.
Identity-Event-Typee.g. verification.completed
Subscribe to the exact strings above — an endpoint listening for a name we do not send simply never fires, and that failure is silent on both sides.
Identity-Payload-VersionThe payload shape version.

Verifying the signature

HMAC-SHA256 over "{timestamp}.{raw body}", keyed with your endpoint secret. The timestamp is signed along with the body, which is what stops a captured approved event being replayed at you forever.

Verify against the raw body Decoding and re-encoding the JSON changes the bytes and the signature will not match. Read the raw request body before your framework parses it.

PHP

function verify(string $header, string $body, string $secret): bool
{
    if (! preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) {
        return false;
    }
    [$timestamp, $provided] = [(int) $m[1], $m[2]];

    if (abs(time() - $timestamp) > 300) {
        return false;                       // too old — treat as a replay
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);

    return hash_equals($expected, $provided);
}

$body = file_get_contents('php://input');
if (! verify($_SERVER['HTTP_IDENTITY_SIGNATURE'] ?? '', $body, $secret)) {
    http_response_code(400);
    exit;
}

Node

const crypto = require('crypto');

function verify(header, body, secret) {
  const m = /t=(\d+),v1=([a-f0-9]+)/.exec(header || '');
  if (!m) return false;

  const [timestamp, provided] = [parseInt(m[1], 10), m[2]];
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}

// express: mount express.raw({ type: 'application/json' }) so req.body stays a Buffer
app.post('/webhooks/identity', (req, res) => {
  if (!verify(req.get('Identity-Signature'), req.body.toString(), secret)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString());
  res.sendStatus(200);              // acknowledge first, process after
});

Python

import hmac, hashlib, re, time

def verify(header: str, body: bytes, secret: str) -> bool:
    m = re.match(r"t=(\d+),v1=([a-f0-9]+)", header or "")
    if not m:
        return False

    timestamp, provided = int(m.group(1)), m.group(2)
    if abs(time.time() - timestamp) > 300:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, provided)

Delivery

Respond 2xx within 10 seconds. Anything else is retried with exponential backoff. An endpoint that keeps failing is disabled and flagged in the console rather than retried forever. Replay any event yourself:

POST /v1/events/{id}/replay
GET /v1/events

Errors

Conventional HTTP status codes, with a machine-readable body.

{
  "error": {
    "type": "invalid_request_error",
    "code": "resource_missing",
    "message": "No applicant found with id 'app_nope'.",
    "request_id": "req_01kypb0rbef1bv1kv9e9w7nbsd"
  }
}
StatusMeaning
400Malformed request.
401Missing, revoked or unknown API key.
403The key lacks the scope for this endpoint.
404No such object, or it belongs to another tenant.
422Validation failed. message names the field.
429Rate limited. See Retry-After.
5xxOur fault. Safe to retry with the same Idempotency-Key.

Quote request_id in any support conversation — it locates the exact request in our logs.

Idempotency

Send an Idempotency-Key on any POST. If the same key arrives again with the same payload, you get the original response back instead of a second verification. A network timeout on your side then costs you nothing.

Idempotency-Key: 7f9c1e2a-4b6d-4a1f-9e3c-2d8b5a0c7e11

Rate limits

120 requests per minute per key by default, raised on request. Exceeding it returns 429 with Retry-After. The limit is per key, so a busy batch job on one key cannot starve your live signup traffic on another.

Usage & billing

GET /v1/usage

Metered counts for the current month, or any period you pass with from and to. Test-mode traffic is metered separately and never billed.

{
  "object": "usage",
  "period": { "from": "2026-07-01T00:00:00+00:00", "to": "2026-07-29T07:00:00+00:00" },
  "environment": "live",
  "meters": {
    "verification": 1204,
    "document": 1204,
    "liveness": 1198,
    "face_match": 1198,
    "screening": 1331
  }
}

verification is the billable unit for a full journey. screening counts standalone screenings and monitor re-screens as well as those inside a verification, so it runs ahead of the others.

Object reference

Every identifier is prefixed, so an ID in a log always says what it is.

PrefixObject
app_Applicant
ver_Verification
doc_Stored image
scr_Screening
evt_Webhook event
key_API key
ls_Watchlist snapshot

Statuses

StatusMeaning
createdSession exists, the applicant has not started.
in_progressThe applicant is partway through.
processingSubmitted; checks are running.
completedFinished. Read decision.
expiredThe link lapsed before completion.
cancelledYou cancelled it.

Engines & evidence

Every decision records the engine versions, the policy version and the watchlist snapshot that produced it. Months later you can reproduce exactly why a person was approved — which is the question an auditor actually asks.

CheckEngine
Document readingVision model with ICAO 9303 MRZ parsing and per-field check-digit verification. Fields proven by a check digit are trusted over printed text, because printed text can be edited in an image and a check digit cannot be satisfied by accident.
Face matchAWS Rekognition CompareFaces, Frankfurt.
LivenessRandomised head-turn challenge, validated server-side from the captured frames.
ScreeningSelf-hosted consolidated sanctions, PEP and watchlist data with Arabic-aware name matching.
What liveness does and does not prove The challenge stops a printed photo or a phone screen held up to the camera, and that is what it is designed for. It is not iBeta-certified presentation attack detection, and it does not claim to stop a virtual camera or a competent deepfake — that requires a certified engine driving the capture on the device. Every result states its mode, and we never label a heuristic result as certified.
Where data is processed Application and database in Frankfurt, Germany; face matching in AWS Frankfurt (eu-central-1). Images and biometric data are purged on separate retention clocks, both configurable per customer.