CAIRLDocs
Integration

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:

  1. Redirect a user to CAIRL for identity verification
  2. Receive a callback with an authorization code
  3. Exchange that code for an access token
  4. 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 scope parameter takes wire scopes such as age:18+ and identity:verified. The userinfo response returns claim names such as claims.age_18_plus and claims.identity_verified. The two are spelled differently on purpose; a claim name sent as a scope is rejected with invalid_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:

ItemWhere to get it
client_idCreated 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_secretShown 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://.
FundsOnly 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_secret

Replace 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:

EntryWhat happens before consentUse it for
/verify/startThe 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/authorizeSign-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=S256

Sign-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=S256

If 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:

ParameterRequiredDescription
client_idYesYour CAIRL client identifier
redirect_uriYesMust exactly match a callback URL registered on /home/f/{slug}/connect
stateYesRandom string you generate. Minimum 16 characters. Returned to you unchanged — use it to verify the callback is genuine (CSRF protection).
scopeYesSpace-delimited list of wire scopes (age:18+ identity:verified). See Scopes you can request today.
code_challengeYesPKCE S256 challenge derived from your code_verifier
code_challenge_methodYesMust 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 = codeVerifier

Generate 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_VALUE

Validate 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 valueMeaning
access_deniedUser declined consent
verification_failedUser abandoned verification
session_expiredUser 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

ScopeClaim returnedMeaningStatus
age:13+age_13_plusUser is 13 or olderAvailable
age:16+age_16_plusUser is 16 or olderAvailable
age:18+age_18_plusUser is 18 or olderAvailable
age:21+age_21_plusUser is 21 or olderAvailable
age:25+age_25_plusUser is 25 or olderAvailable
age:55+age_55_plusUser is 55 or olderAvailable
age:65+age_65_plusUser is 65 or olderAvailable
identity:verifiedidentity_verifiedUser completed identity verificationAvailable
identity:face_matchphoto_verifiedLive face matched the identity documentAvailable
freshness:currentfreshness_currentVerification is within your key's freshness windowAvailable
age_assurance:txage_assurance_tx_* receiptTexas policy-versioned receipt (pair with age:18+)Available
age_assurance:utage_assurance_ut_* receiptUtah policy-versioned receipt (pair with age:18+)Available
age_assurance:caage_assurance_ca_* receiptCalifornia policy-versioned receipt (pair with age:18+)Available
age_assurance:ukage_assurance_uk_* receiptUK policy-versioned receipt (pair with age:18+)Available
age_assurance:auage_assurance_au_* receiptAustralia policy-versioned receipt (pair with age:16+)Available
age_assurance:laLouisiana modeUnder counsel review; returns no receipt fields
identity:integrityidentity_uniqueness_assuranceIdentity Integrity Assurance objectRestricted 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

EventPrice
Enrollment — the first verification of a person on your site$0.50, once per person
Verified Access Event — every later check for that personPriced 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):

ErrorCauseFix
invalid_scopeA value in scope is not a wire scope, or not enabled for your live keyUse scopes from the table above; for live keys, select them under Required Claims
invalid_redirect_uriredirect_uri doesn't match a registered callback URLAdd the exact URL on /home/f/{slug}/connect — no trailing-slash differences
invalid_requestMissing or malformed parameter (for example state under 16 characters)Check all required fields are present
client_inactiveYour business facet or key is not activeCheck the key on /home/f/{slug}/keys and the facet status on /home/f/{slug}/integrate
access_deniedUser declined consentPresent user with option to try again

Returned by the token endpoint:

ErrorHTTP statusCauseFix
invalid_client401Wrong client_id or client_secretCheck credentials
invalid_grant400Code expired, already used, or code_verifier mismatchGenerate a new authorization request
invalid_request400Missing field (all six are required)Check grant_type, code, client_id, client_secret, redirect_uri, code_verifier
payment_required402Balance 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_secret is stored server-side only — not in browser code, mobile apps, or public repos
  • state is validated on every callback before processing the code
  • code_verifier is generated fresh for every authorization request
  • Token exchange happens server-to-server, not from the client
  • redirect_uri in 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:verified or age:21+ by updating your scope parameter
  • 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/start every 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:verified pair 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; session and transaction are 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);

On this page