> ## Documentation Index
> Fetch the complete documentation index at: https://docs.botshield.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Age Gate

> Ask whether a verified human is over 13, 18 or 21, and get back only Over N Verified or Unavailable.

<Info>
  Age Gate is in **Beta**. It is built in the API and the Console. The age fields are not yet in the TypeScript SDK types or the widget's events, so you read them from the API directly, as shown on this page.
</Info>

An Age Gate is a gate that answers one more question than a Human Gate: *is this human over the threshold?* You choose the threshold, 13, 18 or 21, when you place the gate. The integration is the same as a Human Gate. The difference is one extra check on your server.

<Warning>
  **"Verified" does not mean the age was met.** An Age Gate request finishes with status `completed`, a token with `verified: true`, a `botshield:success` event and a `gate.human_verified` webhook **whenever a human confirms**, even when the age result is `unavailable`. Those signals only tell you a real human was there. Your server must read the age result itself: `age_verdict` on `GET /verification/status`, or the `age_over` claim in the token. If it is missing or `unavailable`, the age was not established.
</Warning>

## What it answers, and what it never reveals

The age result is **positive-only**. There are two outcomes:

| Outcome | Wire value | Meaning |
| - | - | - |
| **Over N Verified** | `age_verdict`: `over_13`, `over_18` or `over_21` | A real human confirmed, and their phone's platform reports they are at least N. |
| **Unavailable** | `age_verdict`: `unavailable` | The age was not established. This is **not** a statement that the person is under N. |

An Age Gate never returns a date of birth, an age, an age range, or an "underage" answer. BotShield tells you *that* a human is over your threshold, never who they are or how old they are. You receive no personal data.

## Where the age signal comes from

BotShield does not estimate age, scan documents or analyze faces. During the confirmation on the user's phone, the BotShield app asks the phone's platform for the age range that the platform already offers to apps:

* On iPhone, Apple's **Declared Age Range**.
* On Android, Google Play **Age Signals**.

The app reduces the platform's answer to a yes for each threshold it clears and discards the rest on the device. Only "over 13", "over 18" or "over 21" leaves the phone, and only when true. BotShield then combines that with the live proof that a real human is holding the phone. `age_source` tells you which platform signal was used: `apple_declared_age_range` or `play_age_signals`.

The result is as good as the platform's signal. How the platform established the age range is the platform's own process. Check whether that meets the rules that apply to your product.

## Place an Age Gate

In the Console, select **BotShield Gate → Place a gate**, choose **Age** as the gate type, and pick **13+**, **18+** or **21+** under **Verify over**. See [Place a gate](/gate/place-a-gate) for the full walkthrough.

* An Age Gate always runs in **Live** mode. The age signal is read on the phone during the confirmation, so there is no instant pass for returning users.
* You can change the threshold later in the gate's **Configuration** card. Each request records the threshold that applied when it was created.
* The widget button looks the same as on a Human Gate, and there is no age-specific attribute. The desktop modal says it is verifying age: its title is "Verify your age with BotShield", the text reads "*meridianairlines.com* is asking BotShield to verify your age. BotShield never shares your identity — only an age result.", and the QR code is labelled "Scan to verify your age with BotShield".

Embed it like any gate:

```html theme={null}
<script src="https://cdn.botshield.ai/sdk.js"></script>

<botshield-verify
  id="age-gate"
  site-key="pk_live_…"
  scope="enter_over_18"
  scan-mode="modal"
></botshield-verify>

<script>
  document.getElementById('age-gate').addEventListener('botshield:success', async (e) => {
    // Success means a human confirmed. Ask your server whether the age was met.
    const res = await fetch('/api/age-check', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token: e.detail.token, request_id: e.detail.request_id }),
    });
    const { allowed } = await res.json();
    if (allowed) {
      window.location.assign('/members');
    } else {
      document.getElementById('age-fallback').hidden = false;
    }
  });
</script>

<div id="age-fallback" hidden>
  We couldn't confirm your age with BotShield. <a href="/verify-age">Try another way</a>.
</div>
```

