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

# Webhooks — Payloads & Verification

> Anonymous verification events, and how to verify their Svix signature server-side

# Webhooks

BotShield delivers verification events to your endpoint via [Svix](https://svix.com). Each delivery is **cryptographically signed** — you verify the signature with your endpoint's signing secret, exactly as shown below. This is the recommended way to receive results; polling `verification/status` is supported but adds latency and cost.

## Anonymous by construction

Census performs one operation: anonymous human attestation — one operation, no modes. **No identity ever crosses the boundary** — verification payloads carry **no email, no `auth_mode`, no BotShield user id, and no identity-bearing token**. You learn *that a human verified*, never *who*. Rows correlate to your own system by `request_id` (and the `metadata` you supplied). To attach a known identity on your side, key off the `request_id` you generated when you created the verification.

## Event Types

| Event (`type`)             | When it fires                                                                                                                |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `census.human_verified`    | A fresh Face ID / passkey attestation completed for this request                                                             |
| `census.multipass_active`  | A returning human resolved via MultiPass continuity at the precheck — no fresh challenge was needed                          |
| `census.human_unavailable` | Verification could not complete — the user denied/cancelled, the flow errored, **or** the link TTL lapsed without a response |

There are exactly three events. `census.human_unavailable` merges the former failed and expired cases — distinguish them by the payload (`failed_at` + `reason` for a denial/error vs. `expired_at` for a TTL lapse).

Exactly one event fires per verification request. Once delivered, the request is terminal — no further events fire for that `request_id`.

<Note>
  **Result states in the webhook.** Census exposes three result states — **Human Verified**, **MultiPass Active**, and **Human Unavailable** — and the webhook now carries **its own event for each**: Human Verified = `census.human_verified`, MultiPass Active = `census.multipass_active`, Human Unavailable = `census.human_unavailable`. MultiPass Active no longer collapses into Human Verified — webhook-only partners receive `census.multipass_active` directly, mirroring the embed's synchronous `botshield:multipass-status` distinction. Human Unavailable covers both denial/error and TTL expiry; tell them apart by the payload fields (`failed_at` + `reason` vs. `expired_at`).
</Note>

## Payloads

> **`event_id` is the same value as `request_id`** — both are the `req_<hex>` attestation id. They are aliases for the one identifier; dedupe on either.

### `census.human_verified`

```json theme={null}
{
  "type": "census.human_verified",
  "event_id": "req_xyz789...",
  "request_id": "req_xyz789...",
  "verified_at": "2026-06-16T12:00:00Z",
  "product": "census",
  "metadata": { "order_id": "checkout-12345" }
}
```

### `census.multipass_active`

A returning human resolved via MultiPass continuity at the precheck — no fresh challenge was issued. Same anonymous payload shape as `census.human_verified`; this is the webhook-only mirror of the embed's synchronous MultiPass Active result.

```json theme={null}
{
  "type": "census.multipass_active",
  "event_id": "req_xyz789...",
  "request_id": "req_xyz789...",
  "verified_at": "2026-06-16T12:00:00Z",
  "product": "census",
  "metadata": { "order_id": "checkout-12345" }
}
```

### `census.human_unavailable`

Fires on a denial/error (carries `failed_at` + `reason`) **or** on a TTL lapse (carries `expired_at`). The two cases are distinguished by which timestamp is present.

Denied / errored:

```json theme={null}
{
  "type": "census.human_unavailable",
  "event_id": "req_xyz789...",
  "request_id": "req_xyz789...",
  "failed_at": "2026-06-16T12:00:12Z",
  "reason": "user_denied",
  "error_message": "Verification failed",
  "product": "census",
  "metadata": { "order_id": "checkout-12345" }
}
```

`reason` is one of: `user_denied`, `device_lock_required`, `internal_error`, `platform_declined`.

Expired (link TTL lapsed — default 10 minutes):

```json theme={null}
{
  "type": "census.human_unavailable",
  "event_id": "req_xyz789...",
  "request_id": "req_xyz789...",
  "expired_at": "2026-06-16T12:10:00Z",
  "product": "census",
  "metadata": { "order_id": "checkout-12345" }
}
```

### Fields

| Field                                      | Type           | Description                                                                                                                                    |
| ------------------------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`                                     | string         | The event name — `census.human_verified` \| `census.multipass_active` \| `census.human_unavailable`                                            |
| `event_id`                                 | string         | The attestation id — **the same `req_…` value as `request_id`** (alias). Dedupe on it for idempotency.                                         |
| `request_id`                               | string         | The verification request ID you received when you created the link — your join key                                                             |
| `verified_at` / `failed_at` / `expired_at` | string         | ISO 8601 timestamp of the state change. For `census.human_unavailable`, `failed_at` (with `reason`) or `expired_at` tells the two cases apart. |
| `reason`                                   | enum           | (`human_unavailable` denial/error only) `user_denied` \| `device_lock_required` \| `internal_error` \| `platform_declined`                     |
| `product`                                  | string         | `census` \| `drop` \| `q`                                                                                                                      |
| `metadata`                                 | object or null | Whatever `metadata` you included when creating the verification link — round-tripped back                                                      |

## Get your signing secret

1. In the **Console → Settings → Webhooks**, open the webhooks portal.
2. **Add an endpoint** with your HTTPS URL (e.g. `https://yourapp.com/api/botshield-webhook`).
3. Copy that endpoint's **Signing Secret** — it starts with `whsec_`. Store it as an environment variable; treat it like a password.

Each endpoint has its own secret. From the same portal you can send test events, inspect delivery logs, and replay failed deliveries.

## Verify the signature (server-side)

Every delivery includes three headers — `svix-id`, `svix-timestamp`, `svix-signature` — and is signed with your endpoint's `whsec_` secret. Verify with the official [`svix`](https://www.npmjs.com/package/svix) library. **You must pass the raw, unparsed request body** to the verifier (a re-serialized JSON object will not match the signature).

### Node / Express

```typescript theme={null}
import express from "express";
import { Webhook } from "svix";

const app = express();
const SIGNING_SECRET = process.env.BOTSHIELD_WEBHOOK_SECRET!; // whsec_...

// IMPORTANT: capture the raw body for this route — do NOT use express.json() here.
app.post(
  "/api/botshield-webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const wh = new Webhook(SIGNING_SECRET);

    let evt: any;
    try {
      // Throws if the signature is invalid or the timestamp is out of tolerance.
      evt = wh.verify(req.body, {
        "svix-id": req.header("svix-id")!,
        "svix-timestamp": req.header("svix-timestamp")!,
        "svix-signature": req.header("svix-signature")!,
      });
    } catch {
      return res.status(400).send("invalid signature");
    }

    // Idempotency — BotShield may retry. Dedupe before acting.
    if (await alreadyProcessed(evt.event_id)) return res.sendStatus(200);
    await markProcessed(evt.event_id);

    switch (evt.type) {
      case "census.human_verified":
        // A fresh Face ID / passkey attestation completed.
        // Correlate to your order via request_id / metadata.
        await onVerified(evt.request_id, evt.metadata);
        break;
      case "census.multipass_active":
        // A returning human resolved via MultiPass continuity — no fresh
        // challenge. Treat as a pass, same as human_verified.
        await onVerified(evt.request_id, evt.metadata);
        break;
      case "census.human_unavailable":
        // Could not complete. Distinguish denial/error from TTL expiry by
        // which timestamp is present.
        if (evt.expired_at) {
          await onExpired(evt.request_id);
        } else {
          await onFailed(evt.request_id, evt.reason); // failed_at + reason
        }
        break;
    }

    return res.sendStatus(200); // ack (empty body is fine)
  }
);
```

The `svix` library is also available for **Python**, **Go**, **Rust**, **Java/Kotlin**, **Ruby**, **C#**, and **PHP** — the verification call is identical in shape (`Webhook(secret).verify(rawBody, headers)`). See the [Svix docs](https://docs.svix.com/receiving/verifying-payloads/how) for per-language snippets.

> **Why the signature, not a token?** Authenticity comes from the Svix envelope signature over the whole payload — there is no identity-bearing token to validate, and nothing in the body to decode for a user. Verify the signature, then trust the `type` + `request_id`.

## Idempotency, delivery & retries

* **Idempotency** — dedupe on `event_id` (or the `svix-id` header). Retries reuse the same id.
* **Acknowledge** with any `2xx` (empty body fine). Non-2xx triggers retries with exponential backoff.
* **Inspect & replay** — every attempt is logged in the Console webhooks portal; you can replay failed deliveries there.

## Related

* [Human Presence](/concepts/human-presence) — the verdict + reason model and the user-facing result states
* [Trusted Account Signal](/concepts/trusted-account-signal) — how linked verified accounts strengthen MultiPass durability (internal UUID, never identity)
