Developer docs

Integrate in 15 minutes

Embed the widget, verify the token on your backend, ship. Three endpoints, two keys, one billable call.

On this page
How it works

Integration is a two-side handshake:

  • Front end: the widget verifies the visitor and hands your page a single-use verification token (a signed JWT).
  • Back end: your server posts that token to POST /v1/verify-token. That call is the billable moment: one success is one verification.

All calls use base URL https://api.agewarden.ai and JSON.

You get three keys when you sign up:

KeyPrefixUse
api_key ev_live_ Server secret. Authorizes /v1/verify-token and /v1/account. Never put it in the browser.
widget_key_live aw_live_ Public, embedded in your site. Real traffic, billed.
widget_key_test aw_test_ Public, for integration testing. Never bills (see Live key vs test key).
Quick start

Paste this into a page, drop in your aw_test_ key, and open it over http://localhost:

<div id="age-verify"></div>
<script
  src="https://bouncer.agewarden.ai/loader.js"
  data-site-key="aw_test_YOUR_TEST_KEY"
  data-target="#age-verify"
  data-widget-version="1">
</script>
Serve over http://localhost, not file://. The microphone (getUserMedia) only runs in a secure context: https or http://localhost. A file:// page can't reach the mic, so verification never starts. Any static server works: npx serve or python -m http.server.

Test traffic never bills, and localhost / 127.0.0.1 are always allowed with a test key, no whitelist entry needed. A live (aw_live_) key on a non-whitelisted origin (including localhost) is refused by design; that is the "not available on this site" state.

Your account's copy-paste snippets (test and live), prefilled with your widget keys, appear once on the welcome page right after signup. Save them then; otherwise use the template above and drop in your key.

Live key vs test key
KeyWhere to use itBilling
widget_key_live Production traffic on your real domains. Counts toward your monthly verification total at the published band rates.
widget_key_test Local development, staging, smoke tests. localhost and 127.0.0.1 are always allowed with a test key, no whitelist entry needed. Never bills. Test-key usage is tracked separately in period_test_usage with a per-billing-period cap of 100 verifications, reported via /v1/account action usage as period_test_cap. The counter resets on Stripe invoice.paid at billing-period rollover, alongside the live-traffic counter.

Both keys share the same domain whitelist. Swap the key value in the widget snippet to go live.

Verify the token

Exchange the token your client posted for the verdict by calling POST /v1/verify-token. This is the billable moment: one success equals one verification.

Request

POST https://api.agewarden.ai/v1/verify-token
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{
    "token": "<verification token from the widget>"
}

cURL

curl -X POST https://api.agewarden.ai/v1/verify-token \
    -H "Authorization: Bearer $AGEWARDEN_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"token":"<verification-token>"}'

Response: 200 OK (valid token)

{
    "valid": true,
    "verification_id": "ver_01HZ...",
    "result": "ALLOW"
}

valid: true means the token was genuine and has been consumed. verification_id is the durable receipt; log it for audit and idempotency. result is one of:

  • ALLOW (Approved): age confirmed. Billable.
  • BLOCK (Declined): not confirmed or under age. Billable.
  • RETRY: the recording was inconclusive. It isn't billed and prompts the user to re-record. Not billable.

Treat only ALLOW and BLOCK as terminal. On RETRY, re-prompt the user to record again; don't grant or deny access. Calling /v1/verify-token on a RETRY returns valid: true, result: "RETRY" and is never metered. A verification that stays inconclusive across repeated retries is finalized as BLOCK (Declined), which is billable.

Response: 200 OK (token-level rejection)

Token problems return 200 with valid: false and an error string. The request was well-formed; the token simply isn't usable. Surface these to the visitor as "please try again," not as a server failure.

StatusBodyMeaning
200 {"valid": false, "error": "token_expired"} The JWT's exp has passed (tokens are valid for 5 minutes from issue).
200 {"valid": false, "error": "token_invalid"} Signature failed, claims malformed, or the token references a customer that no longer exists.
200 {"valid": false, "error": "token_already_used"} The token's jti has already been consumed. Single-use; do not retry.
200 {"valid": false, "error": "customer_not_found"} The token's customer_id claim doesn't resolve. Same handling as token_invalid.

