> ## 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.

# Verify on your server

> Check a widget result on your backend, or run the whole verification server-to-server with your API key.

A result in the browser is a hint. The decision belongs on your server. There are two patterns:

* **Pattern A: verify the widget's result.** The [web component](/gate/web-component) runs the verification and your server checks the token or request ID it produced.
* **Pattern B: server-to-server.** Your server creates the verification request, you show the link in your own UI, and you learn the result by webhook or polling.

All calls go to `https://api.botshield.ai/operations`. Every operation answers HTTP `200` with a `data` envelope, and handler errors arrive inside it as `data.error`, also with HTTP `200`. Check `data.error` before you read the payload. See [API overview](/api-reference/overview) and [Errors](/api-reference/errors).

<Note>
  **TypeScript SDK.** Pass `serverURL` when you construct the client; the package does not default to production. `apiKeyAuth` is sent as the `Authorization` header exactly as you give it, so include the `Bearer ` prefix. The SDK does not unwrap the envelope, and it uses camelCase field names. See [TypeScript SDK](/sdk/typescript).
</Note>

## Pattern A: verify the widget's result

The widget's `botshield:success` event gives your page a `request_id` and a `token`. The token is a signed JWT or `null`; it is never a request ID. Send both to your server with the action they protect.

| What your server received | What to do |
| - | - |
| `token` is a JWT | [Verify the token](#verify-the-token), remotely or locally. |
| `token` is `null` (Recent Presence fast path) | [Read the request status](#when-there-is-no-token) by `request_id` right away. |
| `token` fails verification | Treat the user as unverified. If you have a `request_id`, fall back to the status check. |

### The attestation token

| Property | Value |
| - | - |
| Format | JWT signed with **ES256**; the `kid` header names the signing key |
| Issuer (`iss`) | `https://api.botshield.ai` |
| Lifetime | **120 seconds** (`iat` to `exp`) |
| Public keys | `https://api.botshield.ai/.well-known/jwks.json` (cache for 5 minutes) |

| Claim | Description |
| - | - |
| `request_id` | The verification request, `req_…`. |
| `verified` | Always `true` in an issued token. |
| `organization_id` | Your organization. The Console shows the same value as **Organization ID** under **Settings → Developer Tools → API Keys**, and `create-session` returns it as `organization.id`. |
| `timestamp` | When the human confirmed, ISO 8601. |
| `nonce` | Unique per token. |
| `age_over` | `13`, `18` or `21`. Present only on an [Age Gate](/gate/age-gate) request, when an age threshold was met. A Human Gate token never carries it. |

The token describes the event, never the person. It has no subject, no user ID and no email. It also has no gate claim: see [What to check](#what-to-check).

### Verify the token

<Tabs>
  <Tab title="With the API">
    `POST /sdk/verify-token` needs no API key. The token is the proof.

    <CodeGroup>
      ```bash curl theme={null}
      curl -s https://api.botshield.ai/operations/sdk/verify-token \
        -H "Content-Type: application/json" \
        -d '{"token":"eyJhbGciOiJFUzI1NiIsImtpZCI6…"}'
      ```

      ```typescript TypeScript SDK theme={null}
      import { BotShield } from "botshield-sdk";

      const botshield = new BotShield({
        serverURL: "https://api.botshield.ai/operations",
      });

      export async function checkToken(token: string) {
        const result = await botshield.census.verifyToken({}, { token });

        if (result.data.error) {
          throw new Error(result.data.error.message);
        }

        const { valid, reason, claims } = result.data.data!;
        if (!valid) return { ok: false as const, reason };

        return { ok: true as const, requestId: claims!.requestId!, organizationId: claims!.organizationId! };
      }
      ```
    </CodeGroup>

    (`census` is the API's name for BotShield Gate.)

    A valid token:

    ```json theme={null}
    {
      "data": {
        "data": {
          "valid": true,
          "claims": {
            "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
            "verified": true,
            "organization_id": "org_…",
            "timestamp": "2026-09-21T17:04:05.000Z",
            "nonce": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b_1790010245000",
            "issued_at": 1790010245,
            "expires_at": 1790010365
          }
        }
      }
    }
    ```

    An expired or invalid token still answers HTTP `200`, with `valid: false`:

    ```json theme={null}
    {
      "data": {
        "data": {
          "valid": false,
          "reason": "Token has expired",
          "expired_at": "2026-09-21T17:06:05.000Z",
          "claims": { "request_id": "req_4b1f…", "verified": true, "nonce": "req_4b1f…_1790010245000" }
        }
      }
    }
    ```

    For a bad signature, `reason` is `"Invalid token — signature verification failed"` and there are no claims.
  </Tab>

  <Tab title="Locally with the JWKS">
    Verify the signature yourself with any JWT library that supports ES256. This example uses [`jose`](https://www.npmjs.com/package/jose):

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

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

    const MY_ORGANIZATION_ID = process.env.BOTSHIELD_ORGANIZATION_ID!;

    export async function verifyBotShieldToken(token: string) {
      // Throws if the signature, issuer, algorithm or expiry is wrong.
      const { payload } = await jwtVerify(token, JWKS, {
        issuer: "https://api.botshield.ai",
        algorithms: ["ES256"],
      });

      if (payload.verified !== true) throw new Error("BotShield: not verified");
      if (payload.organization_id !== MY_ORGANIZATION_ID) throw new Error("BotShield: wrong organization");

      return {
        requestId: payload.request_id as string,
        ageOver: (payload.age_over as 13 | 18 | 21 | undefined) ?? null,
      };
    }
    ```

    Local verification is the only way to read the `age_over` claim from the token today.
  </Tab>
</Tabs>

### When there is no token

On a gate in **Recent Presence** mode, a returning human can pass without a phone step. The widget's success event then has `token: null`, `via: "precheck"` and a `request_id`. Confirm it from your server with `GET /verification/status`, which needs no API key:

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

  ```typescript TypeScript SDK theme={null}
  import { BotShield } from "botshield-sdk";

  const botshield = new BotShield({
    serverURL: "https://api.botshield.ai/operations",
  });

  export async function isVerified(requestId: string, gateKey: string, organizationId: string) {
    const result = await botshield.verification.getStatus({}, { requestId });
    const s = result.data; // verification/status has no inner "data" level

    if (s.error || !s.found) return false;
    if (s.organizationId !== organizationId || s.scope !== gateKey) return false;

    return s.status === "completed" || s.status === "pass";
  }
  ```
</CodeGroup>

```json theme={null}
{
  "data": {
    "found": true,
    "status": "pass",
    "request_id": "req_9f2c4e7a1b3d5f60a1b2c3d4",
    "organization_id": "org_…",
    "scope": "checkout",
    "created_at": "2026-09-21T17:04:05.000Z",
    "expires_at": "2026-09-21T17:05:05.000Z",
    "verified_at": null,
    "verification_token": null,
    "gate_type": "human"
  }
}
```

<Warning>
  A fast-path record reads as `pass` for **60 seconds**, then as `expired`. Call the status endpoint as soon as your server receives the `request_id`. Accept `completed` **or** `pass` as Verified, and never test for `completed` alone. If the record already reads `expired`, ask the user to verify again. No webhook is sent for a fast-path pass.
</Warning>

If your server must hold a signed token for every verification, set the gate to **Live**. A Live gate always produces a token.

### What to check

`verify-token` needs no credentials and is not bound to an audience. It answers `valid: true` for a genuine token issued for **any** organization, and it does not consume the token, which stays valid for its full 120 seconds. The same is true of a local signature check. So a valid signature is only the first test. Before you let the action through, confirm all of these on your server:

1. The token verifies (signature, issuer, not expired), or the status is `completed` or `pass`.
2. `organization_id` is yours. Copy your **Organization ID** from **Settings → Developer Tools → API Keys** in the Console and keep it in your configuration. A `create-session` response carries the same value as `organization.id`.
3. The `request_id` belongs to this action. In Pattern B, compare it with the request you created. In both patterns, record every `request_id` you accept and reject repeats, so one verification cannot be replayed for many actions.
4. The gate is the one you expect. The token carries no gate claim, so if you run several gates, or both environments, read `GET /verification/status` and compare `scope` with your gate key and `metadata.environment` with `production`.

## Pattern B: server-to-server

Use this when you draw your own UI, for a native app, a kiosk, or a back-office flow. Every request runs the live check on the user's phone; the Recent Presence fast path is a widget feature. Server-to-server verifications count in the gate's **Overview** metrics in the Console, the same as widget verifications.

```mermaid theme={null}
sequenceDiagram
    participant S as Your server
    participant A as BotShield API
    participant P as Your page
    participant Ph as User's phone (BotShield app)
    participant H as Your webhook endpoint
    S->>A: POST /sdk/create-session (API key)
    A-->>S: bss_ grant token (5 minutes, single use)
    S->>A: POST /sdk/create-verification-link (grant token, scope)
    opt This user has a pending request for this gate
        A-->>S: error 409
        S->>A: POST /sdk/revoke-verification, then start again
    end
    A-->>S: request_id, web_url, deep_link, expires_at
    S-->>P: Show web_url as a QR code or a link
    P->>Ph: User opens the link
    Ph->>A: User confirms with device biometric
    alt Webhook (preferred)
        A->>H: gate.human_verified with request_id
    else Polling
        S->>A: GET /verification/status?request_id=
        A-->>S: status completed, verification_token
    end
```

<Steps>
  <Step title="Create a session">
    Call `POST /sdk/create-session` with your API key: `bs_dev_…` for Development gates, `bs_production_…` for Production gates. BotShield resolves the gate in the environment of the key that calls it, so the same gate key can exist in Development and in Production as two independent gates.

    <CodeGroup>
      ```bash curl theme={null}
      curl -s https://api.botshield.ai/operations/sdk/create-session \
        -H "Authorization: Bearer $BOTSHIELD_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{"partner_user_id":"usr_48121"}'
      ```

      ```typescript TypeScript SDK theme={null}
      import { BotShield } from "botshield-sdk";

      const botshield = new BotShield({
        serverURL: "https://api.botshield.ai/operations",
      });

      const apiKeyAuth = `Bearer ${process.env.BOTSHIELD_API_KEY}`;

      const session = await botshield.census.createSession(
        { apiKeyAuth },
        { partnerUserId: "usr_48121" },
      );
      if (session.data.error) throw new Error(session.data.error.message);

      const grantToken = session.data.data!.anchorGrantToken; // "bss_…"
      ```
    </CodeGroup>

    | Request field | Type | Description |
    | - | - | - |
    | `partner_user_id` | string, optional | Your identifier for the user. While this user has a pending request for a gate, BotShield refuses a second one for the same gate (the `409` case below). Stored only as a one-way hash. |
    | `metadata` | object, optional | Your own key-value data, carried onto the verification request. |

    ```json theme={null}
    {
      "data": {
        "data": {
          "anchor_grant_token": "bss_5e1c…",
          "anchor_grant_expires_at": "2026-09-21T17:09:05.000Z",
          "anchor_grant_expires_in_seconds": 300,
          "organization": { "id": "org_…", "environment": "production" }
        }
      }
    }
    ```

    The same three values are also returned under the older names `session_token`, `expires_at` and `expires_in_seconds`. The grant token lives **5 minutes** and works **once**: the next step consumes it. It only authorizes creating one verification request; it is not a user session.
  </Step>

  <Step title="Create the verification request">
    Call `POST /sdk/create-verification-link` with the **grant token** as the Bearer credential, not your API key.

    <CodeGroup>
      ```bash curl theme={null}
      curl -s https://api.botshield.ai/operations/sdk/create-verification-link \
        -H "Authorization: Bearer bss_5e1c…" \
        -H "Content-Type: application/json" \
        -d '{
          "scope": "checkout",
          "mode": "private",
          "partner_user_ref": "usr_48121",
          "return_url": "https://www.meridianairlines.com/booking/MA204",
          "metadata": { "booking_ref": "MA204-7731" }
        }'
      ```

      ```typescript TypeScript SDK theme={null}
      const link = await botshield.census.createVerificationLink(
        { grantTokenAuth: `Bearer ${grantToken}` }, // the bss_ grant token goes here
        {
          scope: "checkout",
          mode: "private",
          partnerUserRef: "usr_48121",
          returnUrl: "https://www.meridianairlines.com/booking/MA204",
          metadata: { booking_ref: "MA204-7731" },
        },
      );

      if (link.data.error) {
        // statusCode 409: this user has a pending request for this gate
        throw new Error(`${link.data.error.statusCode}: ${link.data.error.message}`);
      }

      const { requestId, webUrl, deepLink, expiresAt } = link.data.data!;
      ```
    </CodeGroup>

    | Request field | Type | Description |
    | - | - | - |
    | `scope` | string | The gate **Key**, case-sensitive. The gate must be **Active** in the API key's environment. Always send it. |
    | `mode` | `private` or `linked-account` | How the user confirms in the app. `private` is the passkey-only flow the widget uses, with no account sign-in. The API default is `linked-account`. |
    | `partner_user_ref` | string, optional | Your identifier for the user. If BotShield already knows this user from an earlier verification with you, it sends the request to their phone as a push notification. Stored only as a one-way hash. Send the same value you used for `partner_user_id`. When both are set, this value identifies the user for the [`409` rule](#the-409-case). |
    | `link_on_verify` | boolean, default `true` | On success, remember that `partner_user_ref` belongs to this human so later requests can use push. Set `false` to opt out. |
    | `parent_request_id` | string, optional | The `request_id` of an earlier request in the same user flow, to tie the two together in your logs. |
    | `return_url` | string, optional | A URL on your site for this request. It is stored and echoed in `metadata.return_url` on the status response. Do not rely on a redirect to it; learn the result by webhook or polling. |
    | `metadata` | object, optional | Your own key-value data. The gate webhooks deliver exactly the keys you supplied in their `metadata` object, except keys that collide with a reserved name. See [Webhook events](/webhooks/events). Do not put personal data in it. |

    ```json theme={null}
    {
      "data": {
        "data": {
          "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
          "web_url": "https://app.botshield.ai/verify?request_id=req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b&mode=private",
          "deep_link": "botshield://verify?request_id=req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b&mode=private",
          "expires_at": "2026-09-21T17:09:05.000Z",
          "scope": "checkout",
          "auth_mode": "private",
          "gate_type": "human",
          "pushed_to_devices": 1,
          "organization": { "id": "org_…" }
        }
      }
    }
    ```

    | Response field | Description |
    | - | - |
    | `request_id` | Your handle for this verification: `req_` plus 32 hex characters. Store it. |
    | `web_url` | An `https` link that opens the BotShield app. Show it as a QR code on desktop, or as a link on mobile. |
    | `deep_link` | The same request as a `botshield://` app link, for use when you know the app is installed. |
    | `expires_at` | The request's own expiry, the same instant `GET /verification/status` reports as `expires_at`. Stop waiting at this time. A verification request lives **5 minutes**. |
    | `pushed_to_devices` | How many of the user's devices received a push notification. When it is greater than `0`, tell the user to check their phone, and keep the QR code as a fallback. |
    | `gate_type` | `human` or `age`. An Age Gate also returns `age_threshold`. |

    Errors arrive as `data.data.error` with HTTP `200`:

    | `statusCode` | Cause |
    | - | - |
    | `401` | The grant token is missing, expired or already used. Create a new session. |
    | `400` | The key's environment has no Active gate with this `scope`. Check the key, the gate's state, and that your API key and gate are in the same environment. |
    | `409` | This user has a pending request for this gate. See [below](#the-409-case). |
    | `403` | This API key is not allowed to use that gate. |
  </Step>

  <Step title="Show the link to the user">
    On desktop, render `web_url` as a QR code with your own QR library and ask the user to scan it with their phone camera. On mobile, open `web_url` or `deep_link`. The user needs a BotShield ID — `web_url` opens the BotShield web app (public beta), where a first-time user creates a passkey with no download; `deep_link` opens the BotShield app if they have it. They confirm with their device biometric and are handed back to what they were doing.
  </Step>

  <Step title="Learn the result">
    <Tabs>
      <Tab title="Webhook (preferred)">
        Add an endpoint in the Console under **Settings → Developer Tools → Webhooks** and subscribe to `gate.human_verified` and `gate.unavailable`. Match the delivery to your request by `request_id`.

        ```json theme={null}
        {
          "type": "gate.human_verified",
          "event_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
          "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
          "verified_at": "2026-09-21T17:05:12.000Z",
          "environment": "production",
          "product": "census",
          "metadata": { "booking_ref": "MA204-7731" }
        }
        ```

        One endpoint receives deliveries from both environments, so branch on the top-level `environment` (`development` or `production`). `metadata` holds only the keys you supplied. Verify the signature on every delivery, and treat deliveries as at-least-once. See [Webhooks](/webhooks/overview) and [Webhook events](/webhooks/events) for the full payloads and the `gate.unavailable` reasons.

        <Warning>
          Keep your own timer. BotShield sends `gate.unavailable` when the BotShield app reports a failure, for example when the person cancels the biometric prompt, and when a request that was opened fails. If the user never opens the link, the request simply expires and **no webhook is sent**. When `expires_at` passes with no `gate.human_verified`, treat the request as Unavailable.
        </Warning>
      </Tab>

      <Tab title="Polling">
        Call `GET /verification/status` every 3 to 5 seconds until the status is final or `expires_at` passes. It needs no API key.

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

          ```typescript TypeScript SDK theme={null}
          async function waitForResult(requestId: string, expiresAt: Date) {
            while (Date.now() < expiresAt.getTime()) {
              const result = await botshield.verification.getStatus({}, { requestId });
              const s = result.data;

              if (s.status === "completed" || s.status === "pass") {
                return { verified: true as const, token: s.verificationToken ?? null };
              }
              if (s.status !== "pending") {
                return { verified: false as const, status: s.status };
              }
              await new Promise((r) => setTimeout(r, 4000));
            }
            return { verified: false as const, status: "expired" };
          }
          ```
        </CodeGroup>

        ```json theme={null}
        {
          "data": {
            "found": true,
            "status": "completed",
            "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
            "organization_id": "org_…",
            "scope": "checkout",
            "created_at": "2026-09-21T17:04:05.000Z",
            "expires_at": "2026-09-21T17:09:05.000Z",
            "verified_at": "2026-09-21T17:05:12.000Z",
            "verification_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6…",
            "error_message": null,
            "gate_type": "human",
            "age_threshold": null,
            "age_verdict": null,
            "age_source": null,
            "metadata": {
              "scope": "checkout",
              "environment": "production",
              "return_url": "https://www.meridianairlines.com/booking/MA204",
              "parent_request_id": null
            }
          }
        }
        ```

        The age fields are filled only for an [Age Gate](/gate/age-gate). For a Human Gate, `age_threshold`, `age_verdict` and `age_source` stay `null`. The response can carry other fields. Ignore any that are not listed here.
      </Tab>
    </Tabs>
  </Step>
</Steps>

### Status values

`verification/status` returns its payload directly under `data`, with no second `data` level.

| `status` | Meaning | Result state |
| - | - | - |
| `pending` | Waiting for the user. Keep waiting until `expires_at`. | — |
| `completed` | The user confirmed. `verification_token` holds the signed token. | **Verified** |
| `pass` | A widget fast-path pass. Readable for 60 seconds. | **Verified** |
| `expired` | The request passed `expires_at` without completing. | **Unavailable** |
| `failed` | The verification ended in an error. `error_message` may say why. | **Unavailable** |
| `not_found` | No request has this ID. `found` is `false`. | **Unavailable** |
| `error` | The lookup itself failed. Retry. | — |

The status response never contains identity fields. It reports the request, not the person.

### The 409 case

BotShield allows one **pending** request per user and gate. The user is the `partner_user_ref` you send to `create-verification-link`, or else the `partner_user_id` you sent to `create-session`. While that user has a pending, unexpired request for the gate, a second `create-verification-link` for the same gate returns:

```json theme={null}
{
  "data": {
    "data": {
      "error": {
        "message": "An active or pending verification already exists for this scope and user. Wait for it to expire or complete before creating another.",
        "statusCode": 409
      }
    }
  }
}
```

If the user abandoned the first request, revoke it with your API key and start again from `create-session` (the grant token you used for the rejected call is still unused, so you can also reuse it within its 5 minutes).

<CodeGroup>
  ```bash curl theme={null}
  curl -s https://api.botshield.ai/operations/sdk/revoke-verification \
    -H "Authorization: Bearer $BOTSHIELD_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"scope":"checkout","partner_user_id":"usr_48121"}'
  ```

  ```typescript TypeScript SDK theme={null}
  const revoked = await botshield.census.revokeVerification(
    { apiKeyAuth },
    { scope: "checkout", partnerUserId: "usr_48121" },
  );
  if (revoked.data.error) throw new Error(revoked.data.error.message);

  console.log(revoked.data.data!.revokedCount);
  ```
</CodeGroup>

```json theme={null}
{
  "data": {
    "data": {
      "success": true,
      "revoked_count": 1,
      "message": "1 pending verification(s) revoked. You may now create a new one."
    }
  }
}
```

Revoking expires the pending requests immediately. `revoke-verification` clears exactly the requests the `409` rule matches, the pending, unexpired ones for that user and gate, and returns how many in `revoked_count`. Pass the same identifier in `partner_user_id` that the request was created with.

A completed or failed request never blocks a new one, so you can ask the same user to verify at the same gate again as soon as the last request finishes. The rule applies only when the request names a user: leave `partner_user_id` off `create-session` and `partner_user_ref` off `create-verification-link` if you do not need it. It also does not apply to requests the [web component](/gate/web-component) makes with a site key.

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks" icon="bell" href="/webhooks/overview">
    Add an endpoint and verify deliveries.
  </Card>

  <Card title="TypeScript SDK" icon="js" href="/sdk/typescript">
    Install and configure `botshield-sdk`.
  </Card>

  <Card title="Age Gate" icon="calendar-check" href="/gate/age-gate">
    Read the age result on your server.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/api-reference/errors">
    The error envelope and status codes.
  </Card>
</CardGroup>
