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:
| Surface | Use 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. |
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"
}
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.
| Scope | Allows |
|---|---|
verifications:read | Fetch and list verifications |
verifications:write | Create and cancel verifications |
applicants:read / applicants:write | Read or create applicants |
checks:write | Run a direct face match |
screening:read / screening:write | Read or run screenings and monitors |
events:read / events:write | Read events, replay a webhook |
usage:read | Read 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
| Field | Type | Notes |
|---|---|---|
applicant | object | Required unless applicant_id is given. Accepts external_id, first_name, last_name, dob, nationality (ISO-3), email, phone. |
applicant_id | string | Re-verify an existing applicant. |
checks | array | document, liveness, face_match, screening. Defaults to all four. |
redirect_url | string | Where to send the applicant when they finish. |
metadata | object | Returned verbatim on every response and webhook. Use it to carry your own IDs. |
The full result, including every check and the reasons behind the decision.
List, newest first. Filter with status, decision, applicant_id, created_after.
Abandon a session the applicant never completed. The link stops working immediately.
Decisions & reasons
A completed verification carries exactly one decision.
| Decision | Meaning | What 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 code | Raised when |
|---|---|
name_mismatch | The name you declared is not the name on the document. |
liveness_below_threshold | The challenge did not demonstrate a live person. |
face_match_below_threshold | The selfie and the document portrait are not the same face. |
no_face_in_selfie / no_face_in_document | One of the images has no usable face. Usually a retake fixes it. |
document_expired | The expiry date on the document has passed. |
document_authenticity_suspect | The document contradicts itself — a printed field disagreeing with a check-digit-verified MRZ, for example. |
screening_potential_match | The name matched a sanctions, PEP or watchlist entry above threshold. |
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
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.
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
}'
| Field | Notes |
|---|---|
reference_image | The document portrait. Send the whole document page — the engine finds the face more reliably than a crop does. |
probe_image | The selfie. |
threshold | Override the policy threshold for this call. |
check_liveness | Also assess the selfie. See the caveat under Engines. |
store_result | Attach 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.
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"
}
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.
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.
{ "applicant_id": "app_01kyp4rkgpxq2s7v1x8y3n5m0q" }
Webhooks
Register an endpoint in the console. We deliver these events:
| Event | Fires when |
|---|---|
verification.completed | All checks finished and a decision was reached. |
verification.reviewed | A human resolved a case that was in review — either your compliance team, or ours under managed review. |
screening.hit_found | A monitored person newly matched a list. |
Headers
Identity-Signature | t=1785309347,v1=4e58606f… |
Identity-Event-Id | Stable per event — use it to deduplicate. |
Identity-Event-Type | e.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-Version | The 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.
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:
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"
}
}
| Status | Meaning |
|---|---|
400 | Malformed request. |
401 | Missing, revoked or unknown API key. |
403 | The key lacks the scope for this endpoint. |
404 | No such object, or it belongs to another tenant. |
422 | Validation failed. message names the field. |
429 | Rate limited. See Retry-After. |
5xx | Our 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
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.
| Prefix | Object |
|---|---|
app_ | Applicant |
ver_ | Verification |
doc_ | Stored image |
scr_ | Screening |
evt_ | Webhook event |
key_ | API key |
ls_ | Watchlist snapshot |
Statuses
| Status | Meaning |
|---|---|
created | Session exists, the applicant has not started. |
in_progress | The applicant is partway through. |
processing | Submitted; checks are running. |
completed | Finished. Read decision. |
expired | The link lapsed before completion. |
cancelled | You 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.
| Check | Engine |
|---|---|
| Document reading | Vision 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 match | AWS Rekognition CompareFaces, Frankfurt. |
| Liveness | Randomised head-turn challenge, validated server-side from the captured frames. |
| Screening | Self-hosted consolidated sanctions, PEP and watchlist data with Arabic-aware name matching. |
eu-central-1). Images and biometric data are purged on separate retention
clocks, both configurable per customer.