---
title: "Webhooks & events"
description: "The event catalog, envelope, signatures and delivery semantics"
url: https://docs.gleap.ai/en/articles/722-webhooks-and-events
collection: "Guide"
updated: 2026-11-01
language: en
---

# 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](https://gleap-developer-docs.gleap.help/en/articles/723-realtime-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
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

<table style="min-width: 50px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p>Type</p></th><th colspan="1" rowspan="1"><p>When</p></th></tr><tr><td colspan="1" rowspan="1"><p><code>conversation.created</code></p></td><td colspan="1" rowspan="1"><p>A conversation becomes visible to the customer</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>conversation.updated</code></p></td><td colspan="1" rowspan="1"><p>A customer-visible field changed (title, status, attributes, archived, …)</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>conversation.closed</code></p></td><td colspan="1" rowspan="1"><p>Closed for the customer — by an agent, a workflow close step, or automation (<code>closedBy: "agent" | "bot" | "system"</code>)</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>conversation.reopened</code></p></td><td colspan="1" rowspan="1"><p>Reopened — including by a customer reply</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>conversation.assigned</code></p></td><td colspan="1" rowspan="1"><p>Assignee changed (payload carries the full conversation incl. <code>assignee</code>)</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>conversation.deleted</code></p></td><td colspan="1" rowspan="1"><p>Conversation permanently removed by a manual deletion or by archive retention. <strong>Prune it from your mirror.</strong></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>message.created</code></p></td><td colspan="1" rowspan="1"><p>Any new customer-visible message; <code>data.message.author.type</code> = <code>contact | agent | bot | system</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>workflow.step.presented</code></p></td><td colspan="1" rowspan="1"><p>A workflow step is waiting for the customer</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>workflow.completed</code></p></td><td colspan="1" rowspan="1"><p>A workflow ended (<code>reason: "completed" | "handed_off"</code>)</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>contact.updated</code></p></td><td colspan="1" rowspan="1"><p>Contact identity fields changed</p></td></tr><tr><td colspan="1" rowspan="1"><p><code>agent.typing.started</code> / <code>.stopped</code></p></td><td colspan="1" rowspan="1"><p>Opt-in only — highest-volume events</p></td></tr></tbody></table>

> **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.
>
> Some deletions send no `conversation.deleted`: duplicates deleted together with their main conversation, and conversations deleted together with their contact. To keep a mirror complete, periodically compare your IDs with `GET /v3/tickets/csv-export?projection[]=_id` (no `type`, so every ticket type is included).

## Envelope

```json
{
  "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:

```javascript
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
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:

```javascript
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.

## Related

- [Overview](https://docs.gleap.ai/en/articles/718-overview.md)
- [Authentication](https://docs.gleap.ai/en/articles/719-authentication.md)
- [Quickstart](https://docs.gleap.ai/en/articles/720-quickstart.md)
- [Workflows & bots](https://docs.gleap.ai/en/articles/721-workflows-and-bots.md)
- [Realtime stream](https://docs.gleap.ai/en/articles/723-realtime-stream.md)
