Authentication
Identiwise uses two credentials with deliberately different reach. Your servers hold the long-lived one; the person being verified only ever holds a short-lived, single-subject one.
| Method | Header | Held by | Scope |
|---|---|---|---|
| System / bearer token | Authorization: Bearer <token> | Your backend, or a staff user in the admin console | The whole tenant, gated by role (superuser / admin / view) |
| Ephemeral token | X-Api-Key: <token> | The subject's browser or app | One subject, only the capabilities enabled at mint, for minutes |
Tokens are not being issued yet. The requests below describe the API as it will ship — they are not live endpoints you can call today. Email hello@identiwise.com to be told when access opens.
Every request is addressed to your own tenant hostname — see
Multi-tenant context at the end of this page. The examples use
acme.identiwise.com; substitute your own subdomain.
1. Staff login (two-phase)
POST /api/v1/admin/login authenticates a staff user with email and password. It does not
always return a token.
- If the user has no two-factor authenticator enrolled, the response carries the bearer token directly.
- If the user has 2FA enrolled, the response is a TOTP challenge instead — a
single-use
challenge_tokenvalid for 300 seconds, with at most 5 code attempts. You must complete step 2 to get a token. - If the tenant enforces 2FA and the user has not enrolled, the response is 403. The user enrols in the account portal; there is no way around it from the API.
Your client must handle both shapes.
Step 1 — email and password
- cURL
- PHP
- Python
curl -X POST "https://acme.identiwise.com/api/v1/admin/login" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@yourcompany.com",
"password": "your_secure_password"
}'
$client = new GuzzleHttp\Client(['base_uri' => 'https://acme.identiwise.com/api/v1/']);
$response = $client->post('admin/login', [
'json' => [
'email' => 'admin@yourcompany.com',
'password' => 'your_secure_password',
],
]);
$data = json_decode((string) $response->getBody(), true);
if (isset($data['challenge']) && $data['challenge'] === 'totp') {
$challengeToken = $data['challenge_token']; // single-use, expires_in seconds
} else {
$systemToken = $data['token']; // no 2FA enrolled
}
import requests
BASE = "https://acme.identiwise.com/api/v1"
r = requests.post(f"{BASE}/admin/login", json={
"email": "admin@yourcompany.com",
"password": "your_secure_password",
})
r.raise_for_status()
data = r.json()
if data.get("challenge") == "totp":
challenge_token = data["challenge_token"] # single-use, data["expires_in"] seconds
else:
system_token = data["token"] # no 2FA enrolled
Bearer issued (no 2FA enrolled):
{ "token": "…", "username": "admin", "role": "superuser" }
TOTP challenge (2FA enrolled):
{ "challenge": "totp", "challenge_token": "…", "expires_in": 300 }
Step 2 — the TOTP code
Exchange the challenge and the 6-digit code from the user's authenticator app for the bearer token.
- cURL
- PHP
- Python
curl -X POST "https://acme.identiwise.com/api/v1/admin/login/verify" \
-H "Content-Type: application/json" \
-d '{
"challenge_token": "CHALLENGE_TOKEN_FROM_STEP_1",
"code": "123456"
}'
$response = $client->post('admin/login/verify', [
'json' => [
'challenge_token' => $challengeToken,
'code' => '123456',
],
]);
$systemToken = json_decode((string) $response->getBody(), true)['token'];
r = requests.post(f"{BASE}/admin/login/verify", json={
"challenge_token": challenge_token,
"code": "123456",
})
r.raise_for_status()
system_token = r.json()["token"]
Response:
{ "token": "…", "username": "admin", "role": "superuser" }
Every failure mode — unknown, expired or already-consumed challenge, wrong code, attempts exhausted, wrong tenant — returns the same uniform 401, so the response tells an attacker nothing about which part was wrong.
2. Integration tokens (recommended for servers)
Do not script the login flow above from a server. It is an interactive credential: it needs a human's password, it is subject to per-IP login rate limiting, and under a tenant that enforces 2FA it needs a code from a phone.
For machine-to-machine use, a superuser mints a long-lived API token instead:
curl -X POST "https://acme.identiwise.com/api/v1/admin/api-tokens" \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"description": "billing-service (production)",
"expires_at": "2027-01-31"
}'
| Field | Notes |
|---|---|
description | Optional. String, up to 255 characters. Label it after the system that will hold it — this is what you will read when deciding what to revoke. |
expires_at | Optional. YYYY-MM-DD HH:MM:SS, RFC 3339 YYYY-MM-DDTHH:MM:SSZ, or YYYY-MM-DD (normalised to midnight). Absent or empty means no expiry. |
{
"status": "API token created",
"token": "…",
"expires_at": "2027-01-31 00:00:00",
"description": "billing-service (production)"
}
The plaintext token appears in this response and nowhere else — we store only a fingerprint and a hash of it. If you lose it, revoke the token and mint another.
Manage tokens with GET /api/v1/admin/api-tokens and revoke one with
DELETE /api/v1/admin/api-tokens/{id}. Mint one token per consuming system so that revoking
a compromised one does not take the rest down with it.
Using a bearer token
GET /api/v1/admin/subjects HTTP/1.1
Host: acme.identiwise.com
Authorization: Bearer YOUR_SYSTEM_TOKEN
3. Ephemeral tokens (for the subject's device)
An ephemeral token delegates a narrow, time-boxed permission to one person, so their browser or app can talk to Identiwise directly — you never proxy image bytes through your own servers.
The flow
- Your backend calls
POST /api/v1/ephemeral-tokenswith your bearer token, naming the subject and the capabilities that subject may exercise. - Identiwise returns a token that expires in
expires_in_minutes(default 60). - Your backend hands the token to your frontend — or to the hosted verification UI as
https://acme.identiwise.com/verify/?token=…. - The client sends it as
X-Api-Keyon the subject routes.
Minting a token
- cURL
- PHP
- Python
curl -X POST "https://acme.identiwise.com/api/v1/ephemeral-tokens" \
-H "Authorization: Bearer YOUR_SYSTEM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject_id": "018f3a2b-7c41-7d3e-9a10-6b2c5d8e4f00",
"expires_in_minutes": 30,
"max_uses": 2,
"upload_type": "any",
"can_edit_details": true,
"require_face_match": true
}'
$response = $client->post('ephemeral-tokens', [
'headers' => ['Authorization' => 'Bearer ' . $systemToken],
'json' => [
'subject_id' => '018f3a2b-7c41-7d3e-9a10-6b2c5d8e4f00',
'expires_in_minutes' => 30,
'max_uses' => 2,
'upload_type' => 'any',
'can_edit_details' => true,
'require_face_match' => true,
],
]);
$ephemeralKey = json_decode((string) $response->getBody(), true)['token'];
r = requests.post(f"{BASE}/ephemeral-tokens",
headers={"Authorization": f"Bearer {system_token}"},
json={
"subject_id": "018f3a2b-7c41-7d3e-9a10-6b2c5d8e4f00",
"expires_in_minutes": 30,
"max_uses": 2,
"upload_type": "any",
"can_edit_details": True,
"require_face_match": True,
})
r.raise_for_status()
ephemeral_key = r.json()["token"]
subject_id is the UUID string returned when you created the subject — shard-side ids
(subjects, submissions, tokens, presets, workflows) are UUIDs, while staff users and API
tokens use integer ids.
The response echoes the capabilities the token was actually minted with, so you can assert on them rather than assume:
{
"status": "ephemeral_token_created",
"token": "…",
"expires_at": "2026-09-05 14:32:00",
"max_uses": 2,
"upload_type": "any",
"can_edit_details": true,
"require_face_match": true
}
max_uses — the consumption contract
max_uses counts upload and re-upload submissions only. Reads — fetching details,
listing messages, reading capabilities — never consume a use.
| Value | Meaning |
|---|---|
1 | Single use. One submission and the token is dead. |
n | Allows n submissions, e.g. one retry after a blurry capture. |
omitted / null | Unlimited submissions until expires_at. |
An exhausted token is fully dead: every endpoint denies it, exactly as an expired one does. A use is reserved before any storage or AI cost, so a submission that parks for review still consumes one.
one_time: true is a deprecated alias for max_uses: 1, kept for older integrations. It is
ignored when max_uses is present. Use max_uses in new code.
Using an ephemeral token
The client sends it as X-Api-Key. No bearer token is involved.
POST /api/v1/subject/details HTTP/1.1
Host: acme.identiwise.com
X-Api-Key: YOUR_EPHEMERAL_TOKEN
Content-Type: application/json
{
"first_name": "Alice",
"last_name": "Doe"
}
The same header carries the subject's uploads
(POST /api/v1/subject/upload?type=license), messages, re-uploads and deletion requests. A
client can read what its own token is allowed to do with
GET /api/v1/ephemeral/capability.
Never put a bearer token in client-side code — JavaScript, a mobile app, or anything a user can read. It grants tenant-wide access for as long as it lives. Only ephemeral tokens belong on a device: they are scoped to one subject, expire in minutes, and can be limited to a fixed number of submissions.
Multi-tenant context
Identiwise resolves your tenant from the Host header of the request, before
authentication runs. Your API base URL is your own tenant hostname:
https://{tenant}.identiwise.com/api/v1
So for tenant acme, https://acme.identiwise.com/api/v1/admin/subjects. The same hostname
serves your admin console at /admin/ and the hosted verification UI at /verify/.
There is no shared or default API hostname — a token issued for one tenant is refused on
another tenant's hostname, so the host and the credential must agree. If you route requests
through a proxy, make sure it forwards the Host header unchanged; rewriting it will make
the request resolve to no tenant, or to the wrong one.