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

# Webhooks

> Receive events from Peeve on your own endpoints, and verify they are genuine.

Register HTTPS endpoints and Peeve posts events to them as things happen — a
hand-off opens, a lead is created, a meeting is booked.

<Note>
  This is Peeve **sending to you**. It is not the inbound connector plumbing for
  WhatsApp or Slack, which Peeve configures on your behalf and which is not a
  customer surface.
</Note>

## Setup

Two equivalent paths — **Connectors → Webhooks** in the dashboard, or
[the API](/api/v1/webhooks/create-a-webhook-endpoint). An endpoint created
through the API is the same row with the same guarantees, and it flips the
Connectors card to connected just the same.

Peeve issues a signing secret (`whsec_…`) when you create the endpoint.

<Warning>
  **The secret is returned exactly twice: on create, and on
  [rotate-secret](/api/v1/webhooks/rotate-a-signing-secret).** Never on a list, a
  GET or a PATCH, and there is no way to recover it later. Store it when you see
  it.

  Reads return a 4-character `secret_hint` instead, so you can confirm a rotation
  happened without exposing the secret.
</Warning>

<Warning>
  **Choosing events is mandatory.** `POST /v1/webhooks` requires `events`;
  omitting it is a `400` naming the field, not a subscription to everything. An
  explicit `events: []` is **refused** rather than widened — in an API call an
  empty array is a deliberate statement, and silently turning it into "all
  events" would hand the maximum traffic to a caller who meant to narrow.

  Pass `["*"]` if you genuinely want everything, including events added later.

  **`channels` is different, and deliberately so.** It *is* optional, and an
  absent value means every channel — because a channel filter can only ever
  **narrow** an event set you already chose. An absent `channels` cannot deliver
  an event nobody asked for; an absent `events` could deliver all of them.

  That is the whole asymmetry: `handoff.*` payloads carry real customer message
  text, so subscribing a new URL to them because a field was left blank is not a
  convenience.
</Warning>

Use the test-send to confirm your receiver works before you rely on it. A test
delivery carries `livemode: false` and a `Peeve-Test: true` header, and is capped
at **20 per endpoint per hour**.

<Warning>
  **Never create real records from a delivery with `livemode: false`.** Log it and
  move on.
</Warning>

## Verifying a request — read this first

<Warning>
  **Verify before you trust.** A `POST` to your URL is unauthenticated by default:
  anyone who learns the URL can forge a hand-off. A receiver that acts on one —
  opens a ticket, pages an on-call, issues a refund — has been had.

  The signature is the only thing that makes a request provably from Peeve.
</Warning>

The signed string is `"<timestamp>.<raw body>"`. **The timestamp is inside the
HMAC**, so a captured request cannot be replayed with a rewritten header.

Three rules:

1. Sign the **raw body bytes**, exactly as received. Re-serialising parsed JSON
   produces different bytes and the check will fail.
