The stream is the recommended event channel: your backend holds one outbound WebSocket to Gleap's realtime cluster and receives every event for your project on it — no inbound endpoint to expose, no per-delivery signature verification, no retry windows, and lower latency than webhooks (which queue and retry per delivery). The stream speaks the Pusher wire protocol, so any mature off-the-shelf Pusher client (Node, Java, Go, .NET) works unmodified. Webhooks remain fully supported when your infrastructure policy prefers inbound HTTP.
Connect
The API host depends on the data region of your project:
Data region | API host |
|---|---|
EU (default) |
|
US |
|
https://api.gleap.io with your region's host. API tokens are per project and only valid in the project's region.Read the connection parameters at startup — never hardcode them:
curl https://api.gleap.io/v3/s2s/stream/config -H "Authorization: Bearer $TOKEN"{ "protocol": "pusher", "host": "sockets.gleap.io", "port": 443, "tls": true,
"appKey": "…", "channel": "private-s2s-<projectId>",
"authEndpoint": "https://api.gleap.io/v3/s2s/stream/auth",
"userAuthEndpoint": "https://api.gleap.io/v3/s2s/stream/user-auth" }Point your client's channel authorization at authEndpoint (with your token as the Authorization header) and subscribe to channel. Include two custom auth params:
{ "consumerId": "backend-pod-3", "typingEvents": false }consumerIdnames the connecting instance ([A-Za-z0-9_-]{1,64}).typingEvents: trueopts this stream intoagent.typing.*(high volume; default off).
Also configure the client's user authentication against userAuthEndpoint (Pusher "signin") — that is what allows the server to cleanly terminate a superseded connection during takeover.
Event data arrives as a JSON string of the same envelope webhooks receive — JSON.parse it (Pusher clients usually do this for you). Payloads larger than the socket limit arrive as a slim pointer (data.truncated: true with the conversation/message id) — fetch the full object over REST.
Exactly one consumer
Gleap allows one active consumerId per project. When a connection authorizes with a different consumerId, it takes over: the previous consumer's connections are closed and a s2s.takeover control event is published on the channel.
Connections that share the same consumerId are the same consumer: none of them is taken over, and each receives every event. Use this to run the stream on several instances at once (active-active), so losing one instance loses no events. Deduplicate by the envelope id (for example SET NX in Redis with a short TTL) or make processing idempotent. typingEvents is a project-wide setting and the most recent auth wins, so use the same value on every instance.
Rolling deployments need no coordination either way: with the same consumerId old and new pods overlap and both receive events; with a new consumerId the new pod takes over and the old one is dropped.
Detecting a takeover
s2s.takeover goes out at the same moment as the disconnect, so it is best effort: it may arrive just before the close or not at all. It is a control event, not wrapped in the envelope:
{ "event": "s2s.takeover", "channel": "private-s2s-<projectId>",
"data": "{\"consumerId\":\"<consumerId of the new consumer>\"}" }Every subscriber sees it, so compare consumerId with your own and ignore it when it matches. A taken-over connection is closed with code 4009 and reason You got disconnected by the app., which only reaches connections that completed user authentication.
You were taken over when you receive s2s.takeover with a foreign consumerId, or a close 4009 with that reason. Do not reconnect automatically in that case: two instances with different ids would keep evicting each other. Reconnect on every other close.
Disconnects
Every close initiated by the realtime server first sends a pusher:error message ({ "code", "message" }) and then a close frame with the same code. Log both.
Code | Reason | Cause | Action |
|---|---|---|---|
|
| Takeover by a different | Stop; don't reconnect |
|
| A realtime node restarts (deploys, scaling) | Reconnect immediately |
|
| After 120 s without traffic the server sends | Answer pings (or send |
| — | Over capacity or the client reads too slowly | Reconnect with backoff |
| — | Network path: edge proxy, load balancer, your own proxy or NAT idle timeouts | Reconnect with backoff |
There is no maximum connection lifetime. A failed channel authorization (for example a revoked token) does not close the socket; it arrives as pusher:subscription_error. In general the Pusher ranges apply: 4000–4099 fix the cause before reconnecting, 4100–4199 reconnect with backoff, 4200–4299 reconnect immediately.
Resync on every (re)connect — mandatory
A dropped connection loses the events published during the gap; there is no replay. After every connect — first connect, reconnect, and after a takeover — run per-conversation catch-up for anything you track:
GET /v3/s2s/contacts/{userId}/conversationsfor contacts with open activity, and/orGET /v3/s2s/conversations/{id}/messages?after=<last message id you have>per conversation.
This is the same self-healing model the Gleap widget uses, and it also covers webhook consumers after their retry window.

