Everything you need to add “Sign in with VisualEyes” to your site: signed API, callback flow, error handling and billing. WordPress users can skip straight to the plugin.
Client ID, a secret (shown once) and your
registered callback origin.Every call to /api/v1/* is a POST with a JSON body and three headers:
X-VE-Client-Id: your client id
X-VE-Timestamp: unix seconds (float is fine), e.g. 1783765000.123
X-VE-Signature: hex HMAC-SHA256, see belowThe signature is an HMAC-SHA256 over the timestamp, a literal dot, and the exact raw request body bytes, keyed with your secret:
signature = hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) )401 bad_signature.$body = json_encode(array(
'user' => $email,
'callback_url' => 'https://yoursite.com/ve-callback',
));
$ts = (string) time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $secret); // $secret from your config
$ch = curl_init('https://aqa.com/api/v1/challenges');
curl_setopt_array($ch, array(
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'X-VE-Client-Id: ' . $client_id,
'X-VE-Timestamp: ' . $ts,
'X-VE-Signature: ' . $sig,
),
));
$res = json_decode(curl_exec($ch), true);const crypto = require("crypto");
const body = JSON.stringify({
user: email,
callback_url: "https://yoursite.com/ve-callback",
});
const ts = String(Date.now() / 1000);
const sig = crypto.createHmac("sha256", secret) // secret from your config
.update(ts + "." + body).digest("hex");
const res = await fetch("https://aqa.com/api/v1/challenges", {
method: "POST",
body,
headers: {
"Content-Type": "application/json",
"X-VE-Client-Id": clientId,
"X-VE-Timestamp": ts,
"X-VE-Signature": sig,
},
}).then(r => r.json());Never expose the secret to the browser — all signed calls are server-to-server. Rotate it any time from your dashboard; rotation invalidates the old secret immediately.
/api/v1/challenges — start a login{
"user": "customer@example.com",
"callback_url": "https://yoursite.com/ve-callback",
"policy": {} // optional per-request override, see below
}callback_url must start with an allow-listed origin for your client — the site address you
registered (additional origins are rolling out in the dashboard). Otherwise: 400.
policy is optional. Empty/absent uses your site's configured security tier. The
high-assurance tier is:
{"rounds": 6, "catch_rounds": 2, "images": 8, "max_misses": 0, "max_skips": 1}Responses:
200 {"enrolled": false}
// unknown user -> send them to the warm invite page: https://aqa.com/invite
200 {"enrolled": true,
"challenge_url": "https://aqa.com/c/<token>",
"expires_at": 1783765300.0,
"pool": {"live_images": 14, "pool_low": false}}
// redirect the user's browser to challenge_urlError responses: see the error codes table below (402 out of credit, 409 needs re-enrolment, 423 locked).
When the user finishes the recognition challenge (pass or fail), VisualEyes redirects their browser
back to your callback_url with a single-use result token appended:
https://yoursite.com/ve-callback?result=<result_token>
https://yoursite.com/ve-callback?next=/account&result=<result_token> // "&" if you already have a queryThe join is ? normally, & when your callback URL already carries a query
string. The browser never learns the outcome — you must confirm it server-side with
/api/v1/verify. Never grant a session from the redirect alone.
/api/v1/verify — confirm the outcome{"result_token": "<from the callback>"}200 {"passed": true,
"user": "customer@example.com",
"assurance": "VE1",
"duress": false, // true = user signalled coercion; act per your policy
"billed_credits": 15, // present when this login was billed
"balance_credits": 1485,
"stats": { ... }}
200 {"passed": false, "user": "...", ...} // failed challenge — deny the login
404 {"error": "unknown_result"} // bad/expired token
404 {"error": "already_verified"} // result tokens are single-useBilling happens here: 15 credits (~$0.015) are charged per successful login on metered clients. Failed challenges are free. Your own first-party test logins are never billed.
Security note: duress is for your server to act on (e.g. limit the session, alert your
staff). Never surface it — or any challenge internals — to the login browser.
/api/v1/enroll — programmatic enrolment{
"email": "customer@example.com",
"images": [{"data_b64": "...", "mime": "image/jpeg", "category": "uploaded"}],
"generate": {"count": 8, "marker": "..."} // optional generated images (max 32)
}200 {"user": "customer@example.com",
"images_added": 8,
"images_rejected": 0,
"pool": {"live_images": 8, "pool_low": true}}Uploads are screened (deduplication + quality checks) — rejected images are counted, not stored. Accounts cap at 75 live photos. Most integrations skip this endpoint entirely and send unknown users to the warm invite instead, where they enrol with VisualEyes directly.
POST /api/v1/challenges.enrolled:false → redirect them to https://aqa.com/invite (friendly enrol
page); stop here.enrolled:true → redirect the browser to challenge_url. The user proves it's
them by recognising their own photos — nothing to type, nothing to phish.callback_url?result=<token>.POST /api/v1/verify with the token.passed:true → create your session. passed:false → deny. Handle
duress per your policy.| Status | Body | What to do |
|---|---|---|
| 400 | user and callback_url required / callback_url not in registered prefix |
Fix the request — the callback must start with your registered origin. |
| 401 | bad_signature |
Check secret, exact raw-body signing, and clock (±120 s skew). |
| 402 | insufficient_credit |
Your prepaid balance can't cover a login — top up in your dashboard. Your own test logins keep working. |
| 409 | needs_reenrolment + portal_url |
The user's photo pool is too weak. Send them to the VisualEyes portal (the portal_url
given) — never to a client-site login. |
| 423 | locked + retry_after |
The account is temporarily locked after failed attempts. Tell the user to retry later. |
| 404 | (verify) unknown_result / already_verified |
Treat as a failed login. Result tokens are single-use and short-lived. |
402 until you top up; your own
first-party test logins are exempt, so you can always verify your integration.