> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pickupbell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Outbound webhooks

> Register endpoints to receive real-time events from PickupBell — calls, leads, bookings, emergencies.

## Register an endpoint

**Dashboard:** Settings → API & MCP → Webhook endpoints → **Add endpoint**.
**API:** `POST /api/v1/locations/:id/webhook-endpoints`.

You provide a label, an `https://` URL, and the list of events you care about. PickupBell returns an HMAC signing **secret** on creation — store it. Subsequent `GET` calls omit the secret.

```bash theme={null}
curl -X POST https://api.pickupbell.com/api/v1/locations/premier-hvac/webhook-endpoints \
  -H "Authorization: Bearer 550e8400-e29b-41d4-a716-446655440000" \
  -H "content-type: application/json" \
  -d '{
    "label": "production CRM sync",
    "url":   "https://api.yourcrm.com/webhooks/pickupbell",
    "events": ["call.ended", "lead.created", "booking.created"]
  }'
# → { "data": { "id": "…", "secret": "whsec_…", ... } }
```

## Event types

| Type              | When it fires                                                                |
| ----------------- | ---------------------------------------------------------------------------- |
| `call.ended`      | Every call, including spam-rejected. One per call.                           |
| `call.emergency`  | Caller hit urgency 5 or the agent warm-transferred.                          |
| `lead.created`    | A call produced a lead row (outcome is `lead/booked/transferred/voicemail`). |
| `lead.updated`    | Lead status changed (new → contacted, etc.).                                 |
| `booking.created` | The AI scheduled an appointment on a calendar.                               |
| `booking.updated` | Booking status or time changed.                                              |
| `spam.blocked`    | Spam call rejected before the agent answered.                                |

A single event can fire multiple types (e.g. an emergency call triggers `call.ended` *and* `call.emergency` *and* `lead.created`). Subscribe only to what you need.

## Delivery shape

We POST JSON to your endpoint:

```json theme={null}
{
  "event": "lead.created",
  "location_id": "5e9a7b21-…",
  "created_at": "2026-04-22T14:47:52Z",
  "data": {
    "id": "8a3f9d21-…",
    "call_id": "…",
    "name": "Sarah Chen",
    "phone": "+15125550142",
    "service_type": "AC repair",
    "urgency_level": 5,
    "estimated_value": 2800,
    "status": "new"
  }
}
```

Headers on every delivery:

```
content-type: application/json
user-agent: PickupBell-Webhooks/1.0
pickupbell-event: lead.created
pickupbell-event-id: 4a7…
pickupbell-delivery-id: 8f9…
pickupbell-timestamp: 1713879084
pickupbell-signature: v1=<hex hmac-sha256 of the raw body using your secret>
```

## Verifying signatures

Every request carries `pickupbell-signature: v1=<hex>` — HMAC-SHA256 of the raw request body using your endpoint's `secret`.

<CodeGroup>
  ```ts Node theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verify(req: Request, secret: string): boolean {
    const sig = (req.headers.get("pickupbell-signature") ?? "").replace(/^v1=/, "");
    const body = (req as unknown as { rawBody?: string }).rawBody ?? "";
    const expected = createHmac("sha256", secret).update(body).digest("hex");
    return (
      sig.length === expected.length &&
      timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))
    );
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib
  def verify(headers: dict, raw_body: bytes, secret: str) -> bool:
      sig = headers.get("pickupbell-signature", "").removeprefix("v1=")
      expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(sig, expected)
  ```
</CodeGroup>

<Warning>
  Always verify against the **raw** request body, not a re-serialized parsed object. Different key ordering or whitespace in your re-serialization will break the signature check.
</Warning>

## Idempotency

Each delivery has a unique `pickupbell-delivery-id`. On retry (see below), the `delivery-id` changes but the `pickupbell-event-id` stays stable — use `event-id` to dedupe in your consumer.

## Retries

If your endpoint returns a non-2xx status or times out (10s), we retry with exponential backoff: **1m → 5m → 30m → 2h → 6h → 24h**. After 6 failed attempts the delivery is marked `dead` and we stop. The endpoint's `failure_count` counter tracks consecutive failures; a successful delivery resets it.

To inspect history, `GET /api/v1/locations/:id/webhook-deliveries` returns the most recent delivery attempts with status codes and response bodies.

## Pausing without revoking

`PATCH /webhook-endpoints/:id` with `{ "active": false }` pauses delivery (pending retries are not re-enqueued until you flip `active` back to true). `DELETE` permanently removes the endpoint and discards pending deliveries.
