Developer integration guide
Proof of Age lets partners add privacy-preserving age checks without becoming an identity database. Create a session, send the user to Proof of Age, and receive a signed, short-lived proof token.
Prove age, not identity. Partners receive only whether the user meets an age requirement — not their name, date of birth, passport details, email or wallet address.
This product is designed to align with UK and EU digital verification direction. It is not certified under any trust framework.
Getting access
- Choose a plan at proofofage.app/pricing and complete Stripe checkout.
- On the success page, save your API key, Partner ID, and webhook secret — they are shown once.
- Sign in to the partner dashboard with the billing email you used at checkout (magic link).
To explore the user experience before subscribing, try the live demo. The demo does not issue API credentials.
Your credentials
| Credential | Purpose |
|---|---|
API key (poa_…) | Authenticate all Partner API requests: Authorization: Bearer <API_KEY> |
Partner ID (partner_…) | Proof token audience (aud claim). Used when verifying tokens — not for API authentication |
| Webhook secret | Verify HMAC signatures on optional session callbacks |
Store the API key and webhook secret securely. If you lose the API key, rotate it in the partner dashboard. If you lose the webhook secret, contact [email protected].
Your API base URL and JWKS URL are shown in the partner dashboard under Integration.
Partner ID in practice
Every proof token is scoped to your organisation. The JWT aud claim equals your Partner ID.
When using the verify API, confirm the response partnerId matches yours:
{
"valid": true,
"partnerId": "partner_a44ffac1-56ef-4353-b702-86d41f091f9c",
"ageOver": 18
}When verifying offline via JWKS, pass your Partner ID as the expected audience when validating the JWS signature (ES256). Reject tokens whose aud does not match.
Your Partner ID is also visible in the partner dashboard at any time. It is safe to store in configuration — unlike your API key, it is not a secret.
Quick start
Replace https://api.proofofage.app with your API base URL from the dashboard if different.
1. Create an age-check session
curl -s -X POST https://api.proofofage.app/v1/age-check/sessions \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"requestedAge": 18,
"returnUrl": "https://acme.example/age-verified"
}'Response:
{
"sessionId": "acs_b852bd7e-6d7f-46e7-ab0d-5cb8a5473165",
"status": "pending",
"requestedAge": 18,
"expiresAt": "2026-06-12T10:10:00.000Z",
"verificationUrl": "https://proofofage.app/age-check/acs_b852bd7e-6d7f-46e7-ab0d-5cb8a5473165",
"qrPayload": "https://proofofage.app/age-check/acs_b852bd7e-6d7f-46e7-ab0d-5cb8a5473165"
}Redirect the user to verificationUrl, or render qrPayload as a QR code.
2. User completes verification
The user sees a consent screen listing exactly what will and will not be shared, then approves via:
- Web / World App miniapp — World ID at
/age-check/{sessionId} - Mobile app — scan QR or open
proofofage://age-check/{sessionId}
On approval, if you provided returnUrl, the user is redirected with:
https://acme.example/age-verified?proof_token=<signed-jws>3. Verify the proof token
Option A — API verification (recommended):
curl -s -X POST https://api.proofofage.app/v1/age-check/verify \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"token": "<PROOF_TOKEN>"}'Response (success):
{
"valid": true,
"ageOver": 18,
"proofLevel": "medium",
"proofMethod": "world_id",
"partnerId": "partner_a44ffac1-56ef-4353-b702-86d41f091f9c",
"sessionId": "acs_b852bd7e-6d7f-46e7-ab0d-5cb8a5473165",
"expiresAt": "2026-06-12T10:05:00.000Z"
}Confirm partnerId matches your Partner ID before granting access.
Option B — offline verification via JWKS:
curl -s https://api.proofofage.app/.well-known/proof-of-age/jwks.jsonVerify the JWS locally with the public keys (ES256). Check aud matches your Partner ID.
4. Poll session status (alternative)
curl -s https://api.proofofage.app/v1/age-check/sessions/<SESSION_ID> \
-H "Authorization: Bearer <API_KEY>"Returns proof and proofToken when status is approved.
React integration snippet
const API_BASE = "https://api.proofofage.app";
async function startAgeCheck(apiKey: string) {
const res = await fetch(`${API_BASE}/v1/age-check/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
requestedAge: 18,
returnUrl: `${window.location.origin}/age-verified`,
}),
});
const { verificationUrl } = await res.json();
window.location.href = verificationUrl;
}
// On your /age-verified page:
async function handleReturn(apiKey: string, expectedPartnerId: string) {
const token = new URLSearchParams(window.location.search).get("proof_token");
if (!token) return { verified: false };
const res = await fetch(`${API_BASE}/v1/age-check/verify`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
});
const result = await res.json();
if (!result.valid || result.partnerId !== expectedPartnerId) {
return { verified: false };
}
return { verified: true, ageOver: result.ageOver };
}What partners receive
| Field | Description |
|---|---|
valid | Whether the proof is valid and unexpired |
ageOver | Age threshold met (e.g. 18) |
proofLevel | low, medium, or high assurance |
proofMethod | world_id, passport_nfc, eu_wallet, etc. |
partnerId | Your partner id (token audience) |
sessionId | The age-check session id |
expiresAt | Proof expiry (short-lived, typically 5 minutes) |
What partners never receive
- Name
- Date of birth
- Passport or document number
- Nationality
- Wallet address
- Raw document or NFC data
- Stable cross-session user identifiers
Proof tokens carry a session-scoped pseudonymous subject (sub) — not a user identity.
Usage and billing
Only successful verifications that result in an issued proof token count toward your plan. Abandoned sessions, user denials, and failed attempts are not billed.
| Proof method | Assurance | Meter |
|---|---|---|
| World ID | Medium | Medium |
| Passport NFC | High | High |
| EU wallet | High | High |
View your usage for the current billing period in the partner dashboard. Overage proofs are billed via Stripe according to your plan.
Webhook callbacks
Optionally set callbackUrl when creating a session. The API POSTs a JSON payload on approve/deny with HMAC-SHA256 signing using your webhook secret.
Headers:
Content-Type: application/jsonX-Proof-Of-Age-Signature: sha256=<hex>
Verify (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(body, signatureHeader, secret) {
const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader ?? ""));
}Callback URLs must use HTTPS in production. Private and metadata IP ranges are rejected (SSRF protection).
Privacy notes
- Proofs are purpose-limited to one partner and one age threshold per session.
- Only fixed age thresholds are supported: 13, 16, 18, 21, 25.
- World ID zero-knowledge payloads are verified and discarded; raw nullifier hashes are never stored or returned.
- Set
includeRepeatSubject: trueonly when the user explicitly consents to a derived, partner-scoped repeat-use identifier. - Use HTTPS
returnUrlvalues in production.
Further reading
- Privacy Policy — how verification data is handled, including partner age-check sessions
- Terms of Service — service terms for partners and users
For security review, DPIA support, or integration architecture questions, contact [email protected].
Support
- Partner dashboard: proofofage.app/partner/login
- Email: [email protected]