Skip to main content

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=1 adds one named event with the stream's ids, the only form of them a browser EventSource can read (it can't see response headers or comments):

    event: hooklistener-session
    data: {"session_id":"0b9c6f3e-…","client_id":"7f1c…"}

    It has no id:, so it leaves the client's Last-Event-ID alone, 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 or message, so the event reaches onmessage. Named events only reach addEventListener("<name>", …).
  • id: carries the event id: the one you send (the composer's ID field, event_id in 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_ms in 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 : ping comment.

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:

  1. 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.
  2. The client reconnects with the id of the last event it saw. EventSource sends the Last-Event-ID header by itself; other clients can send the header or a ?lastEventId= query parameter.
  3. 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 events and 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-ID values start a fresh stream with no replay.
  • To turn replay off, clear Replay events after Last-Event-ID when clients reconnect.
  • In proxy mode Hooklistener doesn't replay: the upstream receives the client's Last-Event-ID and 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:

  1. A session id: ?session_id=<id> or the X-Hooklistener-Session: <id> header. Each stream announces its id in its X-Hooklistener-Session response header, its : connected comment and, with ?announce_session=1, a hooklistener-session event.
  2. A client id: ?client_id=<name>, matching the client_id the 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.
  3. 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​

StatusErrorCause
400Endpoint is not configured for SSEThe slug belongs to a WebSocket, Socket.IO or MQTT endpoint
401Missing or invalid access token
403Origin not allowedThe browser's origin isn't in Allowed origins
404No open stream with that session id on this endpointThe session_id is unknown, or that stream has closed
404No 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
404The stream is no longer connectedThe stream closed while the message was being delivered
409The session id and the client id name different streams; pass only one of themsession_id and client_id point at different streams
409No 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
413Message is larger than 1000000 bytesThe body is over 1 MB
429Too many messagesOne client address sent more POSTs per second than the endpoint's inbound rate limit (200 by default)
429 / 413Rate limit exceeded: …; the stream was closed / Message too large: …; the stream was closedThe 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
503Endpoint is paused

CORS​

Browsers can open the stream and POST from any page:

  • When a request's Origin is allowed, it is echoed in Access-Control-Allow-Origin with Access-Control-Allow-Credentials: true and Vary: Origin. Credentialed requests work: new EventSource(url, { withCredentials: true }) and fetch(url, { credentials: "include" }). With an empty Allowed origins list, every origin is allowed. Requests without an Origin get *.
  • Allowed request headers: Last-Event-ID, Content-Type, Authorization, Cache-Control, X-Hooklistener-Token, X-Hooklistener-Session and X-Hooklistener-Event. Allowed methods: GET, POST and OPTIONS.
  • X-Hooklistener-Session is exposed, so a page can read the session id from a fetch response.
  • 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, id and retry fields, and recorded. They go through the network simulation. A block that only carries retry: or id: is relayed as-is.
  • The client's query string is forwarded, without token, access_token, lastEventId, client_id and announce_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 with upstream 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 and Content-Type, and the client's query string without token, access_token, client_id, announce_session, session_id and event.
  • The client gets the upstream's status, body and content type, plus the X-Hooklistener-Session and X-Hooklistener-Message headers 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 502 with a JSON error.
  • → 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. Authorization is dropped when it carries the endpoint's access token.
  • Cookies and X-Hooklistener-* headers are never forwarded.

See Proxy mode for more.