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

> Receive BotShield Gate and Agents Ask outcomes on your server, verify each delivery, and handle retries safely.

BotShield sends a signed HTTP `POST` to your server when a gate request or an Agents Ask proposal reaches an outcome. You add endpoints in the BotShield Console, verify each delivery with the `svix` library, deduplicate, and answer with a `2xx` quickly. Webhooks carry results only. No personal data about the human is included.

## How a delivery works

```mermaid theme={null}
sequenceDiagram
    participant B as BotShield API
    participant W as Your webhook endpoint
    participant D as Your database

    B->>W: POST event (svix-id, svix-timestamp, svix-signature)
    W->>W: Verify signature against the raw body
    alt Signature invalid
        W-->>B: 400
    else Signature valid
        W->>D: Seen this event before?
        alt Already processed
            W-->>B: 2xx (no work)
        else First time
            W->>D: Record the event key
            W-->>B: 2xx
            W->>W: Process in the background
        end
    end
    opt No 2xx within the timeout
        B->>W: Retry with increasing delay
    end
```

## Add an endpoint

<Steps>
  <Step title="Open the Webhooks tab">
    In the [BotShield Console](https://console.botshield.ai), go to **Settings → Developer Tools → Webhooks**. The tab embeds a webhooks portal for your organization. Use **Open in new tab** if you want more room.
  </Step>

  <Step title="Add your endpoint URL">
    Add an endpoint and enter a public `https://` URL on your server, for example `https://api.meridianairlines.example/webhooks/botshield`.
  </Step>

  <Step title="Choose event types">
    Subscribe the endpoint to the event types you need. An endpoint with no filter receives every event type, including types added later. An endpoint with a filter receives only the types you selected, so add a new type to the filter when you want it. See [Webhook events](/webhooks/events) for the list.
  </Step>

  <Step title="Copy the signing secret">
    Open the endpoint and copy its **signing secret** (it starts with `whsec_`). Store it as a server-side secret. Each endpoint has its own secret.
  </Step>

  <Step title="Send an example event">
    Use the portal's testing view to send an example of any event type to your endpoint, then check the result in the endpoint's delivery log.
  </Step>
</Steps>

The same portal shows every delivery attempt with its request body, response code, and timing, and lets you replay a single message or recover all failed messages from a point in time.

<Note>
  Webhook endpoints belong to your organization, not to one environment. An endpoint receives events from both your Development and Production gates and agents. Every gate event carries a top-level `environment` field, `"development"` or `"production"`. Branch on it before you act, so a test verification never changes production data. Agents Ask events have no `environment` field. Match their `request_id` against the records you created.
</Note>

## Verify the signature

Every delivery carries three headers:

| Header | Purpose |
| - | - |
| `svix-id` | Unique message ID. It stays the same across retries of the same message. |
| `svix-timestamp` | Unix timestamp (seconds) of the attempt. The library rejects timestamps outside its tolerance window, which blocks replay attacks. |
| `svix-signature` | One or more space-separated signatures of `svix-id.svix-timestamp.body`. |

Verify with the `svix` library and your endpoint's signing secret. Verify against the **raw request body**. If a framework parses the JSON and you re-serialize it, the bytes change and verification fails.

```bash theme={null}
npm install svix
```

<CodeGroup>
  ```typescript Express theme={null}
  import express from "express";
  import { Webhook } from "svix";

  const app = express();
  const webhook = new Webhook(process.env.BOTSHIELD_WEBHOOK_SECRET!); // whsec_…

  // Stand-in for your database. Use a unique constraint in production.
  const processed = new Set<string>();

  // One endpoint receives both environments. Set this to "development" on your test deployment.
  const ENVIRONMENT = process.env.BOTSHIELD_ENVIRONMENT ?? "production";

  type BotShieldEvent = {
    type: string;
    request_id?: string; // absent on account.unlinked
    environment?: "development" | "production"; // on gate events
    event_id?: string;
    resolution_id?: string;
    card_id?: string;
    [key: string]: unknown;
  };

  // express.raw() keeps the body as a Buffer. Do not use express.json() on this route.
  app.post("/webhooks/botshield", express.raw({ type: "application/json" }), (req, res) => {
    let event: BotShieldEvent;
    try {
      event = webhook.verify(req.body, {
        "svix-id": req.header("svix-id") ?? "",
        "svix-timestamp": req.header("svix-timestamp") ?? "",
        "svix-signature": req.header("svix-signature") ?? "",
      }) as BotShieldEvent;
    } catch {
      res.status(400).send("Invalid signature");
      return;
    }

    // One key per logical event. See "Deduplicate" below.
    // account.unlinked (3.0) has none of these IDs, so fall back to the svix-id header.
    const dedupeKey = `${event.type}:${
      event.resolution_id ?? event.card_id ?? event.request_id ?? req.header("svix-id")
    }`;
    if (processed.has(dedupeKey)) {
      res.status(204).end();
      return;
    }
    processed.add(dedupeKey);

    // Acknowledge first, then do the work.
    res.status(204).end();
    void handleEvent(event);
  });

  async function handleEvent(event: BotShieldEvent): Promise<void> {
    // Gate events say which environment they came from. Skip the other one.
    if (event.environment && event.environment !== ENVIRONMENT) return;

    switch (event.type) {
      case "gate.human_verified":
        // Mark the order or session for event.request_id as verified.
        break;
      case "gate.unavailable":
        // Keep the action blocked. Offer the user another try.
        // event.reason says why a failure happened. An expiry has no reason.
        break;
      case "agents_ask.resolution.confirmed":
        // Verify event.proof_token, then let the agent proceed.
        break;
      case "agents_ask.resolution.denied":
      case "agents_ask.resolution.expired":
        // Stand the agent down.
        break;
      case "account.unlinked":
        // Mark the account for event.handle as no longer trusted.
        break;
    }
  }

  app.listen(3000);
  ```

  ```typescript Next.js route handler theme={null}
  // app/api/webhooks/botshield/route.ts
  import { Webhook } from "svix";

  const webhook = new Webhook(process.env.BOTSHIELD_WEBHOOK_SECRET!);

  export async function POST(request: Request): Promise<Response> {
    const payload = await request.text(); // raw body, exactly as sent

    let event: { type: string; request_id?: string };
    try {
      event = webhook.verify(payload, {
        "svix-id": request.headers.get("svix-id") ?? "",
        "svix-timestamp": request.headers.get("svix-timestamp") ?? "",
        "svix-signature": request.headers.get("svix-signature") ?? "",
      }) as { type: string; request_id?: string };
    } catch {
      return new Response("Invalid signature", { status: 400 });
    }

    await enqueue(event); // hand off to a queue or background job; keep this fast
    return new Response(null, { status: 204 });
  }

  async function enqueue(event: { type: string; request_id?: string }): Promise<void> {
    // Deduplicate, then store the event for processing.
  }
  ```
</CodeGroup>

<Warning>
  Never act on an unverified payload, and never log the signing secret. If a secret leaks, rotate it from the endpoint's page in the portal.
</Warning>

## Respond fast

Return any `2xx` as soon as the signature checks out and the event is recorded. Do slow work (database writes beyond the dedupe record, calls to other services, email) after you respond. A response that is not `2xx`, including a `3xx` redirect, or that is slow to arrive counts as a failed attempt. Aim to respond within a few seconds.

## Retries

A failed attempt is retried automatically, several times, with increasing delay between attempts. Retries continue for hours, not minutes, so a short outage on your side does not lose events.

Every attempt, with its response code and body, is listed under the endpoint in the portal. After the last attempt the message is marked failed for that endpoint, and you can replay it from the portal at any time. An endpoint that keeps failing for days can be disabled automatically. Re-enable it in the portal once it is healthy, then replay what it missed.

## Deduplicate

Retries and manual replays mean your endpoint can receive the same event more than once. Make processing idempotent by recording a key per event before you act on it.

| Events | Deduplicate on |
| - | - |
| `gate.human_verified`, `gate.unavailable` | `type` + `request_id` (`event_id` always equals `request_id`) |
| `agents_ask.resolution.confirmed`, `agents_ask.resolution.denied` | `resolution_id` |
| `agents_ask.card.proposed`, `agents_ask.resolution.expired` | `type` + `card_id` |
| `account.unlinked` | The `svix-id` header. The payload carries no request or event ID. |

The `svix-id` header also works as a transport-level key: it is identical on every retry of one message.

## Do not depend on webhooks alone

Webhook delivery is best-effort relative to the verification itself. BotShield never delays or fails a verification because a webhook could not be queued, so a webhook can be late or, rarely, missing.

BotShield sends a gate event when something happens to a request on the phone:

* **The human confirms.** `gate.human_verified` is sent.
* **The BotShield app reports a failure,** for example the person cancels the biometric prompt, or a request that was opened fails. `gate.unavailable` is sent with `failed_at` and a `reason`. [Webhook events](/webhooks/events) lists the values.
* **The person opens the request after its five-minute window has passed.** `gate.unavailable` is sent with `expired_at`.

Two paths produce no gate event at all:

* **Recent Presence fast path.** When a returning human passes instantly and the widget reports `via: "precheck"`, no `gate.human_verified` webhook is sent for that pass. Confirm it from your server right away with `GET /verification/status`, which reports `status: "pass"` for 60 seconds after the check.
* **Requests nobody opens.** A request that the person never opens on their phone expires with no event. Closing the widget's modal without scanning the QR code, or ignoring the push, ends this way.

So a missing webhook is itself an outcome. Do not hold a user's flow open waiting for `gate.unavailable`.

For any flow where the outcome matters (checkout, account changes, agent actions), also check the result from your server:

* BotShield Gate: call `GET /verification/status` with the `request_id`. See [Verify on your server](/gate/verify-on-your-server).
* Agents Ask: long-poll `GET /agentlink/check-status` with your `request_id`. See [Propose an action](/agents-ask/propose-an-action).

Treat a gate request with no `gate.human_verified`, and no `completed` or `pass` status by its `expires_at`, as **Unavailable**.

## Next steps

<CardGroup cols={2}>
  <Card title="Webhook events" icon="list" href="/webhooks/events">
    Payloads and field tables for every event type.
  </Card>

  <Card title="Verify on your server" icon="server" href="/gate/verify-on-your-server">
    Confirm a gate result without waiting for a webhook.
  </Card>

  <Card title="Proof of Resolution" icon="signature" href="/agents-ask/proof-of-resolution">
    Verify the signed JWT carried by resolution events.
  </Card>

  <Card title="Keys and environments" icon="key" href="/console/keys-and-environments">
    Where your keys live in the Console.
  </Card>
</CardGroup>
