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

# Token and webhooks

> The trust claims on the attestation token, and the webhook events that report a secured account, an unlink, and a declined request.

Your server learns about a Secured Account in two ways: the **attestation token** that the widget hands to your page, and the **webhook events** that BotShield sends to your endpoint. Use the token for the decision in the request. Use the webhooks to keep your records current, because an account can be unlinked when the person is not on your site.

Every payload on this page carries an opaque handle or a request ID. None carries an email address, a name, a device, a BotShield ID, or any account on another platform.

## The token

The attestation token is the same signed JWT that BotShield Gate issues. See [Verify on your server](/gate/verify-on-your-server) for the format, the issuer, the 120-second lifetime and the published keys. Secured Accounts adds three claims.

| Claim | Type | Description |
| - | - | - |
| `trusted` | boolean | `true` when an active link exists for your platform and this `platform-user-ref` after this confirmation. Always present. `false` on a plain gate pass for an account that is not secured. |
| `trusted_since` | string (ISO 8601) | When the account was secured. Present only when `trusted` is `true`. |
| `last_pass_at` | string (ISO 8601) | When the human made the pass that produced this token. Present only when `trusted` is `true`. |

A token without `trusted_since` is never trusted. Do not read a missing date as "secured since the beginning".

### Verify with the API

`POST /sdk/verify-token` needs no API key. Pass your organization ID as `audience` and a token issued for any other organization is rejected.

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

```json theme={null}
{
  "data": {
    "data": {
      "valid": true,
      "verdict": "pass",
      "replayed": false,
      "claims": {
        "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
        "verified": true,
        "organization_id": "org_...",
        "audience": "org_...",
        "timestamp": "2026-10-20T17:04:05.000Z",
        "nonce": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b_1792515845000",
        "issued_at": 1792515845,
        "expires_at": 1792515965,
        "trusted": true,
        "trusted_since": "2026-10-20T17:04:05.000Z",
        "last_pass_at": "2026-10-20T17:04:05.000Z"
      }
    }
  }
}
```

The result sits under `data.data`. An error arrives with HTTP `200` as `data.error`. An invalid or expired token is a normal result with `valid: false` and a `reason`.

`replayed: true` means this token was checked before. A token is meant to be checked once. From November 15, 2026, a second check returns `valid: false`.

### Verify locally

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 },
);

const MY_ORGANIZATION_ID = process.env.BOTSHIELD_ORGANIZATION_ID!;

