Client Actions (subject-facing)
These are the routes the subject — the person being verified — calls from their own browser or app. They are authenticated with an ephemeral token: short-lived, scoped to exactly one subject, and carrying only the capabilities you granted when you minted it.
Your backend never hands a bearer token to a subject. It mints an ephemeral token and passes that on. If you use the hosted verification UI, it calls these routes for you.
Identiwise has not launched. This page describes the API as it is built today; the endpoints are not open to the public yet, and the hostnames in the examples do not resolve.
Base URL: https://{tenant}.identiwise.com/api/v1
Auth header: X-Api-Key: <ephemeral token>
What consumes a token use
An upload or re-upload submission consumes one use. Reads — details, messages,
capabilities — never do. The use is reserved atomically before any storage or AI work, and
it is not refunded if that work then fails: a spent use records that a submission was
attempted. A token with max_uses: null is unlimited until it expires; an exhausted token is
fully dead and every route denies it.
| Status | Meaning |
|---|---|
401 | The token is missing, invalid, expired, or exhausted. |
403 | The token is valid but does not carry the capability this route needs — or this submission type's own attempt cap (max_license_attempts / max_selfie_attempts) is spent. |
429 | The tenant's monthly API request quota is spent. The body carries the limit. This is a tenant-level limit, not a token one. |
1. Read the token's capabilities
Endpoint: GET /ephemeral/capability
What this token is allowed to do, and how much of it is left. The hosted UI uses this to decide which steps to render.
{
"can_edit_details": true,
"view_subject_details": true,
"upload_type": "any",
"can_list_reupload": true,
"can_list_need_reply": true,
"can_return_capabilities": true,
"min_age": 18,
"require_face_match": false,
"max_uses": 3,
"use_count": 1,
"max_license_attempts": 2,
"license_use_count": 1,
"max_selfie_attempts": null,
"selfie_use_count": 0,
"workflow_steps": [
{ "step_name": "verify_license", "step_order": 1 },
{ "step_name": "check_selfie", "step_order": 2 }
]
}
A token minted without can_return_capabilities answers
{"can_return_capabilities": false} and nothing else.
The response also echoes a few stored settings that nothing enforces today — the match thresholds among them. Build on the fields documented here and on Ephemeral Tokens; treat the rest as informational.
2. The subject's own details
The details on the subject record are the ground truth the OCR step scores a submitted document against, which is why letting the subject correct their own spelling before they photograph anything measurably improves the outcome.
Read — GET /subject/details
Requires the token capability view_subject_details, otherwise 403.
curl -s "https://acme.identiwise.com/api/v1/subject/details" \
-H "X-Api-Key: $EPHEMERAL_TOKEN"
{
"first_name": "Jane",
"middle_name": "",
"last_name": "Doe",
"license_number": "D1234567",
"license_state": "",
"license_issue_date": null,
"license_expiry_date": null,
"dob": "1994-03-11",
"addr_street": "123 Main St",
"addr_city": "Austin",
"addr_state": "",
"addr_zip": "",
"addr_country": "",
"email": "jane@example.com",
"photo_url": null
}
Write — POST /subject/details
Requires can_edit_details, otherwise 403. Send only the fields that changed. Accepted:
first_name, last_name, dob (or date_of_birth), photo_url, middle_name,
license_number, license_state, license_issue_date, license_expiry_date,
addr_street, addr_city, addr_state, addr_zip, addr_country, email.
{ "first_name": "Jane", "addr_street": "123 Main St" }
{ "status": "updated" }
Both writes happen in one transaction. 404 if the subject was erased while the update was
in flight (nothing is partly applied); 503 under database contention — retry.
3. Upload an image
Endpoint: POST /subject/upload?type=license|selfie|analyze|verify_age
multipart/form-data:
| Field | Type | Required | Description |
|---|---|---|---|
image_file | file | Yes | The image. |
verification_id | string | Depends | Your id for this verification step. Required for selfie, analyze and verify_age; optional for license. Re-using it continues the same submission as a new attempt. |
external_uid | string | No | Your id for the person. Also usable as the submission's continuation key. |
manual | int | No | 1 skips the AI pipeline and files the submission straight into the admin review queue. It approves nothing, and it still consumes a token use. |
The verification runs synchronously: the pipeline result is the response to this request. There are no webhooks — read later state through the admin routes.
Read the verdict from status in the body, not from the HTTP status code. A license or
selfie upload whose pipeline ran answers 200; manual=1, analyze, verify_age and
re-uploads answer 201.
type=license — document check
Document detection → face detection on the card (including the "ghost photo" internal-consistency check) → face match against an approved reference selfie, if the subject has one → OCR scored against the subject's stored details.
- cURL
- 200 — approved
- 200 — parked for review
curl -s -X POST "https://acme.identiwise.com/api/v1/subject/upload?type=license" \
-H "X-Api-Key: $EPHEMERAL_TOKEN" \
-F "image_file=@/path/to/licence.jpg" \
-F "verification_id=order-88120-step-1"
{
"status": "LICENSE_APPROVED",
"score": 100,
"similarity": 0.71,
"face_comparison": "matched",
"attempt": 1
}
{
"status": "LICENSE_PENDING_APPROVAL",
"reason": "Document detection unavailable.",
"attempt": 1
}
status | Meaning |
|---|---|
LICENSE_APPROVED | Approved automatically. Carries score, similarity and face_comparison. |
LICENSE_PENDING_APPROVAL | Parked for a human. Carries reason when there is one to state, and score when OCR ran. |
NO_LICENSE_DETECTED | The image does not contain a document the detector recognises. |
NO_FACE_DETECTED | No face was found on the document. |
ID_NOT_MATCHED | The face on the document did not match the approved reference selfie. Carries similarity. |
SUBJECT_DATA_NOT_FOUND | The subject has no stored details to score the document against. |
ATTEMPT_SUPERSEDED | While this attempt was processing, an administrator decided the submission or a newer attempt replaced it. Whatever decided it stands; this attempt's result was discarded. The upload itself was still recorded. |
MANUAL_REVIEW_REQUESTED | You sent manual=1; the AI pipeline was skipped. |
face_comparison is on every automatic approval and says what the approval is worth:
matched — a face comparison against an approved reference selfie ran and passed on this
attempt; none — a document-check approval, where no reference existed, so the approval
asserts only that the document was detected and its text matches the subject's details. Mint
the token with require_face_match: true if a document-check approval is not good enough for
your flow; that licence then parks for review until one administrator approval anchors the
subject.
similarity is a comparison figure recorded for the reviewer, never the verdict itself — the
verdict is the model's own decision, not a threshold you configure.
similarity is not the same scale on both routesOn type=license it is a similarity (1 - distance): higher is a closer match. On
type=selfie it is the model's raw distance: lower is a closer match. Do not threshold,
chart or log the two through one comparison.
type=selfie — face check
- 200 — approved
- 200 — parked for review
{
"status": "SELFIE_APPROVED",
"similarity": 0.29,
"attempt": 1
}
{
"status": "SELFIE_PENDING_APPROVAL",
"reason": "Reference image has no detectable face.",
"attempt": 1
}
status | Meaning |
|---|---|
SELFIE_APPROVED | Matched the approved reference (the subject's approved document photo, or a selfie an administrator pinned as the basis). |
SELFIE_PENDING_APPROVAL | Parked for a human — including when there is no reference to compare against yet. Carries reason where there is one. |
NO_FACE_DETECTED | No face in the image. |
MULTIPLE_FACES | More than one face in the image. |
ID_NOT_MATCHED | Did not match the reference. Carries similarity. |
ATTEMPT_SUPERSEDED | As above. |
reason — why a submission was parked
A *_PENDING_APPROVAL verdict carries reason when there is something to say. The value is
one of a fixed set:
reason | Meaning |
|---|---|
Multiple faces detected. | More than two faces on the document. |
Internal face mismatch on ID. | The document's secondary photo does not match its primary one. |
Reference image unavailable. | The stored reference could not be retrieved. |
Reference image has no detectable face. | The stored reference is not usable as a face reference. |
Reference face check unavailable. | The face service failed while checking the reference. |
Document detection unavailable. | Document detection could not be reached. |
Face detection unavailable. | Face detection could not be reached. |
Face comparison unavailable. | Face comparison could not be reached. |
Internal face comparison unavailable. | The internal ghost-photo comparison could not be reached. |
Text extraction unavailable. | OCR could not be reached. |
Verification exceeded its time budget. | The run hit its wall-clock budget (150 seconds by default). |
This token requires a face match for approval and no approved reference selfie is on file - an administrator must review this submission. | The token's strict flow demands an identity anchor that does not exist yet. Nothing is down. |
Most of that list is infrastructure. When a dependency is unavailable the submission is
parked with the reason, never rejected and never reported as NO_FACE_DETECTED or
ID_NOT_MATCHED. Treat every one of these as undecided, awaiting a human.
type=analyze — age estimate
Returns an estimated age for the face in the image. It runs no identity comparison and decides nothing.
{
"status": "ANALYZED",
"selfie_id": "0198c3ff-0c18-7d62-9a04-5b1d9f2e6c30",
"attempt": 1,
"age_estimate": 28
}
type=verify_age — age gate
Compares the estimated age against the min_age the token was minted with.
{
"status": "AGE_VERIFICATION_DONE",
"selfie_id": "0198c3ff-0c18-7d62-9a04-5b1d9f2e6c30",
"attempt": 1,
"age_estimate": 24,
"age_verified": true
}
age_verified is false when the token carries no min_age, or when no age could be
estimated. Both routes are an estimate from a photograph — treat them as a signal, not as
proof of age.
Upload failures
| Status | Meaning |
|---|---|
400 | Missing or invalid image_file, missing/invalid ?type=, or verification_id missing for a type that requires it. Nothing is consumed. |
403 | The token's upload_type forbids first uploads, or this type's attempt cap is spent. Nothing is consumed. |
409 | The verification_id / external_uid names an already-approved submission for this subject. An approved submission is final; re-opening it is an administrator action (reject with require_reupload, then re-upload). Also answered when the submission was approved while this upload's storage write was in flight. |
503 | Either the image could not be stored — nothing at all was recorded, retry the whole upload — or, on analyze / verify_age only, the AI service could not be reached. In that second case the image was stored and the row exists (named by selfie_id), the token use is spent, and status is ANALYSIS_UNAVAILABLE or AGE_VERIFICATION_UNAVAILABLE. Nothing is asserted about the person either way. Honour Retry-After. |
analyze and verify_age answer 503 rather than parking for review because they have no
review queue behind them — there is no reviewer to hand the question to.
4. Re-upload a rejected submission
Endpoint: POST /subject/reupload?type=license|selfie&id={uuid}
Available once an administrator rejects a submission with require_reupload. The subject
finds the request in GET /subject/messages.
multipart/form-data: image_file (required), optional comment (posted to the message
thread with the new image), optional manual.
curl -s -X POST "https://acme.identiwise.com/api/v1/subject/reupload?type=license&id=0198c3a1-2d44-7b90-8e21-11c4f0aa9b32" \
-H "X-Api-Key: $EPHEMERAL_TOKEN" \
-F "image_file=@/path/to/better-licence.jpg" \
-F "comment=Retaken without the glare."
{
"status": "license reupload complete",
"ai_result": {
"status": "LICENSE_APPROVED",
"score": 100,
"face_comparison": "matched"
}
}
The re-upload runs the same pipeline as a first upload, so the verdict lives in
ai_result.status — the envelope's own status is a constant. A re-upload is a new attempt
and consumes one token use. 409 if the submission was approved or un-flagged while the new
image was being stored; 400 for a missing or malformed ?type= / ?id= or file.
5. Messages
The subject's side of the conversation with the reviewer.
GET /subject/messages
| Parameter | Description |
|---|---|
filter=reupload | Only submissions awaiting a fresh image. Requires the can_list_reupload capability. |
filter=need_reply | Only submissions awaiting a written reply. Requires can_list_need_reply. |
license_id / selfie_id | One submission's thread. Give at most one. |
{
"licenses": [
{
"license_id": "0198c3a1-2d44-7b90-8e21-11c4f0aa9b32",
"reupload_required": true,
"response_required": false,
"messages": [
{
"author_type": "admin",
"comment_text": "Please retake in better light.",
"created_at": "2026-09-04 09:31:07"
}
]
}
],
"selfies": []
}
POST /subject/messages
| Field | Type | Required | Description |
|---|---|---|---|
comment | string | Yes | The message text. |
license_id | string | One of | The licence submission this is about. |
selfie_id | string | One of | The selfie submission this is about. |
Give exactly one of license_id / selfie_id; both or neither answers 400.
{ "status": "ok", "correspondence_id": "0198c44e-51aa-7ccf-9b70-2d0c8a6e1b93" }
6. Request erasure (Article 17)
Endpoint: POST /subject/request-deletion
The data subject asks for their record to be erased. This lodges the request in the tenant's privacy queue — it does not erase anything by itself; an administrator carries it out.
| Field | Type | Required | Description |
|---|---|---|---|
confirmation | bool | Yes | Must be true. |
reason | string | No | Free text, maximum 4096 bytes. |
One pending request per subject. A repeat while a request is still outstanding answers
200 with the id of the request that already exists, and records nothing new.
- 201 — recorded
- 200 — one already pending
{
"status": "Deletion request recorded",
"subject_id": "0198c2f4-6b31-7c9a-8d55-2f9a1c7b40e1",
"request_id": "0198c31a-77b2-7f04-9c10-6d2ab3e1f550",
"reason": "I no longer use this service",
"confirmed": true
}
{
"status": "Deletion request already pending",
"subject_id": "0198c2f4-6b31-7c9a-8d55-2f9a1c7b40e1",
"request_id": "0198c31a-77b2-7f04-9c10-6d2ab3e1f550",
"confirmed": true
}
400 if confirmation is missing, or reason is not a string or exceeds 4096 bytes.
See Subject Management for the administrator's side of the same queue.