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.,
age_18_plus) from the token
You never receive raw identity data. CAIRL returns only verified claims (e.g.,
age_18_plus: true). You do not store documents, names, or PII. No data liability, no breach exposure.
Prerequisites
Before you start, you need:
| Item | How to get it |
|---|---|
client_id | Provided by CAIRL when your account is activated |
client_secret | Provided alongside client_id — store it server-side only, never expose to browsers |
| Registered callback URL | Your redirect URI, pre-registered with CAIRL. During beta, contact your CAIRL onboarding contact to register it. |
| Funded wallet | Your CAIRL wallet must have a balance before live verification events are billed. Minimum load: $50. You can build and test the full integration flow without a funded wallet — token exchange will return 402 Payment Required until funded. |
Step 1 — Redirect the user to CAIRL
When a user needs to be verified, redirect them to CAIRL's hosted verification flow.
Copy-paste canonical URL:
https://cairl.app/verify/start?client_id=YOUR_CLIENT_ID&redirect_uri=https://yourapp.com/auth/cairl/callback&state=YOUR_STATE&scope=age_18_plus&code_challenge=YOUR_CODE_CHALLENGE&code_challenge_method=S256Most integrations start with scope=age_18_plus. Add additional claims 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 your registered callback URL |
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 claims you need. See claim reference below. |
code_challenge | Yes | PKCE S256 challenge derived from your code_verifier |
code_challenge_method | Yes | Must be S256 |
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 = codeVerifierFull 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_plus',
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 login (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
If the user has already verified with CAIRL, identity verification is skipped automatically.
First-time users: ~3–5 minutes. Returning verified users: ~30 seconds.
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 — never 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=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_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();
}Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600
}Billing note: Your wallet 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 wallet 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();
}Response:
{
"sub": "user_abc123",
"age_18_plus": true,
"identity_verified": true
}sub is a stable, per-partner pseudonymous user identifier. It is the same for returning users and different across partners — your application cannot use it to track a user across other CAIRL-integrated platforms.
Available claims
| Claim | Description | Risk level |
|---|---|---|
age_18_plus | User is 18 or older | Low |
age_21_plus | User is 21 or older | Low |
identity_verified | User has completed identity verification | Low |
unique_per_partner | User has no other account with your application | Low |
document_active | User's ID document is not expired | Low |
freshness_current | User meets your configured freshness window | Low |
name_match | Submitted name matches verified name | Medium |
dob_match | Submitted date of birth matches verified record | Medium |
address_match | Submitted address matches verified record | Medium |
Request only the claims your application needs. Users see exactly what you request on the consent screen.
Billing reference
| Event | Price |
|---|---|
| Enrollment (first verification per user per partner) | $0.50 |
| Authorization event (subsequent claim checks) | $0.05 |
| Additional claims beyond 5 included | $0.01 each |
Charges are deducted from your prepaid wallet at token exchange. Wallet minimum load: $50. If your wallet balance is insufficient, the token exchange returns 402 Payment Required and no token is issued.
Common errors
| 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_redirect_uri | 400 | redirect_uri doesn't match registered value | Use exact registered URL — no trailing slash differences |
invalid_request | 400 | Missing or malformed parameter | Check all required fields are present |
invalid_scope | 400 | Requested scope not authorized for your client | Contact CAIRL to enable additional claims |
payment_required | 402 | Wallet balance insufficient | Fund your wallet at cairl.app/home/billing |
client_inactive | 403 | Your account is not yet activated | Contact CAIRL to complete activation |
access_denied | — (redirect) | User declined consent | Present user with option to try again |
Security checklist
Before going live, confirm:
-
client_secretis stored server-side only — never 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
Next steps
- Add more claims — request
identity_verifiedorage_21_plusby updating yourscopeparameter - Handle returning users — users who have already verified with CAIRL skip re-verification automatically; you pay only the authorization event rate ($0.05) on subsequent checks
- Configure freshness — contact CAIRL to set a verification freshness window for your application (e.g., require re-verification every 90 days for high-trust actions)
- OAH-001 — self-serve callback URL management is coming; for now, contact your onboarding contact to add or update registered URIs
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_plus 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 claims = await claimsRes.json();
// claims.age_18_plus === true — user is verified and of age
if (claims.age_18_plus) {
req.session.verified = true;
return res.redirect('/dashboard');
}
res.redirect('/verification-failed');
});
app.listen(3000);