export async function readTrust(token: string, expectedRequestId: 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.organization_id !== MY_ORGANIZATION_ID) throw new Error("wrong organization");
  if (payload.request_id !== expectedRequestId) throw new Error("wrong request");

  return {
    trusted: payload.trusted === true,
    trustedSince: (payload.trusted_since as string | undefined) ?? null,
    lastPassAt: (payload.last_pass_at as string | undefined) ?? null,
  };
}
```

### What to check

1. The token is valid and not expired.
2. `organization_id` is your organization.
3. `request_id` is the one your page sent with the token.
4. `trusted` is `true`.

The token does not name the account. Your server knows which account it is because your signed-in user sent the token. Bind the result to that session.

### When there is no token

`GET /verification/status` reports the same result by `request_id`. After the request completes, the response carries `trusted` and `first_time`. For a refused or failed request, `reason` is `already_trusted`, `rebind_requires_prior_id` or `unavailable`.

## Webhook events

Set up delivery and signature verification first. See the [Webhooks overview](/webhooks/overview).

| Event type | Sent when |
| - | - |
| `account.secured` | A person secured an account on your platform. New in 3.3. |
| `account.unlinked` | A link ended, because the person unlinked or your platform revoked. |
| `gate.human_verified` | A human completed a confirmation. `trusted`, `first_time` and `handle` say whether the account is secured. |
| `gate.unavailable` | A confirmation did not complete. This includes a person who declined, and a refusal. |

<Warning>
  **Check your endpoint's event filter.** An endpoint that is subscribed to specific event types receives only those types. It does not receive `account.secured` or `account.unlinked` until you add them. An endpoint with no filter receives every type. Open your endpoint in the Console under **Settings**, **Developer Tools**, **Webhooks**, and add the new types.
</Warning>

<Note>
  `account.secured` and `account.unlinked` have no `environment` field, and the handle is the same in Development and Production. One endpoint receives both environments. If you test with Development keys against the endpoint your production system reads, account events from your tests arrive there too.
</Note>

### `account.secured`

Sent once when a person secures an account: a new link, not a later pass on the same link.

```json theme={null}
{
  "type": "account.secured",
  "handle": "OP_QDhs65484684",
  "secured_at": "2026-10-20T17:04:05.000Z",
  "surface_id": null
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `account.secured`. |
| `handle` | string | The opaque handle for this person on your platform: `OP_` followed by 12 characters. Store it beside your user. |
| `secured_at` | string (ISO 8601) | When the account was secured. |
| `surface_id` | string or null | The Link with BotShield ID surface the account was secured on. `null` when the link came through a gate. |

The event does not carry your `platform-user-ref`. To connect it to your user, match it to the `gate.human_verified` event for the same confirmation, which carries the same `handle` and the `request_id` your page received, or search the Console registry by your user reference.

### `account.unlinked`

Sent when a link ends. The next confirmation on that account is a first-time confirmation again.

```json theme={null}
{
  "type": "account.unlinked",
  "handle": "OP_QDhs65484684",
  "by": "human",
  "unlinked_at": "2026-10-24T09:12:40.000Z",
  "reason": "human",
  "surface_id": null
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `account.unlinked`. |
| `handle` | string | The same handle that `account.secured` carried. The Console registry shows the same value. |
| `by` | string | `human` when the person ended the link in the BotShield app. `platform` when your organization revoked. |
| `unlinked_at` | string (ISO 8601) | When the link ended. |
| `reason` | string | `human`, `platform` or `credential_changed`. See below. |
| `surface_id` | string or null | The Link with BotShield ID surface the account was secured on, the same value `account.secured` carried. `null` for a link made through a gate. |

| `by` | `reason` | What happened |
| - | - | - |
| `human` | `human` | The person selected **Unlink** in the BotShield app, or deleted their BotShield account. |
| `platform` | `platform` | Someone in your organization selected **Revoke** in the Console registry. BotShield notifies the person in the BotShield app. |
| `platform` | `credential_changed` | A revoke that reported a changed sign-in detail on your side. The person's app shows the account as unlinked, with a way to link it again. The Console's **Revoke** does not send it. |

### `gate.human_verified`

Secured Accounts adds three fields to this event.

```json theme={null}
{
  "type": "gate.human_verified",
  "event_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
  "request_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
  "verified_at": "2026-10-20T17:04:05.120Z",
  "verdict": "pass",
  "trusted": true,
  "first_time": true,
  "handle": "OP_QDhs65484684",
  "environment": "production",
  "product": "census"
}
```

| Field | Type | Description |
| - | - | - |
| `trusted` | boolean | `true` when an active link exists for your platform and this `platform-user-ref` after this confirmation. Always present. `false` on a plain gate pass. |
| `first_time` | boolean | `true` when **this** confirmation created the link. `false` on every later pass. Always present. |
| `handle` | string, optional | The account's handle. Present only when `trusted` is `true`. |

| `trusted` | `first_time` | Meaning |
| - | - | - |
| `true` | `true` | The person secured the account now. `account.secured` is also sent. |
| `true` | `false` | A human passed on an account that was already secured. |
| `false` | `false` | A human passed. The account is not secured. |

The other fields are described in [Webhook events](/webhooks/events#gate-human_verified). (`census` is the API's name for BotShield Gate.)

### `gate.unavailable`

A request to secure an account that does not complete ends with this event.

```json theme={null}
{
  "type": "gate.unavailable",
  "event_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
  "request_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
  "failed_at": "2026-10-20T17:05:10.000Z",
  "verdict": "unavailable",
  "reason": "internal_error",
  "error_message": "already_trusted",
  "failure_code": "already_trusted",
  "failure_name": "account_already_secured_here",
  "environment": "production",
  "product": "census"
}
```

| Case | Fields |
| - | - |
| The person selected **Cancel** when BotShield asked | `reason: "user_denied"`, `failed_at` |
| The confirmation was refused because of a link conflict | `reason: "internal_error"`, `failed_at`, `failure_code` set to `already_trusted` or `rebind_requires_prior_id`, and `failure_name` set to `account_already_secured_here` or `account_held_by_another_id` |
| The request expired | `expired_at`, and no `reason` |

A request produces one terminal event. Use `request_id` as your idempotency key.

## Keep your records current

| When you receive | Update your user record |
| - | - |
| A valid token with `trusted: true` | Mark the account secured, and store `trusted_since`. |
| `account.secured` | Store the `handle` beside your user. |
| `account.unlinked` | Find the user by `handle` and mark the account not secured. Show the chip again on the next sign-in. |

Do not rely on webhooks alone. A later gate pass on the account reports the current `trusted` value in its token. See [Staying in sync](/secured-accounts/staying-in-sync) for the whole pattern.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.