<Note>
  The age signal is read on the phone, so an Age Gate never uses the desktop [inline passkey](/gate/web-component#inline-passkey-beta). If the element has `betas="inline-passkey"`, the widget skips the passkey prompt by itself, shows the QR code, and dispatches `botshield:inline-passkey` with `{ status: "fallback", reason: "age_gate", request_id }`.
</Note>

## The flow from your side

```mermaid theme={null}
sequenceDiagram
    participant P as Your page
    participant W as BotShield widget
    participant Ph as User's phone (BotShield app)
    participant A as BotShield API
    participant S as Your server
    P->>W: User clicks the widget (Age Gate, 18+)
    W->>Ph: QR code, push notification or deep link
    Ph->>Ph: User confirms with device biometric, app reads the platform age signal
    Ph->>A: Completes the request, with "over 18" or with no age claim
    A-->>W: completed
    W-->>P: botshield:success (token, request_id)
    P->>S: token and request_id
    S->>A: GET /verification/status?request_id=
    alt Threshold met
        A-->>S: completed, age_verdict over_18
        S-->>P: Allow
    else No age signal
        A-->>S: completed, age_verdict unavailable
        S-->>P: Show your fallback
    end
```

## What your server reads

### On `GET /verification/status`

```bash theme={null}
curl -s "https://api.botshield.ai/operations/verification/status?request_id=req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b"
```

```json theme={null}
{
  "data": {
    "found": true,
    "status": "completed",
    "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
    "organization_id": "org_…",
    "scope": "enter_over_18",
    "verified_at": "2026-09-21T17:05:12.000Z",
    "verification_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6…",
    "gate_type": "age",
    "age_threshold": 18,
    "age_verdict": "over_18",
    "age_source": "apple_declared_age_range"
  }
}
```

| Field | Values | Description |
| - | - | - |
| `gate_type` | `human`, `age` | `age` for an Age Gate. |
| `age_threshold` | `13`, `18`, `21`, or `null` | The gate's threshold when the request was created. |
| `age_verdict` | `over_13`, `over_18`, `over_21`, `unavailable`, or `null` | The result **against this gate's threshold**. On an 18+ gate it is `over_18` or `unavailable`. `null` until the request completes. |
| `age_source` | `apple_declared_age_range`, `play_age_signals`, or `null` | The platform signal used. `null` when there was none. |

Age data belongs to Age Gates only. A Human Gate request never returns it: its token has no `age_over` claim, and its `age_threshold`, `age_verdict` and `age_source` stay `null`, whatever the user's phone can share.

`create-verification-link` also returns `gate_type` and `age_threshold`, so a server-to-server integration can label its own screen before the user confirms.

### In the token

When a threshold is met, the attestation token carries one extra claim:

| Claim | Values | Description |
| - | - | - |
| `age_over` | `13`, `18` or `21` | The **highest** threshold the platform signal clears. A user over 21 on an 18+ gate gets `age_over: 21`. Absent when the age was not established. |

Compare with `>=`, never with equality: allow when `age_over >= your threshold`.

<Note>
  `POST /sdk/verify-token` does not return `age_over` today. Its `claims` object lists only the standard claims. To read `age_over`, verify the JWT locally against the JWKS, or use `age_verdict` from `GET /verification/status`.
</Note>

### A server check that fails closed

Both versions return `true` only when the age is positively established, and `false` for everything else: a bad token, a missing claim, an error, a timeout. Rely on the age fields only for requests where `gate_type` is `age`.

<Tabs>
  <Tab title="From the token">
    Works whenever the success event carries a token, on desktop and on mobile. Verify within the token's 120-second life.

    ```typescript theme={null}
    import { createRemoteJWKSet, jwtVerify } from "jose";

    const JWKS = createRemoteJWKSet(
      new URL("https://api.botshield.ai/.well-known/jwks.json"),
      { cacheMaxAge: 300_000 },
    );

    const AGE_THRESHOLD = 18; // the threshold of your Age Gate

    /** Returns true only when BotShield established "over AGE_THRESHOLD". Fails closed. */
    export async function isOverThreshold(token: string, organizationId: string): Promise<boolean> {
      try {
        const { payload } = await jwtVerify(token, JWKS, {
          issuer: "https://api.botshield.ai",
          algorithms: ["ES256"],
        });

        if (payload.verified !== true) return false;
        if (payload.organization_id !== organizationId) return false;

        // verified: true only means a human confirmed. The age result is age_over.
        const ageOver = payload.age_over;
        if (typeof ageOver !== "number") return false; // absent = age not established

        return ageOver >= AGE_THRESHOLD; // age_over is the highest threshold met
      } catch {
        return false; // bad signature, wrong issuer, expired
      }
    }
    ```
  </Tab>

  <Tab title="From the request status">
    Works whenever you have the `request_id`, including from a webhook, and does not depend on the token's 120-second life.

    ```typescript theme={null}
    const AGE_THRESHOLD = 18; // the threshold of your Age Gate
    const MET: Record<string, number> = { over_13: 13, over_18: 18, over_21: 21 };

    /** Returns true only when BotShield established "over AGE_THRESHOLD". Fails closed. */
    export async function isOverThresholdByStatus(
      requestId: string,
      organizationId: string,
      gateKey: string,
    ): Promise<boolean> {
      try {
        const url = new URL("https://api.botshield.ai/operations/verification/status");
        url.searchParams.set("request_id", requestId);

        const res = await fetch(url);
        if (!res.ok) return false;

        const body = (await res.json()) as { data?: Record<string, unknown> };
        const s = body.data;
        if (!s || s.error || s.found !== true) return false;

        // An Age Gate always runs the live check, so its only success status is "completed".
        if (s.status !== "completed") return false;
        if (s.organization_id !== organizationId || s.scope !== gateKey) return false;
        if (s.gate_type !== "age") return false;

        // "completed" only means a human confirmed. The age result is age_verdict.
        const met = MET[String(s.age_verdict)] ?? 0; // "unavailable" or null -> 0
        return met >= AGE_THRESHOLD;
      } catch {
        return false;
      }
    }
    ```
  </Tab>
</Tabs>

Also apply the general checks from [Verify on your server](/gate/verify-on-your-server#what-to-check): record each `request_id` you accept and reject repeats.

### Webhooks

An Age Gate sends `gate.human_verified` when a human confirms, whatever the age result. The webhook payload does not carry `age_verdict`. When it arrives, call `GET /verification/status` with its `request_id` and read the age fields there.

## Current limits

* **Not in the SDK types.** `botshield-sdk` 2.0.x does not type `gate_type`, `age_threshold`, `age_verdict` or `age_source`. Call `GET /verification/status` with `fetch` or curl, as above.
* **Not in the widget events.** `botshield:success` has no age fields, and it fires when a human confirms even if the age is `unavailable`.
* **Not echoed by `verify-token`.** Decode the token locally to read `age_over`.
* **Needs the BotShield app on the phone.** The age signal is read inside the BotShield app for iPhone and Android, which is in review. The web app ([app.botshield.ai](https://app.botshield.ai), public beta) completes the human check but has no platform age signal to read, so it returns `unavailable` for age. Older app builds do the same.
* **Needs a platform signal.** The result is `unavailable` when the platform has nothing to share: the operating system version does not provide the signal (on iPhone, Declared Age Range requires iOS 26 or later), the user declined to share it, the platform asks the user to confirm their age with it first, or the app was not installed from the platform's store.

## Design for Unavailable

**Unavailable** is a normal outcome for an Age Gate, because it depends on what the user's phone platform can share. Plan for it:

* **Keep a fallback.** Route Unavailable to the age check you use today. Treat the Age Gate as the fast path for users whose phone can answer.
* **Never say "underage".** Unavailable means "not established". Use neutral copy such as "We couldn't confirm your age with BotShield."
* **Fail closed.** For a restricted action, anything other than a positive result means no access on this proof.
* **Let people retry.** A user who declined to share their age range, or who needs to confirm their age with their platform first, can fix that and try again.
* **Watch the rate.** The gate's **Overview** in the Console shows **Over N Verified** and **Age Unavailable** counts, and the Verification Logs label every row.

## Next steps

<CardGroup cols={2}>
  <Card title="Place a gate" icon="location-dot" href="/gate/place-a-gate">
    Create the Age Gate in the Console.
  </Card>

  <Card title="Verify on your server" icon="server" href="/gate/verify-on-your-server">
    Token checks, status polling and the server-to-server flow.
  </Card>

  <Card title="Testing" icon="flask" href="/gate/testing">
    See the age fields in the Console Sandbox.
  </Card>

  <Card title="Privacy boundary" icon="user-shield" href="/concepts/privacy-boundary">
    What crosses to you, and what never does.
  </Card>
</CardGroup>
