Guides Handling results
Handling Responses & Error Cases
Your callback code has two decision points, and it helps to think about them separately. First, the browser arrives back at your redirect_uri — either with an authorization code or with an error. Only if you get a code do you exchange it and call /userinfo, which is where the age result appears.
Errors can surface at either point, and they mean different things.
Before you exchange anything
Once state checks out, look for an error parameter. If one is present there is no code to exchange, and the flow ends here.
| Outcome | Redirect contains | Action |
|---|---|---|
| User cancelled | error=access_denied | Do not grant access. Show something like “Verification was cancelled.” |
| Verification unsuccessful | error=access_denied | Do not grant access. You may offer a retry via a new authorization request. |
No error parameter | code=… and state=… | Continue to the token exchange. |
Verification is unsuccessful when the user attempted it but it did not complete — a rejected document, a failed liveness check, or an expired session.
https://yourapp.com/callback?error=access_denied&error_description=The+user+denied+the+request&state=YOUR_ORIGINAL_STATE
Both outcomes return the same error value. For most integrations that is fine — the correct action is identical, so handle them together and deny access. If you need to distinguish them, see the note at the end of this page.
After you call /userinfo
You have a code, you exchanged it successfully, and you have called /userinfo. The response tells you the result.
Verified
{
"sub": 89,
"age_verified": true,
"expires_at": 1765035995,
"metadata": "order:XYZ-42"
}
Not verified
The user completed the flow and did not pass.
{
"sub": 110,
"age_verified": false
}
Do not grant access. Treat them as an unverified user. Note that expires_at and metadata are absent here — code that reads them unconditionally will break, so guard your field access.
Exempt region
Users in a Safezone or otherwise exempt region return the same shape as a verified user.
{
"sub": 88,
"age_verified": true,
"expires_at": 1765035995
}
Treat this as a success and grant access exactly as you would for a verified user.
Everything in one table
| Where | Signal | Grant access? |
|---|---|---|
| Callback | An error parameter is present | No |
| Token exchange | HTTP 400 with an error field | No — see Error reference |
/userinfo | age_verified: true | Yes |
/userinfo | age_verified: false | No |
/userinfo | HTTP 401 or 5xx | No — and treat as an integration fault, not a failed user |