Integrate in 15 minutes
Embed the widget, verify the token on your backend, ship. Three endpoints, two keys, one billable call.
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:
| Key | Prefix | Use |
|---|---|---|
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). |
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>
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.
| Key | Where to use it | Billing |
|---|---|---|
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.
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.
| Status | Body | Meaning |
|---|---|---|
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
| Status | Body | Meaning |
|---|---|---|
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). |
verification_id early.
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.
| Action | Body | Returns |
|---|---|---|
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"}.
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.
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
keyfield. 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_atas a unix epoch: the exact moment the old key dies. - During that window, both keys authenticate.
- After
prev_expires_atpasses, 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.
/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.
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, nothttps://example.com/. Entries with://or/are rejected as400. - Exact match by default. List subdomains explicitly (
app.example.com), or use a leading*.wildcard (*.example.commatches 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
whitelistcall replaces the whole list. To add one domain, send the existing list plus the new one. - Test-key dev exception. With a test key,
localhostand127.0.0.1are accepted regardless of the whitelist.
Every /v1/verify-token request and every widget load checks your status:
| Status | Widget 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/accountactionportal). Keys keep working through the end of the paid period; then the account moves toinactive. - 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 (
portalreturns400).
| Symptom | Cause | Fix |
|---|---|---|
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. |
| Endpoint | Auth | Purpose |
|---|---|---|
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.