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

Integrating programmatically — including as an AI agent? Here is the entire contract.
Base URL https://aqa.com. Auth: every /api/v1/* call is a server-to-server POST, signed HMAC-SHA256(secret, timestamp + "." + raw_body) and carrying X-VE-Client-Id / X-VE-Timestamp / X-VE-Signature — the secret never touches a browser. Three endpoints: /api/v1/challenges (start a login), /api/v1/verify (confirm the one-time result token), /api/v1/enroll (optional). Flow: user clicks the VisualEyes button → your server calls /challenges with the username already typed in your login box → redirect the browser to the returned challenge_url → VisualEyes redirects back to your callback_url?result=TOKEN → your server calls /verifypassed:true means create the session. Always free for your users; you pay ~1.5¢ per successful login. Where to place the button, the copy-paste drop-in, and how an unknown username becomes a registration are all covered below.

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.

Where it goes — beside your username field

Keep your existing login form exactly as it is. Add one VisualEyes button, immediately to the right of your “Enter your username to log into this website” field (Django, WordPress, Rails, whatever you run). The person types their username once and then chooses: enter a password and submit as normal, or click the VisualEyes logo to sign in by recognising their own photos. The button reuses whatever is already in the username box — nothing to retype, and you capture the username either way.

It looks like this:

Username ← click to sign in with photos instead of a password
Users self-select password or VisualEyes on the same form — no redesign, no separate page. It is the fastest way to offer both while you migrate away from passwords.

Copy-paste markup (framework-agnostic)

<!-- your normal login form, unchanged -->
<form method="post" action="/login">
  <label for="id_username">Enter your username to log into this website</label>

  <span style="display:inline-flex;align-items:center;gap:8px">
    <input id="id_username" name="username" autocomplete="username">

    <!-- VisualEyes drop-in: sits to the RIGHT, reuses the username above -->
    <button type="button" id="ve-signin" title="Sign in with VisualEyes"
            aria-label="Sign in with VisualEyes"
            style="display:inline-flex;padding:6px;border:1px solid #d0d7e6;border-radius:10px;background:#fff;cursor:pointer">
      <svg width="26" height="26" viewBox="0 0 48 48" aria-hidden="true"><defs><linearGradient id="veMark" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7fb3ff"/><stop offset="1" stop-color="#9d7bff"/></linearGradient></defs><g fill="#c3cee8"><rect x="3" y="3" width="12" height="12" rx="3"/><rect x="18" y="3" width="12" height="12" rx="3"/><rect x="33" y="3" width="12" height="12" rx="3"/><rect x="3" y="18" width="12" height="12" rx="3"/><rect x="33" y="18" width="12" height="12" rx="3"/><rect x="3" y="33" width="12" height="12" rx="3"/><rect x="18" y="33" width="12" height="12" rx="3"/><rect x="33" y="33" width="12" height="12" rx="3"/></g><rect x="15.5" y="15.5" width="17" height="17" rx="4.5" fill="url(#veMark)"/><path d="M20 24.3l2.8 2.9 5.2-5.8" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </button>
  </span>

  <input type="password" name="password">   <!-- your password path, unchanged -->
  <button type="submit">Log in</button>
</form>

<script>
  document.getElementById("ve-signin").addEventListener("click", function () {
    // #id_username is Django's default; WordPress is #user_login; use your field's id
    var box  = document.getElementById("id_username");
    var user = (box && box.value || "").trim();
    if (!user) { if (box) box.focus(); return; }
    // POST it to YOUR server, which signs the API call — the secret never reaches the
    // browser, and the username stays out of URLs, logs, and browser history.
    var f = document.createElement("form");
    f.method = "post"; f.action = "/ve/start";
    var i = document.createElement("input");
    i.type = "hidden"; i.name = "user"; i.value = user;
    f.appendChild(i);
    // If your framework requires a CSRF token on POSTs, append it here too.
    document.body.appendChild(f);
    f.submit();
  });
</script>

