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.
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 /verify →
passed: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)
- Get your credentials — register your site at /client/register
(email + one-time code). You receive a
Client ID, asecret(shown once) and your registered callback origin. - Install the plugin — download visualeyes-plugin.zip, upload it in WP Admin → Plugins, and paste your credentials into Settings → VisualEyes.
- 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:
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:false →
https://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 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.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_urlError 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 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.
?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-usealready_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
| Field | Type | Meaning |
|---|---|---|
| passed | bool | true = challenge cleared, create the session; false = deny the login. |
| user | string | The billing identity — an email, or an opaque vza_… throwaway handle for an anonymous login. |
| alias | string | Present for an attested per-site alias login: the username on your site to sign the person in as. Prefer it over user when present. |
| duress | bool | true = coercion signalled out-of-band. Act server-side (limit or flag the session, alert staff); never show the browser. |
| new_source | bool | true = login from an unfamiliar network or device. Advisory — step up or notify if you wish. |
| src_device / src_os / src_country | string | null | Best-effort provenance behind the new_source advisory; null when unknown. |
| assurance | string | null | "VE1" on a pass, otherwise null. |
| stats | object | Challenge tally: rounds_answered, rounds_correct, misses, skips, policy. |
| billed_credits | int | 15 on a billed pass (metered clients); absent when nothing was charged. |
| balance_credits | int | Your prepaid credit balance remaining after this login. |
| receipt / tid | string / int | Tamper-evident login receipt and its transaction id — present only when receipts are enabled for your client. |
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
- User clicks Sign in with VisualEyes on your site and enters their email.
- Your server signs and sends
POST /api/v1/challenges. enrolled:false→ redirect them tohttps://aqa.com/invite(friendly enrol page); stop here.enrolled:true→ redirect the browser tochallenge_url. The user proves it's them by recognising their own photos — nothing to type, nothing to phish.- VisualEyes redirects back to your
callback_url?result=<token>. - Your server signs and sends
POST /api/v1/verifywith the token. passed:true→ create your session.passed:false→ deny. Handleduressper 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.
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
| 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. |
| 403 | site_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. |
| 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. |
| 423 | alias_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
- Always free for your end users — they never pay to enrol or to sign in, and there is nothing for them to install. The charge below is yours alone, and only when a login actually succeeds.
- Pay per successful login — 15 credits each (1 credit = $0.001, so ~$0.015). Failed challenges, unknown users and API errors cost nothing.
- Try before you buy — every new client starts with 1,500 free credits (~100 logins).
- Out of credit — new end-user logins return
402until you top up; your own first-party test logins are exempt, so you can always verify your integration. - Balance, history and top-up live in your dashboard.
Why VisualEyes
- No reusable secret — nothing for users to type, reuse, leak or phish.
- No password database — nothing on your side to breach.
- Conversion — works in any mobile or desktop browser; no app to install.
- Pay only for successful logins — aligned incentives, no per-seat fees.
- We use it ourselves — sensitive actions in the client dashboard are stepped-up with a VisualEyes photo login. We dogfood what we sell.