Get a Verified User in 10 Minutes
Integrate CAIRL identity verification into your application using OAuth 2.0 with PKCE.
What you'll build
CAIRL uses the OAuth 2.0 Authorization Code flow with PKCE. If you've integrated Stripe or Plaid, this will feel familiar.
By the end of this guide, your application will:
- Redirect a user to CAIRL for identity verification
- Receive a callback with an authorization code
- Exchange that code for an access token
- Pull verified claims (e.g.,
claims.age_18_plus) from userinfo
You receive derived claims, not raw identity material. CAIRL returns the specific claims requested and authorized by the user (e.g.,
claims.age_18_plus: true). The claims interface does not return raw documents, names, or biometric material. Derived claims are still personal data and must be protected, retained, and used under applicable obligations.
Scopes vs. claim names. The
scopeparameter takes wire scopes such asage:18+andidentity:verified. The userinfo response returns claim names such asclaims.age_18_plusandclaims.identity_verified. The two are spelled differently on purpose; a claim name sent as a scope is rejected withinvalid_scope. See Scopes you can request today.
Prerequisites
If you have not set up your account yet, follow Step 0 in Getting Started first. You need:
| Item | Where to get it |
|---|---|
client_id | Created with your business facet and shown once on /home/f/{slug}/integrate in the Your test credentials card. If you already dismissed the card, generate new credentials on /home/f/{slug}/keys. |
client_secret | Shown once in the same card; store it server-side only and keep it out of browser code |
| Registered callback URL | /home/f/{slug}/connect — on the Sandbox tab, click Use this on the suggested https://<your-site>/auth/cairl/callback, or add your own. http://localhost is allowed for test keys; live keys require https://. |
| Funds | Only for live credentials: /home/f/{slug}/billing, $50 minimum load. Once loaded, Create live credentials on /home/f/{slug}/keys is one click. Test credentials need no funds. |
If a keyless Sandbox page is available in your environment at
/home/developer/sandbox, it shows a synthetic run of the flow. It is optional.
Sample credentials used below
For copy-paste examples, the docs use this non-live sample client id:
CAIRL_CLIENT_ID=cairl_test_demo
CAIRL_CLIENT_SECRET=replace_with_your_test_secretReplace both values with your own test credentials for development and CI. Switch to live credentials only after you have loaded funds and registered a live callback URL.
Step 1 — Redirect the user to CAIRL
CAIRL has two entry URLs. They take the same parameters and both end with a
code on your callback, but they do different work in between:
| Entry | What happens before consent | Use it for |
|---|---|---|
/verify/start | The hosted verification flow: sign-in, then document and face verification for your site. Every new request performs a new verification; a member's earlier verification, on CAIRL or on your site, does not skip it. | "Verify now" — a check of this person for this site |
/oauth/authorize | Sign-in, then a check of the person's existing CAIRL verification against your key's required depth and its time-based freshness window; only someone who does not meet it is sent through verification. Requires response_type=code. | "Sign in with CAIRL" and "prove you are 18+ / verified" buttons |
Most integrations want /oauth/authorize: a member CAIRL has already verified
should not repeat liveness and document capture for every site they sign in
to. Use /verify/start when the point of the step is a new verification.
The freshness half of that check covers the time-based policies only —
daily, 3day, weekly, biweekly, monthly. The event-driven policies,
session and transaction, are not enforced at /oauth/authorize today: a
key on either one passes the door regardless of when the person last
verified. If you need a per-session or per-transaction check, send the user
through /verify/start.
Copy-paste canonical URL (hosted verification):
https://cairl.app/verify/start?client_id=cairl_test_demo&redirect_uri=https://yourapp.com/auth/cairl/callback&state=YOUR_STATE&scope=age%3A18%2B&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256Sign-in entry, and what a conventional OAuth client sends: the same
parameters on /oauth/authorize plus response_type=code, which standard
OAuth libraries add automatically and the hosted /verify/start URL above
does not need:
/oauth/authorize?response_type=code&client_id=cairl_test_demo&redirect_uri=https://yourapp.com/auth/cairl/callback&state=YOUR_STATE&scope=age%3A18%2B&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256If you accepted the suggested callback URL on CAIRL:connect, your
redirect_uri already has the shape used in every example on this page:
https://<your-site>/auth/cairl/callback.
Most integrations start with scope=age:18+. Add additional scopes only when your use case requires them.
Full parameter reference:
| Parameter | Required | Description |
|---|---|---|
client_id | Yes | Your CAIRL client identifier |
redirect_uri | Yes | Must exactly match a callback URL registered on /home/f/{slug}/connect |
state | Yes | Random string you generate. Minimum 16 characters. Returned to you unchanged — use it to verify the callback is genuine (CSRF protection). |
scope | Yes | Space-delimited list of wire scopes (age:18+ identity:verified). See Scopes you can request today. |
code_challenge | Yes | PKCE S256 challenge derived from your code_verifier |
code_challenge_method | Yes | Must be S256 |
When building the URL with a query-string helper (as in the examples below),
age:18+ is encoded automatically. If you assemble the URL by hand, encode the
plus sign as %2B: scope=age%3A18%2B.
Generate PKCE values (Node.js):
import crypto from "crypto";
// Generate once per authorization request, store server-side
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");Generate state:
const state = crypto.randomBytes(16).toString("base64url");
// Store in session: req.session.cairl_state = state
// Store verifier in session: req.session.cairl_code_verifier = codeVerifierGenerate PKCE values (Python):
import base64
import hashlib
import secrets
def base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
code_verifier = base64url(secrets.token_bytes(32))
code_challenge = base64url(hashlib.sha256(code_verifier.encode("ascii")).digest())
state = base64url(secrets.token_bytes(16))Full redirect (Node.js / Express):
app.get("/auth/cairl", (req, res) => {
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
req.session.cairl_code_verifier = codeVerifier;
req.session.cairl_state = state;
const params = new URLSearchParams({
client_id: process.env.CAIRL_CLIENT_ID,
redirect_uri: "https://yourapp.com/auth/cairl/callback",
state,
scope: "age:18+",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(`https://cairl.app/verify/start?${params}`);
});What CAIRL does next
CAIRL owns the entire user experience after the redirect:
- Account creation or sign-in (inline — no separate signup page)
- Email verification (6-digit code, entered inline)
- Identity verification (document upload + face match)
- Consent screen showing your app name and requested claims
Whether a returning member repeats identity verification depends on the entry
you chose in Step 1. On /oauth/authorize, a member whose existing CAIRL
verification meets your key's depth and is inside its time-based freshness
window skips it. On /verify/start, every new request performs a new
verification; only re-entering the same still-live hosted session resumes
one.
First-time users: ~3–5 minutes. Returning members on /oauth/authorize: ~30
seconds.
With test credentials the same screens appear: you sign in with your own
CAIRL account, see the consent screen, and are sent back with a code. Only the
claim values, the sub, and billing are synthetic, and you do not need to
complete identity verification to test. See
what is real and what is synthetic in test mode.
Step 2 — Handle the callback
After verification, CAIRL redirects the user back to your redirect_uri:
https://yourapp.com/auth/cairl/callback?code=AUTH_CODE&state=YOUR_STATE_VALUEValidate the state first. If state does not match what you stored in Step 1, reject the request — it may be a CSRF attempt.
app.get("/auth/cairl/callback", async (req, res) => {
const { code, state, error } = req.query;
// Handle user denial or verification failure
if (error) {
return res.redirect("/verification-failed");
}
// Validate state
if (state !== req.session.cairl_state) {
return res.status(400).send("Invalid state");
}
// Proceed to Step 3
const token = await exchangeCode(code, req.session.cairl_code_verifier);
// ...
});Error callbacks — if verification fails or the user cancels, CAIRL redirects with an error parameter instead of code:
error value | Meaning |
|---|---|
access_denied | User declined consent |
verification_failed | User abandoned verification |
session_expired | User took too long (30 min limit) |
Retry behavior: If verification fails or the user cancels, restart the flow by redirecting to /verify/start with a fresh state and new PKCE values. Nothing is broken — the user simply starts a new session.
Step 3 — Exchange the code for a token
Exchange the authorization code for an access token. This is a server-to-server request — do not make it from the browser.
cURL:
curl -X POST https://cairl.app/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "client_id=cairl_test_demo" \
-d "client_secret=$CAIRL_CLIENT_SECRET" \
-d "redirect_uri=https://yourapp.com/auth/cairl/callback" \
-d "code_verifier=YOUR_CODE_VERIFIER"Node.js:
async function exchangeCode(code, codeVerifier) {
const response = await fetch("https://cairl.app/api/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: process.env.CAIRL_CLIENT_ID,
client_secret: process.env.CAIRL_CLIENT_SECRET,
redirect_uri: "https://yourapp.com/auth/cairl/callback",
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error_description ?? err.error);
}
return response.json();
}Python:
import os
import requests
def exchange_code(code: str, code_verifier: str) -> dict:
response = requests.post(
"https://cairl.app/api/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": os.environ["CAIRL_CLIENT_ID"],
"client_secret": os.environ["CAIRL_CLIENT_SECRET"],
"redirect_uri": "https://yourapp.com/auth/cairl/callback",
"code_verifier": code_verifier,
},
timeout=10,
)
response.raise_for_status()
return response.json()Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "age:18+"
}There is no refresh_token and no id_token. When the access token expires,
run the flow again to get a fresh snapshot.
Billing note: Test credentials do not bill. With live credentials, your
balance is charged at this step, not during verification. You are charged only
when the user completes verification and you successfully exchange the
authorization code. Abandoned flows, failed verifications, and failed token
exchanges are not billed. If your balance is insufficient, the token exchange
returns 402 Payment Required and no token is issued.
Step 4 — Pull verified claims
Use the access token to retrieve the verified claims you requested.
cURL:
curl https://cairl.app/api/oauth/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"Node.js:
async function getVerifiedClaims(accessToken) {
const response = await fetch("https://cairl.app/api/oauth/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) throw new Error("Failed to retrieve claims");
return response.json();
}Python:
import requests
def get_verified_claims(access_token: str) -> dict:
response = requests.get(
"https://cairl.app/api/oauth/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
response.raise_for_status()
return response.json()Response:
{
"sub": "pws_v1_abc123",
"mode": "live",
"evaluated_at": "2026-05-03T10:00:00.000Z",
"claims": {
"age_18_plus": true,
"identity_verified": true
},
"meta": {
"claims_requested": ["age_18_plus", "identity_verified"],
"claims_resolved": ["age_18_plus", "identity_verified"],
"claims_null": [],
"claims_ignored": []
}
}sub is a stable, per-site pseudonymous user identifier. It is the same for
returning users on your site and different across sites. This design reduces
cross-site linkability, but sub is still personal data and must be protected
accordingly.
mode is "live" or "test" and matches the key that started the flow. With
test credentials the sub is a random pws_v1_test_… value and the claims are
fixtures, so treat a "test" response as plumbing confirmation, not as a
verified user.
Scopes you can request today
| Scope | Claim returned | Meaning | Status |
|---|---|---|---|
age:13+ | age_13_plus | User is 13 or older | Available |
age:16+ | age_16_plus | User is 16 or older | Available |
age:18+ | age_18_plus | User is 18 or older | Available |
age:21+ | age_21_plus | User is 21 or older | Available |
age:25+ | age_25_plus | User is 25 or older | Available |
age:55+ | age_55_plus | User is 55 or older | Available |
age:65+ | age_65_plus | User is 65 or older | Available |
identity:verified | identity_verified | User completed identity verification | Available |
identity:face_match | photo_verified | Live face matched the identity document | Available |
freshness:current | freshness_current | Verification is within your key's freshness window | Available |
age_assurance:tx | age_assurance_tx_* receipt | Texas policy-versioned receipt (pair with age:18+) | Available |
age_assurance:ut | age_assurance_ut_* receipt | Utah policy-versioned receipt (pair with age:18+) | Available |
age_assurance:ca | age_assurance_ca_* receipt | California policy-versioned receipt (pair with age:18+) | Available |
age_assurance:uk | age_assurance_uk_* receipt | UK policy-versioned receipt (pair with age:18+) | Available |
age_assurance:au | age_assurance_au_* receipt | Australia policy-versioned receipt (pair with age:16+) | Available |
age_assurance:la | — | Louisiana mode | Under counsel review; returns no receipt fields |
identity:integrity | identity_uniqueness_assurance | Identity Integrity Assurance object | Restricted pilot; rejected unless enabled for your client |
Any other value in scope is rejected with invalid_scope. Request only the
scopes your application needs; users see exactly what you request on the
consent screen. The claims reference lists claims
that are planned but not yet requestable.
Billing reference
| Event | Price |
|---|---|
| Enrollment — the first verification of a person on your site | $0.50, once per person |
| Verified Access Event — every later check for that person | Priced from the claims you request, minimum $0.03 |
There is no flat VAE rate and no allowance of included claims. A VAE costs
the sum of the claims it evaluates, floored at $0.03 — so the
age:18+ + identity:verified pair used in this quickstart costs $0.06,
and asking for more claims costs more.
Enrollment is charged once per person for your site, not per API key: rotating to a new live key does not bill it again.
Charges are deducted from your prepaid balance at token exchange. Minimum load: $50 at /home/f/{slug}/billing. If your balance is insufficient, the token exchange returns 402 Payment Required and no token is issued. Test credentials are never billed.
Common errors
Returned on the CAIRL error page or as a redirect (authorization stage):
| Error | Cause | Fix |
|---|---|---|
invalid_scope | A value in scope is not a wire scope, or not enabled for your live key | Use scopes from the table above; for live keys, select them under Required Claims |
invalid_redirect_uri | redirect_uri doesn't match a registered callback URL | Add the exact URL on /home/f/{slug}/connect — no trailing-slash differences |
invalid_request | Missing or malformed parameter (for example state under 16 characters) | Check all required fields are present |
client_inactive | Your business facet or key is not active | Check the key on /home/f/{slug}/keys and the facet status on /home/f/{slug}/integrate |
access_denied | User declined consent | Present user with option to try again |
Returned by the token endpoint:
| Error | HTTP status | Cause | Fix |
|---|---|---|---|
invalid_client | 401 | Wrong client_id or client_secret | Check credentials |
invalid_grant | 400 | Code expired, already used, or code_verifier mismatch | Generate a new authorization request |
invalid_request | 400 | Missing field (all six are required) | Check grant_type, code, client_id, client_secret, redirect_uri, code_verifier |
payment_required | 402 | Balance insufficient (live keys) | Add funds at /home/f/{slug}/billing |
The full list is in the error reference.
Security checklist
Before going live, confirm:
-
client_secretis stored server-side only — not in browser code, mobile apps, or public repos -
stateis validated on every callback before processing the code -
code_verifieris generated fresh for every authorization request - Token exchange happens server-to-server, not from the client
-
redirect_uriin token exchange exactly matches the value used in the authorization request - Live credentials are created only after funds are loaded and a live
https://callback URL is registered, and only when you click Create live credentials on/home/f/{slug}/keys
Next steps
- Add more claims — request
identity:verifiedorage:21+by updating yourscopeparameter - Handle returning users — on
/oauth/authorize, a user who already meets your key's depth and is inside its time-based freshness window skips re-verification and goes straight to consent; on/verify/startevery request runs a new verification - What you pay — there is no flat per-exchange rate. The first successful live exchange for a person your site has not verified before bills an enrollment at $0.50, once per person and not per API key, so rotating keys does not bill it again. Every later exchange for that person bills a Verified Access Event priced from the claims you requested, floored at $0.03 — the
age:18++identity:verifiedpair used in this quickstart is $0.06. See Billing reference above. Test-key exchanges are free, so a sandbox run tells you nothing about your bill - Configure freshness — choose a Freshness Policy when generating a key, then request
freshness:current. Only the time-based policies (daily,3day,weekly,biweekly,monthly) gate/oauth/authorize;sessionandtransactionare not enforced there - Manage callback URLs — add, edit, or remove registered URLs yourself on
/home/f/{slug}/connect - No developer? — follow Add CAIRL to a base44 app
Full working example
If you want to copy-paste a working integration, start here. This is a complete Express.js server that handles all four steps.
import express from "express";
import crypto from "crypto";
import session from "express-session";
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}),
);
// Step 1: Redirect to CAIRL
app.get("/auth/cairl", (req, res) => {
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
req.session.cairl_code_verifier = codeVerifier;
req.session.cairl_state = state;
const params = new URLSearchParams({
client_id: process.env.CAIRL_CLIENT_ID,
redirect_uri: "https://yourapp.com/auth/cairl/callback",
state,
scope: "age:18+ identity:verified",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(`https://cairl.app/verify/start?${params}`);
});
// Step 2 + 3 + 4: Handle callback, exchange code, pull claims
app.get("/auth/cairl/callback", async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.redirect("/verification-failed");
if (state !== req.session.cairl_state)
return res.status(400).send("Invalid state");
// Step 3: Exchange code
const tokenRes = await fetch("https://cairl.app/api/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: process.env.CAIRL_CLIENT_ID,
client_secret: process.env.CAIRL_CLIENT_SECRET,
redirect_uri: "https://yourapp.com/auth/cairl/callback",
code_verifier: req.session.cairl_code_verifier,
}),
});
if (!tokenRes.ok) return res.redirect("/verification-failed");
const { access_token } = await tokenRes.json();
// Step 4: Pull claims
const claimsRes = await fetch("https://cairl.app/api/oauth/userinfo", {
headers: { Authorization: `Bearer ${access_token}` },
});
const userinfo = await claimsRes.json();
// userinfo.claims.age_18_plus === true: user is verified and of age
if (userinfo.claims?.age_18_plus) {
req.session.verified = true;
return res.redirect("/dashboard");
}
res.redirect("/verification-failed");
});
app.listen(3000);