Everything that happens in a conversation reaches your backend as an event — over the 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
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 |
|---|---|
| A conversation becomes visible to the customer |
| A customer-visible field changed (title, status, attributes, archived, …) |
| Closed for the customer — by an agent, a workflow close step, or automation ( |
| Reopened — including by a customer reply |
| Assignee changed (payload carries the full conversation incl. |
| Conversation permanently removed by a manual deletion or by archive retention. Prune it from your mirror. |
| Any new customer-visible message; |
| A workflow step is waiting for the customer |
| A workflow ended ( |
| Contact identity fields changed |
| Opt-in only — highest-volume events |
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
{
"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": "…" } }
}sequenceis monotonic per conversation — order and deduplicate by(conversation id, sequence). Events can arrive out of order on the wire.sequenceisnullonagent.typing.*andcontact.updated(no conversation context).Treat
dataas 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.createdv1 = 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.
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=0278b76839efecb5145a60c07a4da3f00b8dce39f20d91d2826ea9a40e983e8eDelivery semantics & recovery
Respond
2xxwithin 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}/conversationsandGET /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.