Errors: auth and request

StatusBodyMeaning
400 {"valid": false, "error": "token field required"} The request body has no token field, or it's empty.
401 {"valid": false, "error": "Authorization header required"} No Authorization: Bearer <key> header on the request.
403 {"valid": false, "error": "api_key_mismatch"} The API key doesn't match the customer record the token was minted for. Wrong key, or token from a different account.
403 {"valid": false, "error": "account_inactive", "message": "Account is not active."} Your account status is not active or past_due. See Account status.
500 {"valid": false, "error": "internal_error"} Server-side fault. Safe to retry the entire visitor flow (the token is unaffected if it never reached us).
Idempotency. The token is the boundary. Don't retry a spent token; it's already burned. Re-running the visitor produces a new token and counts as a new verification. Persist verification_id early.
Account API

POST /v1/account manages your account: one endpoint, four actions, same auth as verify-token (Authorization: Bearer YOUR_API_KEY). Auth failure (missing key, wrong key, or account status not active / past_due) returns 401 {"error": "unauthorized"}. The Account dashboard calls these routes directly.

ActionBodyReturns
usage { "action": "usage" } period_usage, period_spend_cents, period_start, account_type, status, has_payment_method, period_test_usage, period_test_cap.
rotate { "action": "rotate", "kind": "api" | "widget_live" | "widget_test" } { "key": "<new raw key>", "kind": "...", "prev_expires_at": <epoch seconds> }. Shown once. The previous key keeps working until prev_expires_at (24 hours from rotation; see Key rotation). Unknown kind returns 400 {"error": "invalid_kind"}.
whitelist { "action": "whitelist", "hostnames": ["example.com", "..."] } { "domain_whitelist": [...] }. Body field is hostnames; response field is domain_whitelist. Hostnames only: no scheme, no path. Max 50. Replaces the existing list.
portal { "action": "portal" } { "url": "https://billing.stripe.com/..." }. Redirect the user there to manage card, view invoices, cancel. Enterprise accounts return 400 with a "Portal not available" message. They don't have a Stripe portal; contact your account manager.

whitelist validation errors return 400 with one of: hostnames must be a list, too_many_hostnames, hostname_must_be_string, hostname_must_not_include_scheme, hostname_must_not_include_path. Unknown action strings return 400 {"error": "unknown_action"}.

Recover a lost key

Lost your api_key? POST /v1/recover is unauthenticated and runs in two phases. Phase is inferred from the body: email triggers phase 1, token triggers phase 2.

Phase 1: request the link

POST https://api.agewarden.ai/v1/recover
Content-Type: application/json

{ "email": "you@example.com" }

Always returns 202 Accepted with body {"status": "ok"}. We don't reveal whether the email matched an account, to prevent enumeration. If it matches a Stripe customer record, a magic link is sent. The link is valid for 15 minutes and is single-use.

Phase 2: exchange the token

POST https://api.agewarden.ai/v1/recover
Content-Type: application/json

{ "token": "<token from the magic link>" }

Returns { "api_key": "<new key>", "prev_expires_at": <epoch> }. Shown once. The new key arrives under api_key (the rotate action returns it under key). Your previous api_key keeps working for 24 hours, then stops authenticating.

Errors: 401 {"error": "link expired or already used"} if the token in phase 2 has been redeemed, has expired past its 15-minute TTL, or doesn't exist. Request a new link.

Key rotation

Every key (api_key, widget_key_live, widget_key_test) rotates from the dashboard or via /v1/account action rotate. Rotation is non-disruptive:

  • A new raw key is returned once, under the key field. We store only its SHA-256 hash; if you lose it, rotate again.
  • The old key keeps working for 24 hours. The response includes prev_expires_at as a unix epoch: the exact moment the old key dies.
  • During that window, both keys authenticate.
  • After prev_expires_at passes, only the new key works.