Pasting the button more than once on a page? Give each SVG linearGradient a unique id (they're global DOM ids).

Your /ve/start handler (a POST endpoint) should first validate the typed name against your own user records — and only then sign and call POST /api/v1/challenges, passing local_account: true for names you recognise (see the signed examples just below). Validating first is what stops your login form being used to probe which usernames exist. Then redirect the browser: enrolled:true → the returned challenge_url; enrolled:falsehttps://aqa.com/invite to enrol. That is the whole button — the two round-trips are covered in The login flow section.

WordPress needs none of this — the plugin injects the button on wp-login.php automatically. The snippet above is for Django and every other stack — though on Django you can skip the hand-rolling entirely: pip install django-visualeyes (PyPI) ships the signed client, the views and the button as a reusable app. Full Django guide →

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

The identical signing applies to every /api/v1/* call — for /api/v1/verify the JSON body is just {"result_token": "TOKEN"}: build that exact string, sign timestamp + "." + body with the same secret, send the same three headers.

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. Rotate in a quiet window: any login already in flight when you rotate will fail its /verify with 401 bad_signature, and the user simply signs in again. Update the secret in your server config in the same change.

Endpoint reference

POST/api/v1/challenges — start a login

{
  "user":         "customer@example.com",
  "callback_url": "https://yoursite.com/ve-callback",
  "local_account": true,      // optional: you attest "user" is a real, active account on your site
  "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}

local_account is an optional boolean — your attestation that the typed user is a real, active, login-able account on your site. Only set it true for names you have validated against your own user table. Under live attestation, the self-registration offer (alias_offer / register_url, below) is returned only when local_account:true; an unattested unknown name gets a plain {"enrolled": false}, so VisualEyes can't be turned into a username enumerator against your site.

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.

Origin binding: deliver the ?result= callback to the same host the challenge was minted for — don't relay it cross-host, or verify returns origin_mismatch (404). For sensitive sites a high-assurance second channel runs at this step too, but it is transparent to you: verify still just returns passed true/false.

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
If your verify call times out (network error, no HTTP response): do not retry the token — it is single-use, and a retry can only return already_verified. Treat the login as not completed and let the person sign in again (a fresh challenge is issued; nothing is at risk). Rarely, the timed-out verify may still have completed and been billed on our side — every verified login carries a receipt/tid and appears in your dashboard ledger, so reconciliation is straightforward.

Success response fields

FieldTypeMeaning
passedbooltrue = challenge cleared, create the session; false = deny the login.
userstringThe billing identity — an email, or an opaque vza_… throwaway handle for an anonymous login.
aliasstringPresent for an attested per-site alias login: the username on your site to sign the person in as. Prefer it over user when present.
duressbooltrue = coercion signalled out-of-band. Act server-side (limit or flag the session, alert staff); never show the browser.
new_sourcebooltrue = login from an unfamiliar network or device. Advisory — step up or notify if you wish.
src_device / src_os / src_countrystring | nullBest-effort provenance behind the new_source advisory; null when unknown.
assurancestring | null"VE1" on a pass, otherwise null.
statsobjectChallenge tally: rounds_answered, rounds_correct, misses, skips, policy.
billed_creditsint15 on a billed pass (metered clients); absent when nothing was charged.
balance_creditsintYour prepaid credit balance remaining after this login.
receipt / tidstring / intTamper-evident login receipt and its transaction id — present only when receipts are enabled for your client.
Anonymous logins: for a throwaway vza_… identity, duress and new_source are forced false, the src_* fields are null, and there is no alias — the opaque handle is the identity.

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. The Django guide shows exactly where to read alias, duress and new_source in your callback view.

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.

Usernames, new users & uniqueness

What to send as user

Send whatever your login field holds — an email address or your site's username. VisualEyes resolves it within your site: it accepts an email, a login name, or a per-site handle and maps it to the right account. You don't have to change what your users type.

First time / unrecognised name → registration

If the value isn't known, the challenge call returns {"enrolled": false}. Redirect that person to https://aqa.com/invite to enrol — it is free, takes a couple of minutes, and they simply add a handful of their own photos (no faces, nothing posted online). When they return and click the button again, they're recognised and signed in. You never store, send or check a password.

“But usernames aren't globally unique” — already handled

They don't need to be. VisualEyes scopes identity to your site. A username only has to be unique on your site — which you already guarantee — not across the whole internet. VisualEyes binds (your site + that username) to a globally-unique VisualEyes account, anchored by the person's email at enrolment. So jsmith on your site and jsmith on someone else's site are simply different people; there is no collision to resolve.

Same person, another site: if at enrolment their email already has a VisualEyes account (they use VisualEyes elsewhere), VisualEyes offers to link this site's username to their existing photos — they reuse their recognition everywhere and never re-enrol. On the challenge call this surfaces as {"enrolled": false, "alias_offer": true, "register_url": "…"} — just redirect the browser to that register_url and VisualEyes handles the linking and consent. This offer is returned only when your challenge call attested local_account:true (see the endpoint above) — an unattested unknown name gets a bare {"enrolled": false}, so the response never reveals whether a name exists.

Smoothest path: if your usernames are not emails, capture an email at sign-up and pass that as user. Emails are globally unique, so the same person reuses their photos across any VisualEyes site with zero friction. Passing site usernames also works — VisualEyes just keeps them scoped to you.

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.
403site_untrusted Only when reverse-KYC blocking is enabled: the requesting site is below the trust threshold. Contact us — end users can't clear this themselves.
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.
423alias_locked + portal_url The per-site login name is locked. The user unlocks it at VisualEyes — send them to the given portal_url, never to a client-site login.
404(verify) unknown_result / already_verified Treat as a failed login. Result tokens are single-use and short-lived.
404(verify) origin_mismatch The ?result= callback reached a different host than the challenge was minted for. Deliver callbacks to the same origin — see The callback.

Billing

Why VisualEyes

Get your credentials → Open your dashboard → Django guide → Talk to us