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

# Webhooks & events

> The event catalog, envelope, signatures and delivery semantics

Everything that happens in a conversation reaches your backend as an event — over the [realtime stream](/documentation/s2s/stream) (**recommended** — one WebSocket, lower latency, nothing to expose), HMAC-signed webhooks, or both. Event types and envelopes are identical on both channels; this page covers the shared catalog and the webhook delivery contract.

## Registering endpoints

```bash theme={null}
curl -X POST https://api.gleap.io/v3/s2s/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "url": "https://api.your-backend.example/gleap/events", "events": [] }'
```

The response contains the signing `secret` (`whsec_…`) **exactly once** — store it in your secret manager. Up to 5 endpoints per project; `GET /v3/s2s/webhooks` lists them (secrets masked), `DELETE /v3/s2s/webhooks/{id}` removes one. URLs must be public `https://` endpoints.

An empty `events` array means **all events except typing** — `agent.typing.*` is strictly opt-in and only delivered to endpoints that list it explicitly.

## Event catalog

| Type                                | When                                                                                                                   |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `conversation.created`              | A conversation becomes visible to the customer                                                                         |
| `conversation.updated`              | A customer-visible field changed (title, status, attributes, archived, …)                                              |
| `conversation.closed`               | Closed for the customer — by an agent, a workflow close step, or automation (`closedBy: "agent" \| "bot" \| "system"`) |
| `conversation.reopened`             | Reopened — including by a customer reply                                                                               |
| `conversation.assigned`             | Assignee changed (payload carries the full conversation incl. `assignee`)                                              |
| `conversation.deleted`              | Conversation permanently removed — retention, GDPR or manual deletion. **Prune it from your mirror.**                  |
| `message.created`                   | Any new customer-visible message; `data.message.author.type` = `contact \| agent \| bot \| system`                     |
| `workflow.step.presented`           | A workflow step is waiting for the customer                                                                            |
| `workflow.completed`                | A workflow ended (`reason: "completed" \| "handed_off"`)                                                               |
| `contact.updated`                   | Contact identity fields changed                                                                                        |
| `agent.typing.started` / `.stopped` | Opt-in only — highest-volume events                                                                                    |

<Note>
  **Data retention**: bot-only conversations are permanently deleted \~33 days after creation; archived conversations follow the project's retention window. `conversation.deleted` (and a `404` on any fetch) means "remove from your mirror" — there are no tombstones.
</Note>

## Envelope

```json theme={null}
{
  "id": "evt_66c0ffee0000000000000001",
  "type": "message.created",
  "apiVersion": "2026-08-01",
  "createdAt": "2026-08-17T10:00:00.000Z",
  "projectId": "64bab1e3b5d6f9e472f187c9",
  "sequence": 42,
  "data": { "conversation": { "id": "cnv_…", "status": "OPEN", "closed": false, "unread": true },
            "message": { "id": "msg_…", "author": { "type": "agent" }, "text": "…" } }
}
```

* `sequence` is **monotonic per conversation** — order and deduplicate by `(conversation id, sequence)`. Events can arrive out of order on the wire.
* `sequence` is `null` on `agent.typing.*` and `contact.updated` (no conversation context).
* Treat `data` as the authoritative current state of what it carries.

## Verifying signatures

Every delivery carries:

```
Gleap-Signature: t=<unix seconds>,v1=<hex>
Gleap-Delivery-Id: dlv_…
Gleap-Event: message.created
```

`v1 = HMAC-SHA256(secret, t + "." + <raw request body>)`. Verify against the **raw** body bytes, allow a 5-minute tolerance on `t`, compare constant-time, and reject anything that fails.

```js theme={null}
const crypto = require("crypto");

function verifyGleapSignature(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = parseInt(parts.t, 10);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  try {
    return crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), Buffer.from(expected, "hex"));
  } catch {
    return false;
  }
}
```

**Test vector** — your implementation must produce exactly this signature:

```
secret : whsec_9f1c2e4a7b3d5f60819a2c4e6f8091b3d5f70a2c4e6f8091b3d5f70a2c4e6f80
t      : 1755424800
body   : {"id":"evt_66c0ffee0000000000000001","type":"message.created","apiVersion":"2026-08-01","createdAt":"2026-08-17T10:00:00.000Z","projectId":"64bab1e3b5d6f9e472f187c9","sequence":42,"data":{"hello":"world"}}
→ Gleap-Signature: t=1755424800,v1=0278b76839efecb5145a60c07a4da3f00b8dce39f20d91d2826ea9a40e983e8e
```

## Delivery semantics & recovery

* Respond `2xx` within 5 seconds; anything else (or a timeout) is retried **3 times over \~20 seconds**, then dropped. Endpoints that fail persistently are circuit-broken for a few minutes.
* Delivery is therefore **at-least-once within a short window** — not guaranteed. Your durable safety net is per-conversation catch-up: whenever your app opens a contact's support screen (or your consumer suspects a gap), re-pull `GET /v3/s2s/contacts/{userId}/conversations` and `GET /v3/s2s/conversations/{id}/messages?after=<last id you have>`.
* Signed file URLs inside payloads expire (\~30 days) and rotate — never dedupe or persist by URL.
