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

# Webhook events

> Reference for every webhook event BotShield sends, with example payloads, field tables, and the names that replaced retired event types.

BotShield sends six event types today: two for BotShield Gate and four for Agents Ask. A seventh, `account.unlinked`, arrives with BotShield 3.0 for [Trusted Accounts](/trusted-accounts/overview). Every payload is a JSON object with a `type` field that names the event. Payloads never identify the human. To connect an event to your own records, use `request_id` and the `metadata` you supplied when you created the request. Set up delivery and signature verification first in the [Webhooks overview](/webhooks/overview).

| Event type | Sent when |
| - | - |
| `gate.human_verified` | A human completed the confirmation for a gate request. |
| `gate.unavailable` | The BotShield app reported a failure for a gate request, a request that was opened failed, or a request was opened after it expired. |
| `agents_ask.card.proposed` | Your agent's proposal was accepted and sent to the human. |
| `agents_ask.resolution.confirmed` | The human confirmed the proposed action. |
| `agents_ask.resolution.denied` | The human denied the proposed action. |
| `agents_ask.resolution.expired` | The proposal expired before the human acted. |
| `account.unlinked` | A Trusted Account binding ended. |

<Note>
  Payloads can gain fields over time. Ignore fields you do not recognize.
</Note>

<Warning>
  **Check your endpoint's event filter.** An endpoint that is subscribed to specific event types receives only those types. It does not receive a new type, or a type that replaced a retired one, until you add it. The portal keeps showing a retired type on an endpoint that was subscribed to it, but BotShield no longer sends that type. An endpoint with no filter receives every type. Open the endpoint under **Settings → Developer Tools → Webhooks** and select the current types.
</Warning>

### Retired event names

These names are no longer sent. If your code or your endpoint filter still holds one, replace it.

| Retired name | Replaced by |
| - | - |
| `census.human_verified` | `gate.human_verified` |
| `census.unavailable`, `census.human_unavailable` | `gate.unavailable` |
| `verification.success` | `gate.human_verified` |
| `verification.failed`, `verification.expired` | `gate.unavailable` |
| `q.card.proposed` | `agents_ask.card.proposed` |
| `q.resolution.confirmed` | `agents_ask.resolution.confirmed` |
| `q.resolution.denied` | `agents_ask.resolution.denied` |
| `q.resolution.expired` | `agents_ask.resolution.expired` |

## BotShield Gate events

Gate events refer to a verification request created with `POST /sdk/create-verification-link`. The `request_id` is the one that call returned (`req_` followed by 32 hex characters).

### Fields every gate event shares

