> ## 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 Trusted 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. Trusted Accounts adds three claims.

| Claim | Type | Description |
| - | - | - |
| `trusted` | boolean | `true` when an active binding 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 "trusted since the beginning".

### Verify with the API

`POST /sdk/verify-token` needs no API key.

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

```json theme={null}
{
  "data": {
    "data": {
      "valid": true,
      "claims": {
        "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
        "verified": true,
        "organization_id": "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"
      }
    }
  }
}
```

### 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 |
| - | - |
| `gate.human_verified` | A human completed a confirmation. `trusted` and `first_time` say whether an account was secured. |
| `account.unlinked` | A binding ended, because the person unlinked or your platform revoked. |
| `gate.unavailable` | A confirmation did not complete. This includes a person who declined to secure the account. |

<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.unlinked` until you add it. An endpoint with no filter receives every type. Open your endpoint in the Console under **Settings**, **Developer Tools**, **Webhooks**, and add the new type.
</Warning>

### `gate.human_verified`

The event gains two fields. Both are always present.

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

| Field | Type | Description |
| - | - | - |
| `trusted` | boolean | `true` when an active binding exists for your platform and this `platform-user-ref` after this confirmation. `false` on a plain gate pass. |
| `first_time` | boolean | `true` when **this** confirmation created the binding. `false` on every later pass by the same person on the same account. |

| `trusted` | `first_time` | Meaning |
| - | - | - |
| `true` | `true` | The person secured the account now. |
| `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.)

### `account.unlinked`

Sent when a binding 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"
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `account.unlinked`. |
| `handle` | string | The opaque handle for this person on your platform: `OP_` followed by 12 characters. It is the same value the Console registry shows. |
| `by` | string | `human` when the person unlinked in the BotShield app. `platform` when your organization revoked. |
| `unlinked_at` | string (ISO 8601) | When the binding ended. |
| `reason` | string | `human`, `platform` or `credential_changed`. See below. |

| `by` | `reason` | What happened |
| - | - | - |
| `human` | `human` | The person selected **Unlink** in the BotShield app. |
| `platform` | `platform` | Someone in your organization selected **Revoke** in the Console registry. BotShield notifies the person in the BotShield app. |
| `platform` | `credential_changed` | Reserved. The Console's **Revoke** does not send it. |

<Note>
  The event identifies the account by `handle`, not by your `platform-user-ref`. BotShield does not keep your reference in a readable form, so it cannot send it back. To find the handle for one of your users, search the Console registry by your user reference. The matching row shows the handle.
</Note>

### `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",
  "reason": "user_denied",
  "error_message": "declined",
  "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 binding conflict | `reason: "internal_error"`, `failed_at`, and `failure_code` set to `already_trusted` or `rebind_requires_prior_id` |
| The request expired | `expired_at`, and no `reason` |

`failure_code` is present only for the two binding conflicts. 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 trusted, and store `trusted_since`. |
| `gate.human_verified` with `first_time: true` | The same, when you correlate by `request_id`. |
| `account.unlinked` | Mark the account not trusted. Show the offer 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.
