Server-Sent Events (SSE)
An SSE endpoint serves an event stream that any EventSource or HTTP client can open, and accepts messages from the client by POST:
GET https://app.hooklistener.com/sse/<slug> # the event stream
POST https://app.hooklistener.com/sse/<slug> # a message from the client
Every event sent on the stream and every POSTed message is recorded. See the overview for the console, rules, scenarios, limits and the REST API.
Connect
These are the snippets from the endpoint's Connect tab, for an endpoint with an access token. EventSource can't set headers, so the stream takes the token in the query string; POSTs send it as a header. Without a token, leave out the token parts.
Each client names its stream with ?client_id= and POSTs with the same client_id, so its messages, and the rule replies they trigger, stay on its own stream however many are open. See Choosing the stream.
Browser (EventSource)
// One client id per tab: the tab's stream and its POSTs both carry it, so
// replies reach this tab however many tabs are open.
const clientId = crypto.randomUUID();
const source = new EventSource(`https://app.hooklistener.com/sse/<slug>?client_id=${clientId}&token=<token>`);
source.onopen = () => console.log("connected");
source.onmessage = (event) => console.log("message", event.lastEventId, event.data);
// Named events (event: tick) only reach listeners for that name:
source.addEventListener("tick", (event) => console.log("tick", event.data));
source.onerror = (error) => console.error("sse error", error);
// Send a message to the server: POST to the same URL with the same client_id.
function send(message) {
return fetch(`https://app.hooklistener.com/sse/<slug>?client_id=${clientId}`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Hooklistener-Token": "<token>" },
body: JSON.stringify(message),
}).then((response) => response.json()); // { session_id, message_id }
}
source.addEventListener("open", () => send({ type: "ping" }).then(console.log), { once: true });
Node (eventsource)
// npm i eventsource
import { EventSource } from "eventsource";
// Name each client: its POSTs carry the same client_id as its stream, so they
// reach this stream however many others are open.
const clientId = "worker-1";
const source = new EventSource(`https://app.hooklistener.com/sse/<slug>?client_id=${clientId}&token=<token>`);
source.onmessage = (event) => console.log(event.lastEventId, event.data);
source.addEventListener("tick", (event) => console.log("tick", event.data));
source.onerror = (error) => console.error(error);
// Send a message to the server: POST to the same URL with the same client_id.
source.onopen = async () => {
const response = await fetch(`https://app.hooklistener.com/sse/<slug>?client_id=${clientId}`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Hooklistener-Token": "<token>" },
body: JSON.stringify({ type: "ping" }),
});
console.log(await response.json()); // { session_id, message_id }
};
curl
curl -N -H "X-Hooklistener-Token: <token>" "https://app.hooklistener.com/sse/<slug>?client_id=cli-1"
# Reconnect and replay the events after id 42:
curl -N -H "X-Hooklistener-Token: <token>" -H "Last-Event-ID: 42" "https://app.hooklistener.com/sse/<slug>?client_id=cli-1"
# Send a message to that stream: the same client_id picks it however many
# streams are open (so does -H "X-Hooklistener-Session: <id>" from its
# response headers):
curl -H "X-Hooklistener-Token: <token>" -H "Content-Type: application/json" -d '{"type":"ping"}' "https://app.hooklistener.com/sse/<slug>?client_id=cli-1"
Python (requests)
# pip install requests
import requests
with requests.get("https://app.hooklistener.com/sse/<slug>?token=<token>", stream=True, headers={"Accept": "text/event-stream"}) as response:
# Send a message to the server on this stream: POST to the same URL.
session_id = response.headers["X-Hooklistener-Session"]
requests.post("https://app.hooklistener.com/sse/<slug>", json={"type": "ping"}, headers={"X-Hooklistener-Session": session_id, "X-Hooklistener-Token": "<token>"})
for line in response.iter_lines(decode_unicode=True):
if line:
print(line)
The Connect tab also has a Go snippet. In proxy mode the POST answers with your upstream's response instead of { session_id, message_id }; see Proxy mode.
The stream
GET /sse/<slug> opens a session and answers with:
HTTP/1.1 200 OK
content-type: text/event-stream; charset=utf-8
cache-control: no-cache, no-transform
x-accel-buffering: no
x-hooklistener-session: 0b9c6f3e-…
HTTP/1.1 responses also carry connection: close, because the connection is never reused for another request. HTTP/2 works too.
The stream starts with a comment that repeats the session id, followed by the retry: hint if you set one:
: connected session_id=0b9c6f3e-…
retry: 3000
Query parameters for the stream:
-
client_id=<name>names the stream, so POSTs can target it. It is trimmed and cut to 255 characters, and shown as the session's client id in the console. -
announce_session=1adds one named event with the stream's ids, the only form of them a browserEventSourcecan read (it can't see response headers or comments):event: hooklistener-sessiondata: {"session_id":"0b9c6f3e-…","client_id":"7f1c…"}It has no
id:, so it leaves the client'sLast-Event-IDalone, and it is not recorded. Streams opened without the parameter don't get it.
Each message is one event block:
event: tick
id: 42
data: {"price": 101.5}
event:carries the event name. It is left out when the name is empty ormessage, so the event reachesonmessage. Named events only reachaddEventListener("<name>", …).id:carries the event id: the one you send (the composer's ID field,event_idin the API), or an auto-assigned one. See Last-Event-ID replay.retry:is added when a send sets one (the composer's retry field,retry_msin the API). The endpoint's retry: hint is sent once on connect, and again to open streams when you change it.- A payload with several lines becomes one
data:line per line. Binary payloads are sent base64-encoded. - A ping from the console is sent as a
: pingcomment.
A stream ends when the client disconnects, when you close the session, or when a Close the client's stream rule fires. SSE has no close frame, so the code and reason you choose are only recorded on the session. EventSource reconnects on its own after a stream ends, waiting for the retry delay. To stop it, the client has to call close().
Keepalive
When nothing else has been written for 30 seconds, the server writes a comment:
: keepalive
Clients ignore comments, but proxies see traffic and keep the stream open. Cloudflare, for example, cuts streams that stay silent for about 100 seconds. Set the interval under Settings → Server-Sent Events → Keepalive comment (ms), between 1,000 and 90,000. Endpoints saved earlier with a longer interval are capped at 90 seconds.
When a client goes away, its session is closed within about 2 seconds, so the console doesn't list streams that are no longer there.
Last-Event-ID replay
When a client reconnects after a dropped stream, Hooklistener can replay the events it missed:
- Turn on Auto-assign incrementing
id:to events without one so every event carries a numeric id. The counter is per endpoint, and a broadcast gets one id that every stream shares. - The client reconnects with the id of the last event it saw.
EventSourcesends theLast-Event-IDheader by itself; other clients can send the header or a?lastEventId=query parameter. - The server first sends the events with a higher id, then continues live.
Replay works like this:
- What's replayed: events broadcast to every stream, plus events sent to the stream the client is reconnecting from. Each event is sent once, in the order it was first sent. Earlier replays are never replayed again.
- Simulated drops keep their id, so a client that reconnects gets the events the simulation dropped.
- Up to 200 events. After that the stream says
: replay stopped after 200 eventsand continues live. - Truncated events are skipped. Only the first 128 KB of each payload is stored. An event that was cut is skipped with a comment rather than replayed with different data, for example
: replay skipped event 57: only 131072 of its 300000 bytes were recorded. - Ids must be numbers (up to 18 digits). Other
Last-Event-IDvalues start a fresh stream with no replay. - To turn replay off, clear Replay events after
Last-Event-IDwhen clients reconnect. - In proxy mode Hooklistener doesn't replay: the upstream receives the client's
Last-Event-IDand replays from its own history.
Sending messages to the server
SSE streams only go from server to client, so clients send messages with POST /sse/<slug>, the usual stream-plus-POST pattern. The request body, up to 1 MB of any content type, is delivered to an open stream as a message received from that client. So everything that reacts to client messages works on SSE:
- rules, whose replies go out on that stream;
- expectations and contract validation;
- inbound delay and disconnect simulation.
curl -X POST "https://app.hooklistener.com/sse/<slug>?client_id=tab-1&event=checkout" \
-H "X-Hooklistener-Token: <token>" \
-H "Content-Type: application/json" \
-d '{"cart": 42}'
{"session_id": "0b9c6f3e-…", "message_id": "7d1e…"}
The response is 202, or your upstream's answer in proxy mode. POSTs need the access token, like the stream, and are checked against the allowed origins.
Choosing the stream
A POST goes to the first of these that applies:
- A session id:
?session_id=<id>or theX-Hooklistener-Session: <id>header. Each stream announces its id in itsX-Hooklistener-Sessionresponse header, its: connectedcomment and, with?announce_session=1, ahooklistener-sessionevent. - A client id:
?client_id=<name>, matching theclient_idthe stream was opened with. When several open streams share the id, for example after a reload, the newest one whose client is still connected gets the message. - The only open stream of the endpoint, when neither is given.
Browsers can't read the session id of an EventSource, so give each tab its own client id and use it on both requests, as in the browser snippet:
const clientId = crypto.randomUUID(); // one per tab
const source = new EventSource(`https://app.hooklistener.com/sse/<slug>?client_id=${clientId}`);
const send = (message) =>
fetch(`https://app.hooklistener.com/sse/<slug>?client_id=${clientId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
Each tab's POSTs, and the rule replies they trigger, reach that tab only, however many tabs are open.
Before a POST picks the only open stream, or picks a stream by client id, the candidate streams check their connections at once, up to 16 of them. A stream whose client has just left closes and drops out, so a POST sent right after a tab closes doesn't get a false 409. A stream that doesn't answer within about 500 ms still counts as open.
Naming the message
Add ?event=<name> or the X-Hooklistener-Event: <name> header to give the message an event name. Event name equals rules match on it.
Errors
| Status | Error | Cause |
|---|---|---|
400 | Endpoint is not configured for SSE | The slug belongs to a WebSocket, Socket.IO or MQTT endpoint |
401 | Missing or invalid access token | |
403 | Origin not allowed | The browser's origin isn't in Allowed origins |
404 | No open stream with that session id on this endpoint | The session_id is unknown, or that stream has closed |
404 | No stream with client id "<name>" is open on this endpoint. Open GET /sse/<slug>?client_id=… with that id first, then POST with it. | No live stream was opened with that client_id |
404 | The stream is no longer connected | The stream closed while the message was being delivered |
409 | The session id and the client id name different streams; pass only one of them | session_id and client_id point at different streams |
409 | No stream is open on this endpoint. Open GET /sse/<slug> first, then POST messages to it. | Open a stream first |
409 | <n> streams are open on this endpoint. Name the target stream: open each one with ?client_id=<name> (e.g. one per browser tab) and POST with the same ?client_id=, or pass its session id … | Several streams are open and the POST named none |
413 | Message is larger than 1000000 bytes | The body is over 1 MB |
429 | Too many messages | One client address sent more POSTs per second than the endpoint's inbound rate limit (200 by default) |
429 / 413 | Rate limit exceeded: …; the stream was closed / Message too large: …; the stream was closed | The message broke the stream's own rate or size limit, so the stream was closed |
502 | {"error": "…"} | Proxy mode: the upstream couldn't be reached or didn't answer in time |
503 | Endpoint is paused |
CORS
Browsers can open the stream and POST from any page:
- When a request's
Originis allowed, it is echoed inAccess-Control-Allow-OriginwithAccess-Control-Allow-Credentials: trueandVary: Origin. Credentialed requests work:new EventSource(url, { withCredentials: true })andfetch(url, { credentials: "include" }). With an empty Allowed origins list, every origin is allowed. Requests without anOriginget*. - Allowed request headers:
Last-Event-ID,Content-Type,Authorization,Cache-Control,X-Hooklistener-Token,X-Hooklistener-SessionandX-Hooklistener-Event. Allowed methods:GET,POSTandOPTIONS. X-Hooklistener-Sessionis exposed, so a page can read the session id from afetchresponse.- Preflight responses are cached for 10 minutes.
Rules and simulation
Rules run on POSTed messages, as on any protocol. Match on the payload, JSON conditions, or the event name from ?event=. A rule's reply is sent as an event on the stream that the POST targeted. Close the client's stream ends that stream.
The network simulation applies to events sent on the stream: latency, jitter, drops and duplicates. A duplicate repeats the same id. Disconnect fires after POSTed messages, as on other protocols, and also after events sent to the client, so you can test a client's reconnect and Last-Event-ID handling without sending anything.
Proxy mode
Set the Upstream URL to your real SSE endpoint, for example https://api.example.com/events. Every stream then opens its own GET to the upstream, and every POST is forwarded to the same URL.
The stream:
- Upstream events are relayed to the client with their
event,idandretryfields, and recorded. They go through the network simulation. A block that only carriesretry:orid:is relayed as-is. - The client's query string is forwarded, without
token,access_token,lastEventId,client_idandannounce_session. - Replay is left to the upstream.
- When the upstream ends its stream, the client's stream ends too (
upstream closed: …). An upstream error ends it withupstream error.
POSTs:
- The message reaches the client's stream first, so it is recorded and rules run on it. Then it is sent as
POST <upstream URL>with the same body andContent-Type, and the client's query string withouttoken,access_token,client_id,announce_session,session_idandevent. - The client gets the upstream's status, body and content type, plus the
X-Hooklistener-SessionandX-Hooklistener-Messageheaders that name the stream and the recorded message. The upstream's answer is also recorded on the session, with an HTTP status chip in the console. - Connecting to the upstream may take up to 5 seconds, and the whole answer up to 15 seconds. Redirects aren't followed and failed requests aren't retried. If the upstream can't be reached or doesn't answer in time, the client gets
502with a JSONerror. - → upstream in the console composer sends a POST the same way.
Headers sent to the upstream:
- Extra upstream headers are always sent.
- With Forward the client's Authorization, user-agent, accept-language, x-request-id and last-event-id headers on, those client headers go too, on both the stream and POSTs. POSTs also forward
Accept.Authorizationis dropped when it carries the endpoint's access token. - Cookies and
X-Hooklistener-*headers are never forwarded.
See Proxy mode for more.