← VisualEyes for your site

Integration docs

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.

Quickstart (WordPress)

  1. Get your credentials — register your site at /client/register (email + one-time code). You receive a Client ID, a secret (shown once) and your registered callback origin.
  2. Install the plugindownload visualeyes-plugin.zip, upload it in WP Admin → Plugins, and paste your credentials into Settings → VisualEyes.
  3. Done — a “Sign in with VisualEyes” button appears on your login form. Everything below is for integrating on any other stack via the same API the plugin uses.

Authentication & signing

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 below

The 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 ) )
Sign the exact string you send. Re-serializing JSON (different key order, whitespace, unicode escaping) changes the bytes and breaks the signature. The timestamp must be within ±120 seconds of our clock — use real server time (NTP) — or you get 401 bad_signature.

PHP

$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);

Node

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.

Endpoint reference

POST/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_url

Error responses: see the error codes table below (402 out of credit, 409 needs re-enrolment, 423 locked).

The callback

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 query

The 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.

POST/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-use

Billing 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.

POST/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.

The login flow, end to end

  1. User clicks Sign in with VisualEyes on your site and enters their email.
  2. Your server signs and sends POST /api/v1/challenges.
  3. enrolled:false → redirect them to https://aqa.com/invite (friendly enrol page); stop here.
  4. 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.
  5. VisualEyes redirects back to your callback_url?result=<token>.
  6. Your server signs and sends POST /api/v1/verify with the token.
  7. passed:true → create your session. passed:false → deny. Handle duress per your policy.

Error codes

StatusBodyWhat to do
400user and callback_url required / callback_url not in registered prefix Fix the request — the callback must start with your registered origin.
401bad_signature Check secret, exact raw-body signing, and clock (±120 s skew).
402insufficient_credit Your prepaid balance can't cover a login — top up in your dashboard. Your own test logins keep working.
409needs_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.
423locked + 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.

Billing

Why VisualEyes

Get your credentials → Open your dashboard → Talk to us