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.
- Creating WordPress users on first sign-in is now OFF by default, and when you switch it on it also respects your site’s own “Anyone can register” setting. Earlier builds created accounts by default.
- Accounts that can administer your site (Administrator, or any role that can manage options, edit users, promote users or install plugins) can no longer sign in with VisualEyes unless the site is set to the high-assurance tier, and such an account can never be created automatically. Matching is by email address, so this keeps the strongest challenge in front of your most powerful accounts.
https, server errors are no longer treated as a login
result, and your client secret is no longer written into the settings page HTML (leave the box blank to keep
the stored value). Full notes are in the plugin’s readme.txt.Session lifetime. Plugin 0.6.0 also handles VisualEyes-managed sessions for you: it honours whatever
policy you choose in the dashboard and exposes the sign-out endpoint at
/wp-json/visualeyes/v1/logout, so “sign out everywhere” really does end the session on
your site. Nothing to configure beyond pasting that URL into the dashboard —
see Session lifetime & sign-out. Leave the setting on site-managed (the
default) and WordPress keeps handling sessions exactly as it does now.
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());Python
import hashlib, hmac, json, time, urllib.request
body = json.dumps({
"user": email,
"callback_url": "https://yoursite.com/ve-callback",
}).encode() # sign these EXACT bytes — never re-serialize
ts = str(time.time())
sig = hmac.new(secret.encode(), (ts + ".").encode() + body,
hashlib.sha256).hexdigest() # secret from your config
req = urllib.request.Request(
"https://aqa.com/api/v1/challenges", data=body, method="POST",
headers={
"Content-Type": "application/json",
"X-VE-Client-Id": client_id,
"X-VE-Timestamp": ts,
"X-VE-Signature": sig,
})
with urllib.request.urlopen(req, timeout=15) as r:
res = json.load(r)Django? Don't write any of this — pip install django-visualeyes ships the signed
client, the start/callback views, an auth backend and a login button.
See the Django guide.
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).
Language of the VisualEyes screens
By default VisualEyes picks the language itself (the visitor's saved choice if they have one, otherwise a geo-based suggestion). That guess is made without any knowledge of your site, so a visitor reading your pages in one language can land on a VisualEyes screen in another. If you know what language your user is reading, tell us — it removes the guess entirely.
Two equivalent ways, both optional:
// 1. in the create-challenge body
{"user": "customer@example.com", "callback_url": "...", "lang": "fr"}
// 2. or append it to the returned challenge_url before you redirect
https://aqa.com/c/<token>?lang=frlang takes a language code we publish at
https://aqa.com/i18n/languages.json (e.g. en, fr,
es, pt-BR). An unknown or unsupported code is ignored rather than
rejected — you never get a failed login because of a language tag. If you are integrating over
OpenID Connect instead, use the standard ui_locales authorization-request
parameter; it means the same thing and no VisualEyes-specific parameter is needed.
Whatever you send, the user can still change the language themselves — every VisualEyes screen carries a language selector — and their explicit choice wins over both your hint and our guess.
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",
"user_since": 1751328000, // account created (epoch s) — see "email reuse" below
"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"} // unknown or already-used 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, and deliberately opaque whenever we can make it so.
If you signed the person in by your own username (an attested per-site alias) you get a
vep_… handle: stable for this person on your site, different on every other site, and
never their VisualEyes email — use the alias below to sign them in.
An anonymous login gives an opaque vza_… throwaway handle.
You only receive an email address when you sent one to start the login, i.e. when you already had it.
We never hand a site an identity it did not already hold. |
| 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. |
| user_since | int | When the VisualEyes account behind this login was created, epoch seconds. Present on email-keyed logins (the ones where user is an address); absent for vep_… alias and vza_… anonymous logins, whose handles are never reissued. See Account identity and email reuse below. |
| 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. |
| session | object | Present only when you have asked VisualEyes to manage session lifetime for your site (see Session lifetime & sign-out). Absent — and everything below unchanged — for the default, site-managed setting. |
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.Account identity and email reuse
An email address can outlive the account that held it. If someone closes (or loses) their VisualEyes account, the address is released — and mailboxes get recycled: a company address is reassigned, a domain expires and is re-registered, a provider frees an idle handle. Whoever holds it next could enrol their own photos and pass their own challenge. If your site looks up accounts by the email string, that is the same string arriving with a different human behind it.
VisualEyes will not hand a freshly-deleted address straight back: after someone deletes their own account the address is refused for 30 days, and after a deletion we made for security reasons it is refused permanently. That is a speed bump, not a guarantee — 30 days is short, and an address can change hands quietly at any point. Do not rely on it. The verify response gives you the signal to check instead:
- Store
user_sincewhen you first link a VisualEyes login to one of your accounts. - On every later login for that account, compare. Same value → same VisualEyes account, carry on.
- A newer
user_sincethan the one you stored means the account behind that address has been replaced. Do not sign them into the existing account: treat it as a new person, and re-verify out of band (your own recovery flow) before relinking anything.
A quiet account is not a deleted one. An account that goes unused is disabled, not removed:
its photos go, but the account and its email address are kept indefinitely, so that address never becomes
available to anybody else. Only an actual deletion releases an address. In practice that means the risky
transition to watch for is deletion-then-reuse, which is exactly what user_since exposes.
vep_…) and anonymous handles (vza_…) are minted per person and are never reissued
to anybody else, so the substitution above cannot happen — which is why those logins carry no
user_since. Keying your accounts on alias where you can is strictly stronger than
keying on an address, and it keeps the person's email out of your database as a bonus.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.
Session lifetime & sign-out
By default VisualEyes says nothing about how long a login lasts: we authenticate the person, you create whatever session your site normally creates, and nothing we do afterwards touches it. That default is called site-managed, and if you leave it alone nothing on this page applies to you — the verify response is byte-for-byte what it has always been.
If you would rather VisualEyes expressed a session lifetime — and, importantly, could end a session
when the person presses “sign out everywhere” in their VisualEyes account — pick a policy in your
dashboard under Session lifetime. Two things then change: verify returns an extra
session object, and two small endpoints become useful to you.
The four policies
| Policy | max_age | What it means |
|---|---|---|
| client_managed (default) | — | No session object at all. Your site's own rules apply, exactly as today. |
| every_visit | 0 | Re-authenticate on every visit. Nothing is kept signed in. |
| bounded | seconds | The session may live at most this long, measured from the moment of login. Presets of 1 day and 30 days; any value from 5 minutes to 90 days is accepted. |
| until_logout | null | No time limit. It ends when someone ends it — the person from their VisualEyes account, or your site. |
The session object in a verify response
200 {"passed": true,
"user": "customer@example.com",
"session": {
"policy": "bounded", // every_visit | bounded | until_logout
"max_age": 2592000, // 0 | seconds | null (see table above)
"handle": "vesh_9f0c…", // "vesh_" + 48 hex chars — this session's id
"recheck_url": "https://aqa.com/api/v1/session/check",
"recheck_after": 900 // seconds; don't poll faster than this
},
... }Store the handle alongside your own session. It carries no identity of any kind and is
meaningless at any other site — a handle presented by a different client is answered exactly as if it had
never existed. Honour max_age as a ceiling on your session, and re-check no more often than
recheck_after seconds.
recheck_url before you ever call it. Accept it only
if its origin is the same API base you were configured with, and otherwise fall back to your configured base
plus the documented path /api/v1/session/check. This is not a style point: you sign that request
with your client id and secret, so a client that blindly followed a recheck_url from a tampered
verify response would become a signing oracle, handing valid signatures — under its own identity — to whatever
host the attacker named. Both our SDKs enforce this and refuse anything else.bounded is an ABSOLUTE cap from the moment of login, not an idle timeout.
If your framework uses sliding expiry — Django’s SESSION_SAVE_EVERY_REQUEST, or any
“refresh the cookie on each request” setting — it will quietly turn a 30-day cap into a rolling
30-day window that never ends. Pin the absolute deadline separately (store
login_time + max_age and check it yourself); do not rely on your session backend’s own
expiry to enforce it.every_visit and idle timeouts. max_age: 0 means the person
re-authenticates on each visit; it does not by itself say when an open session goes stale. Sites
should apply a short idle timeout to these sessions — our SDKs default to 15 minutes. There is
no wire field for this in v1; pick a value and apply it locally.POST/api/v1/session/check — are these sessions still good?
Signed exactly like every other /api/v1 call (same three headers, same ±120s window). Up to
100 handles per call.
{"handles": ["vesh_9f0c…", "vesh_1a77…"]} // {"handle": "vesh_…"} also accepted
200 {"results": {
"vesh_9f0c…": {"active": true, "reason": "ok"},
"vesh_1a77…": {"active": false, "reason": "revoked"}
}}reason is one of ok, revoked (ended by the person or by you),
expired (past its max_age), or unknown. Anything other than
active: true means stop honouring your session for that handle.
unknown means strictly “this handle has never existed for your client”
— it was never issued to you, or it belongs to another site (the two are deliberately indistinguishable). It
does not mean “we forgot”: a session we issued to you keeps answering
revoked or expired for the life of the account, so unknown is safe for
you to treat as terminal. We do not age these records out from under you.
recheck_after beyond the deadline you
were due to re-check as the hard floor, and end the session once you pass it. That way a VisualEyes
outage costs your users nothing for the first interval, and a genuinely orphaned session still closes. Both
our SDKs implement exactly this.POST/api/v1/session/end — your own sign-out button
{"handle": "vesh_9f0c…"}
200 {"ok": true}Call this when the person signs out on your site, so VisualEyes stops counting the session as live. It is
idempotent and always answers {"ok": true} — including for a handle that is already ended,
unknown, or another site's, so it cannot be used to probe which handles exist.
The sign-out push (optional, recommended)
Polling has a floor of recheck_after. To end sessions immediately, register a
sign-out notification URL in your dashboard. It must be https and on
one of your registered callback origins. When someone presses “sign out everywhere” in their
VisualEyes account, we POST this to it:
POST https://yoursite.com/wp-json/visualeyes/v1/logout
X-VE-Client-Id: your client id
X-VE-Timestamp: 1783765000.123
X-VE-Signature: hex HMAC-SHA256, keyed with YOUR secret
{"type": "logout",
"user": "customer@example.com", // the same identity verify gave you for this person
"alias": null, // the attested per-site alias, or null — always present
"handles": ["vesh_9f0c…"], // their sessions at your site, max 100 per POST
"ts": 1783765000} // always equal to X-VE-Timestamp aboveThe body’s ts is always exactly the value in the signed X-VE-Timestamp
header, so you may check either. If someone has more than 100 live sessions at your site we send several
POSTs rather than one oversized body.
What your handler must do, in this order:
- Verify the signature over
timestamp + "." + raw_bodywith your client secret — byte-for-byte the scheme you already use outbound. An unsigned or wrongly-signed POST is an attacker trying to sign your users out; reject it. - Check the timestamp is within ±120 seconds of your clock.
- Reject replays — remember recently-seen
(timestamp, signature)pairs for a couple of minutes and drop duplicates. A replay is harmless (ending an ended session is a no-op) but there is no reason to accept one. - End the listed
handles, and any session you hold for thatuser/alias. Answer2xx.
/session/check is the backstop, which is why the polling floor exists. Nothing your endpoint
does can delay or fail the person's sign-out in their VisualEyes account.Both SDKs implement all of this for you: the Django package from v0.3.0, and
the WordPress plugin from v0.6.0 (which exposes the endpoint at
/wp-json/visualeyes/v1/logout).
Reference implementation (Python)
The two server-side halves of a login, with the checks that matter marked. It is written with Flask
calls for concreteness, but nothing here is Flask-specific — session, request
and redirect have an equivalent in every framework. The same five rules apply in PHP, Node,
Ruby, Go or anything else.
1. Start — validate locally, then open the challenge
import hmac, secrets
from urllib.parse import urlencode, urlsplit
NEUTRAL = "We couldn't start VisualEyes for that account."
@app.post("/ve-start") # POST only, with your framework's CSRF token
def ve_start():
typed = (request.form.get("username") or "").strip()
# (1) ANTI-ENUMERATION: confirm the account exists in YOUR user table before
# calling us, and use ONE message for every failure — unknown name, blank,
# inactive, VisualEyes-disabled. Different messages (or different redirects)
# turn your login form into a "does this user exist here?" oracle.
user = find_active_local_user(typed)
if user is None:
flash(NEUTRAL)
return redirect("/login")
# (2) BIND THIS LOGIN TO THIS BROWSER. Mint a one-time nonce, keep it in the
# session, and carry it on the callback URL. Without this, anyone can finish a
# challenge for their OWN account and feed the resulting ?result= link to a
# victim, silently signing that victim into the attacker's account (login CSRF).
nonce = secrets.token_urlsafe(32)
session["ve_nonce"] = nonce
session["ve_user_id"] = user.id # the account we are vouching for
callback = "https://yoursite.com/ve-callback?" + urlencode({"n": nonce})
status, res = ve_post("/api/v1/challenges", {
"user": typed,
"callback_url": callback,
"local_account": True, # attested: we validated it above
})
if status == 200 and res.get("alias_offer"):
return redirect(res["register_url"])
if status == 200 and res.get("enrolled"):
return redirect(res["challenge_url"])
flash(NEUTRAL) # same message on service errors too
return redirect("/login")2. Callback — verify, then bind the answer to the account you attested
@app.get("/ve-callback")
def ve_callback():
token = request.args.get("result", "")
nonce = session.pop("ve_nonce", "")
attested_id = session.pop("ve_user_id", None)
# (3) The nonce must match the one this browser started with.
if not token or not nonce or not hmac.compare_digest(nonce, request.args.get("n", "")):
flash("VisualEyes sign-in failed.")
return redirect("/login")
# (4) THE REDIRECT PROVES NOTHING. Only this signed server-to-server call does.
# Never retry it: the token is single-use, so a retry can double-consume it or
# mask a duress signal. Fail closed instead.
status, res = ve_post("/api/v1/verify", {"result_token": token})
if status != 200 or not res.get("passed"):
flash("VisualEyes sign-in failed.")
return redirect("/login")
# (5) SIGN IN THE ACCOUNT YOU ATTESTED — never the name the user typed, and
# never an account chosen only from the returned identity. Resolve what we
# returned, then require it to be the same local account you vouched for at
# step 1. This is what stops any mismatch between our name resolution and
# yours from becoming an account takeover on your site.
alias = res.get("alias") # your own username, when we know it
identity = alias or res.get("user") # else the account email / vza_ handle
user = find_active_local_user(identity)
if user is None or attested_id is None or user.id != attested_id:
flash("VisualEyes sign-in failed.")
return redirect("/login")
if res.get("duress"):
# Act server-side only — alert, limit, log. NEVER show this to the browser,
# and never change the visible outcome: the coerced user must not be tipped off.
alert_security_team(user, res)
log_in(user) # your framework's session creation
# Same-host redirects only: an unchecked ?next= is an open redirect, and it can
# leak the result token to another origin via the Referer header.
nxt = request.args.get("next") or "/"
if urlsplit(nxt).netloc or not nxt.startswith("/"):
nxt = "/"
return redirect(nxt)/api/v1/verify and never retry it; sign in the account
you attested, not the string the user typed; and keep duress and your client secret entirely
server-side.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 for the local account you attested at step 2, not for the name the user typed.passed:false→ deny. Handleduressper your policy — server-side, never in the browser.
challenge_url and everyone else to /invite are two visibly different outcomes, so
if that pair is reachable for arbitrary input it tells an attacker which of your users have VisualEyes.
Validating the typed name against your own user table first (step 2) is what keeps it harmless — only names
you already accept ever get this far. If you need it fully silent, send both branches to one neutral
interstitial of your own and continue from there.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 or a name you have registered as a per-site alias, 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 this offer never reveals whether a name exists.
alias_offer above is
attestation-gated, but 409 needs_reenrolment and 423 alias_locked can only be
returned for a name that does have a VisualEyes account — anyone holding client credentials can tell
those apart from {"enrolled": false}. Treat them as confidential: never surface the difference
to the browser, and keep validating names against your own user table first so an attacker cannot reach
this API with names you have not already accepted.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.