2. Compare in **constant time**.
3. Reject anything older than about five minutes.

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET = process.env.PEEVE_WEBHOOK_SECRET; // whsec_…
  const TOLERANCE_SECONDS = 300;

  // express.raw — NOT express.json. The signature covers the bytes on the wire.
  app.post("/hooks/peeve", express.raw({ type: "application/json" }), (req, res) => {
    const signature = req.get("Peeve-Signature") || "";
    const timestamp = req.get("Peeve-Timestamp") || "";
    const body = req.body; // Buffer

    // 1. Freshness — reject replays.
    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (!timestamp || Number.isNaN(age) || age > TOLERANCE_SECONDS) {
      return res.status(400).send("stale");
    }

    // 2. Recompute.
    const expected =
      "v1=" +
      crypto.createHmac("sha256", SECRET).update(`${timestamp}.${body}`, "utf8").digest("hex");

    // 3. Constant-time compare.
    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("bad signature");
    }

    const event = JSON.parse(body.toString("utf8"));
    if (!event.livemode) {
      // A test delivery. Log it, don't create real records from it.
    }

    // Answer fast; do the work afterwards.
    res.status(200).send("ok");
  });
  ```

  ```python Python (Flask) theme={null}
  import hashlib, hmac, json, os, time
  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["PEEVE_WEBHOOK_SECRET"].encode()
  TOLERANCE_SECONDS = 300

  @app.post("/hooks/peeve")
  def peeve_webhook():
      signature = request.headers.get("Peeve-Signature", "")
      timestamp = request.headers.get("Peeve-Timestamp", "")
      body = request.get_data()  # raw bytes

      try:
          age = abs(int(time.time()) - int(timestamp))
      except ValueError:
          abort(400)
      if age > TOLERANCE_SECONDS:
          abort(400)

      expected = "v1=" + hmac.new(
          SECRET, f"{timestamp}.".encode() + body, hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(signature, expected):
          abort(401)

      event = json.loads(body)
      if not event["livemode"]:
          pass  # test delivery

      return "ok", 200
  ```
</CodeGroup>

## The envelope

| Field          | Meaning                                                                      |
| -------------- | ---------------------------------------------------------------------------- |
| `id`           | Stable event id. **The same value on every retry** — dedupe on it.           |
| `type`         | Event name from the catalogue below.                                         |
| `api_version`  | Payload version. Additive changes keep it; a breaking change gets a new one. |
| `created_at`   | When the event happened — not when this attempt was made.                    |
| `workspace_id` | The workspace it belongs to. Key on this if you serve several.               |
| `livemode`     | `false` for a test send.                                                     |
| `channel`      | Where the conversation is happening. **Absent on billing events.**           |
| `data`         | The typed entity for this event.                                             |

### Headers

| Header                   | Example           | Meaning                                  |
| ------------------------ | ----------------- | ---------------------------------------- |
| `Peeve-Signature`        | `v1=3a5f…`        | HMAC-SHA256, hex, version-prefixed       |
| `Peeve-Timestamp`        | `1787654321`      | UNIX seconds. Part of the signed string. |
| `Peeve-Event-Id`         | `evt_9f1c…`       | Same as `id` in the body                 |
| `Peeve-Event`            | `handoff.created` | Same as `type` in the body               |
| `Peeve-Delivery-Attempt` | `2`               | 1-based attempt number                   |
| `Peeve-Test`             | `true`            | **Only present on a test send**          |

## Event catalogue

| Event                          | Fires when                                         | `channel`? | `data` carries                         |
| ------------------------------ | -------------------------------------------------- | :--------: | -------------------------------------- |
| `session.started`              | Someone starts a conversation                      |      ✅     | `session`                              |
| `session.resolved`             | A conversation reaches an outcome                  |      ✅     | `session`, `status`                    |
| `handoff.created`              | A ticket or live chat opens for your team          |      ✅     | `handoff` (with `summary`)             |
| `handoff.message.created`      | A new message in a hand-off                        |      ✅     | `handoff`, `message`                   |
| `handoff.status.changed`       | A hand-off moves status                            |      ✅     | `handoff`, `status`, `previous_status` |
| `lead.created`                 | A buying conversation produces a lead              |      ✅     | `lead`                                 |
| `lead.status.changed`          | A lead moves status                                |      ✅     | `lead`, `status`, `previous_status`    |
| `meeting.booked`               | A prospect books time                              |      ✅     | `meeting`                              |
| `meeting.status.changed`       | A booked meeting completes, cancels or no-shows    |      ✅     | `meeting`, `status`, `previous_status` |
| `billing.payment_failed`       | A payment for your Peeve subscription fails        |      ✗     | `billing`                              |
| `billing.subscription.changed` | Your plan or subscription state changes            |      ✗     | `billing`, `status`, `previous_status` |
| `billing.credits_low`          | You cross 80% / 95% / 100% of the period's credits |      ✗     | `billing`                              |
| `billing.credits_expiring`     | Purchased top-up credits are about to expire       |      ✗     | `billing`                              |

<Warning>
  **Status and channel are payload fields, never event names.** There is no
  `handoff.resolved` event — it is `handoff.status.changed` with
  `status: "resolved"`. Match on `type`, then read the field.
</Warning>

### Status vocabularies

| Field                | Values                                          |
| -------------------- | ----------------------------------------------- |
| `session.resolution` | `solved`, `unsolved`, `partial`, `abandoned`    |
| `handoff.status`     | `open`, `pending`, `resolved`, `closed`         |
| `handoff.kind`       | `ticket`, `chat`                                |
| `handoff.resolution` | `solved`, `unsolved`, `partial`, `abandoned`    |
| `lead.status`        | `new`, `qualified`, `disqualified`, `converted` |
| `meeting.status`     | `scheduled`, `completed`, `canceled`, `no_show` |

## Retries and idempotency

| Your response         | What Peeve does                                                                  |
| --------------------- | -------------------------------------------------------------------------------- |
| `2xx`                 | Delivered. Done.                                                                 |
| `5xx`                 | **Retried** — up to 3 attempts total, about 1s then 4s apart                     |
| No response within 5s | **Retried**, same schedule                                                       |
| `408`, `429`          | **Retried** — the two `4xx` that mean "later"                                    |
| Any other `4xx`       | **Not retried.** You rejected it; repeating looks like an attack on your service |
| `3xx`                 | **Not followed, not retried.** Point the webhook at the final URL                |

<Warning>
  **Every retry carries the same `id`.** Record the ids you have processed and
  ignore repeats — that is the whole idempotency contract.

  Answer `2xx` fast and do your work afterwards. A receiver that finishes its
  processing before responding will be retried while it is still working.
</Warning>

## Delivery log

**Connectors → Webhooks → your endpoint → Recent deliveries.** Each row records
the event, the channel, the outcome, your HTTP response code, the attempts taken
and how long it took.

| Status       | Meaning                                                          |
| ------------ | ---------------------------------------------------------------- |
| `delivered`  | Your endpoint answered `2xx`                                     |
| `failed`     | Attempts exhausted, or you rejected it with a `4xx`              |
| `suppressed` | Not attempted — the endpoint was in its failure cool-off         |
| `blocked`    | The stored URL no longer passes the HTTPS / public-address check |

## Managing endpoints

| Act                          | How                                                                                                           |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Pause deliveries, reversibly | `PATCH` with `enabled: false` — keeps the secret and the delivery history                                     |
| Delete permanently           | `DELETE` — discards the endpoint id and its history                                                           |
| Replace a leaked secret      | [`POST .../rotate-secret`](/api/v1/webhooks/rotate-a-signing-secret) — keeps the id, subscription and history |

<Note>
  There is no third lifecycle state; this matches the connector UI's two
  operations exactly.

  Reach for **rotate-secret**, not delete-and-recreate, when a secret leaks —
  recreating changes the endpoint id and throws away its delivery history.
</Note>

## Destination requirements

Enforced on **update as well as create**, so an endpoint cannot be edited into a
private address:

* **HTTPS only** — `http://` is a `400`.
* No `localhost`, no RFC-1918 private ranges, no IPv6 loopback, no link-local
  `169.254.169.254`, no `.internal`, no non-HTTP schemes.

Worth knowing before you start: **a local tunnel will not work** unless it
terminates on a public HTTPS address.

An endpoint whose URL later stops qualifying is marked `blocked` in the delivery
log.