| Field | Type | Description |
| - | - | - |
| `environment` | string | `development` or `production`: the environment of the key that created the request. One endpoint receives both, so branch on this field. |
| `product` | string, optional | `census` on every request made against a gate. (`census` is the API's name for BotShield Gate.) The key is left out when the request has no product, which is the case for a server-created request that names no gate. |
| `metadata` | object, optional | Only the keys you supplied. The key is left out when you supplied none. |

**About `metadata`.** The object holds the `metadata` you passed to `POST /sdk/create-session` and to `POST /sdk/create-verification-link`, and nothing else. When both of your calls set the same key, the create-verification-link value is used. When you pass no `metadata`, the payload has no `metadata` key.

<Note>
  **Reserved keys.** BotShield keeps its own request context under these names, so a key of yours with one of these names is dropped from the webhook: `auth_mode`, `auth_source`, `consumer_api_token_id`, `link_on_verify`, `webhook_url`, `return_url`, `environment`, `sdk_type`, `scope`, `scope_id`, `parent_request_id`, `gate_type`, `age_threshold`, `age_verdict`, `age_source`, `account_classes`. With 3.0 these names are reserved as well: `notarize`, `notarize_fallback_reason`, `trusted`, `first_time`, `failure_code`. Pick other names, such as `booking_ref` or `surface` in the examples below.
</Note>

**When a gate event is sent.** A request produces at most one `gate.human_verified`, sent when the human completes the confirmation on the phone. `gate.unavailable` is sent when the BotShield app reports a failure (for example the person cancels the biometric prompt), when a request that was opened fails, and when the person opens a request after it has expired. A request the person never opens sends no event, and neither does a Recent Presence pass that the widget reports with `via: "precheck"`. Treat "no verified result by `expires_at`" as Unavailable, and poll `GET /verification/status` when you need the outcome of a request nobody opened. See [Do not depend on webhooks alone](/webhooks/overview#do-not-depend-on-webhooks-alone).

### `gate.human_verified`

Sent when the human completes the confirmation in the BotShield app for this request.

```json theme={null}
{
  "type": "gate.human_verified",
  "event_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
  "request_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
  "verified_at": "2026-09-21T18:31:45.120Z",
  "environment": "production",
  "product": "census",
  "metadata": {
    "booking_ref": "MA-20418",
    "surface": "checkout"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `gate.human_verified`. |
| `event_id` | string | Always equal to `request_id`. Either works as your idempotency key. |
| `request_id` | string | The verification request this result belongs to. |
| `verified_at` | string (ISO 8601) | When the human completed the confirmation. |
| `environment` | string | `development` or `production`. |
| `product` | string, optional | `census` for a request made against a gate. |
| `metadata` | object, optional | The metadata you supplied. Left out when you supplied none. |
| `trusted` | boolean | `true` when the account is a [Trusted Account](/trusted-accounts/overview) after this confirmation. |
| `first_time` | boolean | `true` when this confirmation secured the account. |

<Info>
  See [Token and webhooks](/trusted-accounts/token-and-webhooks#gate-human_verified) for how `trusted` and `first_time` relate to Trusted Accounts.
</Info>

<Warning>
  On an **Age Gate**, `gate.human_verified` tells you a human completed the confirmation. It does not tell you the age threshold was met, and the payload does not carry the age result. The event is sent even when the age result is `unavailable`. Read `age_verdict` from `GET /verification/status`, or the `age_over` claim in the attestation token, before you unlock anything. See [Age Gate](/gate/age-gate).
</Warning>

### `gate.unavailable`

Sent when a request that reached the phone ends without a verified result. There are two shapes. Tell them apart by which timestamp is present: `failed_at` or `expired_at`. Both are terminal for that `request_id`. To try again, create a new request.

<Tabs>
  <Tab title="Failure">
    Sent when the BotShield app reports that the confirmation did not succeed, for example because the person cancelled the biometric prompt, and when a request that was opened fails on BotShield's side. `reason` says which.

    ```json theme={null}
    {
      "type": "gate.unavailable",
      "event_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
      "request_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
      "failed_at": "2026-09-21T18:32:10.004Z",
      "reason": "user_denied",
      "error_message": "User cancelled passkey verification",
      "environment": "production",
      "product": "census",
      "metadata": {
        "booking_ref": "MA-20418",
        "surface": "checkout"
      }
    }
    ```
  </Tab>

  <Tab title="Expiry">
    Sent when the user opens the request after its five-minute window has passed. There is no `reason` and no `error_message`.

    ```json theme={null}
    {
      "type": "gate.unavailable",
      "event_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
      "request_id": "req_5f2c8e1b9a7d4c3e8f1a2b3c4d5e6f70",
      "expired_at": "2026-09-21T18:37:02.551Z",
      "environment": "production",
      "product": "census",
      "metadata": {
        "booking_ref": "MA-20418",
        "surface": "checkout"
      }
    }
    ```
  </Tab>
</Tabs>

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `gate.unavailable`. |
| `event_id` | string | Always equal to `request_id`. |
| `request_id` | string | The verification request this result belongs to. |
| `failed_at` | string (ISO 8601) | Present on a failure. Never present together with `expired_at`. |
| `expired_at` | string (ISO 8601) | Present on an expiry. Never present together with `failed_at`. |
| `reason` | string | Failure only. One of the values below. |
| `error_message` | string, optional | Failure only. Human-readable detail for your logs. Do not parse it or show it to users. Branch on `reason`. |
| `environment` | string | `development` or `production`. |
| `product` | string, optional | `census` for a request made against a gate. |
| `metadata` | object, optional | Same rules as `gate.human_verified`. |
| `failure_code` | string, optional | `already_trusted` or `rebind_requires_prior_id`, present only when a request to secure an account was refused. `reason` is then `internal_error`. Not sent today. |

`reason` values:

| Value | Sent when |
| - | - |
| `user_denied` | The person cancelled or declined the confirmation on their phone, for example by dismissing the biometric prompt. With 3.0, also sent when the person selects **Cancel** on a request to secure an account. |
| `device_lock_required` | The phone reported that it has no passcode or screen lock, or that no biometric is set up, so it cannot confirm. See [Device security](/concepts/device-security). |
| `platform_declined` | The phone's platform refused the confirmation: the device or browser does not support it, or blocked it for a security reason. |
| `internal_error` | Anything else, including a failure on BotShield's side and a failure the app reported without a recognizable cause. This is the default. |

Use `reason` to choose what you tell the user and what you log. `user_denied` is a person's choice, so offer another try without comment. `device_lock_required` calls for a line of help about turning on a screen lock. `platform_declined` and `internal_error` call for a retry or your alternative path. Every `gate.unavailable` is terminal for its `request_id`, so keep the action blocked in all four cases. Handle a value you do not recognize as `internal_error`.

## Trusted Accounts events

<Info>
  An endpoint created before September 28, 2026 with a filter of specific event types must add `account.unlinked` to receive it. Endpoints that receive all event types need no change.
</Info>

### `account.unlinked`

Sent when a Trusted Account binding ends, because the person unlinked in the BotShield app or your organization revoked in the Console.

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

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

The payload has no `request_id`, no `event_id` and no `environment` field. It never carries your `platform-user-ref`, an email address or a BotShield ID. See [Token and webhooks](/trusted-accounts/token-and-webhooks#account-unlinked) for what each `by` and `reason` pair means.

## Agents Ask events

Agents Ask events refer to a proposal your agent created with `POST /agentlink/inquire`. Here `request_id` is the UUID **your agent supplied** as its idempotency key, so you can match events to your own records without storing anything BotShield generated. Agents Ask payloads do not echo partner metadata. On resolution events, `metadata` holds the BotShield context fields listed below.

### `agents_ask.card.proposed`

Sent when BotShield accepts a proposal and sends it to the human. It is informational. The outcome arrives later as one of the `agents_ask.resolution.*` events.

```json theme={null}
{
  "type": "agents_ask.card.proposed",
  "request_id": "0b6f4a52-3c1e-4d7a-9f28-6f0f0c1d2e3a",
  "card_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "agent_id": "3f1a2b4c-5d6e-4f80-91a2-b3c4d5e6f708",
  "agent_name": "MeridianRebooker",
  "category": "travel.rebook",
  "summary_title": "Rebook to MA 482, departing 18:05",
  "ttl_at": "2026-09-21T18:41:45.000Z"
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `agents_ask.card.proposed`. |
| `request_id` | string (UUID) | The `request_id` your agent sent. |
| `card_id` | string (UUID) | BotShield's ID for the proposal. Use it to deduplicate this event. |
| `agent_id` | string (UUID) | The registered agent that proposed the action. |
| `agent_name` | string | The agent's registered name. |
| `category` | string | The action category your agent sent. |
| `summary_title` | string | The title shown to the human. |
| `ttl_at` | string (ISO 8601) | When the proposal expires if the human has not acted. |
| `source` | string, optional | Present only on test proposals sent from the Console sandbox (`console_tester`). |

### `agents_ask.resolution.confirmed`

Sent when the human confirms the action with their device biometric. It carries the Proof of Resolution, a signed JWT. Verify `proof_token` before your agent acts. See [Proof of Resolution](/agents-ask/proof-of-resolution).

```json theme={null}
{
  "type": "agents_ask.resolution.confirmed",
  "request_id": "0b6f4a52-3c1e-4d7a-9f28-6f0f0c1d2e3a",
  "resolution_id": "a1b2c3d4-e5f6-4708-9a1b-2c3d4e5f6708",
  "outcome": "confirmed",
  "proof_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InYyIiwidHlwIjoiSldUIn0.eyJ2ZXJkaWN0IjoiYXBwcm92ZSJ9.c2lnbmF0dXJl",
  "metadata": {
    "card_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "ceremony_id": "d2719c0e-8a4b-4f6e-b1c3-9e5a7f2d4c68",
    "agent_id": "3f1a2b4c-5d6e-4f80-91a2-b3c4d5e6f708",
    "agent_name": "MeridianRebooker"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `agents_ask.resolution.confirmed`. |
| `request_id` | string (UUID) | The `request_id` your agent sent. |
| `resolution_id` | string (UUID) | Unique per resolution. Use it to deduplicate this event. |
| `outcome` | string | Always `confirmed`. |
| `proof_token` | string | The Proof of Resolution: an ES256 JWT. Inside the token the verdict claim reads `approve`. |
| `metadata.card_id` | string (UUID) | The proposal this resolution answers. |
| `metadata.ceremony_id` | string | Identifies the confirmation the human performed. Resolutions signed in the same confirmation share this value. Treat it as an opaque string. |
| `metadata.agent_id` | string (UUID) | The registered agent. |
| `metadata.agent_name` | string | The agent's registered name. |

### `agents_ask.resolution.denied`

Sent when the human denies the action. A denial is also a signed outcome, so the payload has the same shape as a confirmation, with `outcome` set to `denied` and a `proof_token` whose verdict claim reads `denied`. Stand the agent down.

```json theme={null}
{
  "type": "agents_ask.resolution.denied",
  "request_id": "0b6f4a52-3c1e-4d7a-9f28-6f0f0c1d2e3a",
  "resolution_id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
  "outcome": "denied",
  "proof_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InYyIiwidHlwIjoiSldUIn0.eyJ2ZXJkaWN0IjoiZGVuaWVkIn0.c2lnbmF0dXJl",
  "metadata": {
    "card_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "ceremony_id": "d2719c0e-8a4b-4f6e-b1c3-9e5a7f2d4c68",
    "agent_id": "3f1a2b4c-5d6e-4f80-91a2-b3c4d5e6f708",
    "agent_name": "MeridianRebooker"
  }
}
```

The fields match `agents_ask.resolution.confirmed`, except `type` is `agents_ask.resolution.denied` and `outcome` is `denied`.

### `agents_ask.resolution.expired`

Sent when the proposal reaches its `ttl_at` before the human acts. No Proof of Resolution is issued. Treat it as "no answer in time", not as a denial, and stand the agent down. To ask again, send a new proposal with a new `request_id`.

```json theme={null}
{
  "type": "agents_ask.resolution.expired",
  "request_id": "0b6f4a52-3c1e-4d7a-9f28-6f0f0c1d2e3a",
  "card_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "outcome": "expired",
  "metadata": {
    "agent_id": "3f1a2b4c-5d6e-4f80-91a2-b3c4d5e6f708",
    "agent_name": "MeridianRebooker",
    "summary_title": "Rebook to MA 482, departing 18:05"
  }
}
```

| Field | Type | Description |
| - | - | - |
| `type` | string | Always `agents_ask.resolution.expired`. |
| `request_id` | string (UUID) | The `request_id` your agent sent. |
| `card_id` | string (UUID) | The proposal that expired. Use it to deduplicate this event. |
| `outcome` | string | Always `expired`. |
| `metadata.agent_id` | string (UUID) | The registered agent. |
| `metadata.agent_name` | string | The agent's registered name. |
| `metadata.summary_title` | string | The title that was shown to the human. |

<Note>
  A proposal your agent cancels with `POST /agentlink/cancel` sends no webhook. Your agent already knows the outcome from the cancel response.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks overview" icon="webhook" href="/webhooks/overview">
    Endpoints, signature verification, retries, and deduplication.
  </Card>

  <Card title="Result states" icon="circle-check" href="/concepts/result-states">
    What Verified and Unavailable mean for your product.
  </Card>

  <Card title="Proof of Resolution" icon="signature" href="/agents-ask/proof-of-resolution">
    Verify `proof_token` against the BotShield JWKS.
  </Card>

  <Card title="Trusted Accounts" icon="stamp" href="/trusted-accounts/token-and-webhooks">
    The trust fields and the unlink event that arrive with 3.0.
  </Card>
</CardGroup>