Roll the new key into production config, CI secrets, and edge caches at your own pace; nothing breaks the moment you click the button.

Recovery is a rotation. Completing /v1/recover applies the same 24-hour grace to your old api_key. If the old key was compromised, rotate again immediately after recovery to start a fresh clock and shorten the window.
Domain whitelist

The widget refuses to run on any origin not on your whitelist. Set the list from the Account dashboard or via /v1/account action whitelist. Send hostnames under the hostnames field; read them back under domain_whitelist.

  • Hostnames only. No scheme, no port, no path. example.com, not https://example.com/. Entries with :// or / are rejected as 400.
  • Exact match by default. List subdomains explicitly (app.example.com), or use a leading *. wildcard (*.example.com matches every subdomain but not the bare apex).
  • Max 50 entries. Talk to us if you legitimately need more.
  • One list, both keys. Live and test keys validate against the same whitelist.
  • Replace, not append. A whitelist call replaces the whole list. To add one domain, send the existing list plus the new one.
  • Test-key dev exception. With a test key, localhost and 127.0.0.1 are accepted regardless of the whitelist.
Account status

Every /v1/verify-token request and every widget load checks your status:

StatusWidget loads?verify-token?Set by
active Yes Yes Default. Set when Stripe records a paid invoice.
past_due Yes Yes First failed payment. Stripe Smart Retries runs for ~7 days; reverts to active on success. Service is not interrupted during this window.
inactive / suspended No (403) No (403) Set manually (enterprise) or after Smart Retries exhausts. Pay the outstanding invoice from the billing portal to restore.

A past_due account is not blocked; that's the point of the grace period. Only inactive or suspended produces 403 (verify-token returns {"valid": false, "error": "account_inactive"}; /v1/account returns {"error": "unauthorized"}).

  • Billing is monthly in arrears. Stripe totals successful verify-token calls at month-end and charges the card on file. No upfront fee, no commitment.
  • New accounts get a $10 credit on the first invoice: roughly 100 verifications at the entry band. Applied automatically at onboarding.
  • Disputes don't lock your account. Chargeback an invoice and your keys keep working; we don't auto-suspend on dispute.
  • Cancellation is one click in the Stripe billing portal (/v1/account action portal). Keys keep working through the end of the paid period; then the account moves to inactive.
  • Enterprise accounts never auto-suspend on payment failure: invoices are net-30 ACH or wire, status changes are manual, and the Stripe portal is unavailable (portal returns 400).
Troubleshooting
SymptomCauseFix
Widget won't load / "not available on this site" (403) Origin not whitelisted, or a live key on a non-whitelisted origin (incl. localhost). Add the domain in the dashboard, or use your test key locally.
Mic never prompts; verification never starts Page isn't a secure context (opened as file://). Serve over http://localhost or https.
verify-token → 200 {"valid": false, "error": "token_expired"} Token older than its 5-minute life. Re-run the visitor; tokens are single-use.
verify-token → 200 token_already_used Token already consumed. Re-run the visitor for a new token; never retry verify-token.
verify-token → 401 No Authorization: Bearer header. Send your api_key as a bearer token.
verify-token → 403 api_key_mismatch Key belongs to a different account than the token. Use the api_key for the account that minted the token.
verify-token → 403 account_inactive Status is inactive/suspended (past_due still works). Pay the open invoice in the billing portal.
429 test_key_monthly_cap_exceeded Over 100 test verifications this period. Switch to the live key, or wait for the period to reset.
/v1/account → 401 unauthorized Bad/missing key, or inactive account. Re-check the key; recover it if lost.
Reference
EndpointAuthPurpose
POST /v1/verify-token Bearer api_key Consume a verification token. Billable moment.
POST /v1/account Bearer api_key Usage, key rotation, whitelist, billing portal.
POST /v1/recover None Magic-link recovery of a lost api_key.

Base URL: https://api.agewarden.ai. All requests are JSON. Authorization: Bearer <key>. No other header schemes are accepted.

Something missing? email us.