Events (SSE)
A single-direction Server-Sent Events stream for live state. Use it instead of polling for QR codes, channel state changes, and message activity.
Endpoint
Authenticate via Authorization: Bearer … or ?token=… query string. (Browser EventSource can't set headers; the query-token path is for that case.)
curl -s "$WHATISUP_API/v1/events" \
-H "Authorization: Bearer $WHATISUP_API_KEY"For browser code specifically, EventSource handles reconnection for you. Every
frame carries a named SSE event, so addEventListener('message', …) will
never fire — listen per name, or use onmessage's named-event equivalent:
const es = new EventSource(`${WHATISUP_API}/v1/events?token=${WHATISUP_API_KEY}`);
for (const name of [
'channel.status_changed', 'channel.qr', 'channel.connected',
'channel.disconnected', 'channel.needs_attention',
'message.received', 'message.sent', 'message.status',
'contact.resolved',
]) {
es.addEventListener(name, (e) => {
const data = JSON.parse(e.data);
console.log(name, data.channel_id, data);
});
}
// Heartbeat. Ignore it, or use it as a liveness check.
es.addEventListener('ping', () => {});
es.onerror = () => { /* EventSource retries on its own */ };Filtering
There is no server-side filtering. /v1/events accepts no filter query
params — a channel_id= or events= you pass is ignored, not honoured. Filter
client-side on the frame name and data.channel_id.
Two things do narrow the stream for you, both implicit:
| Narrowing | How |
|---|---|
| Account scope | You only ever receive events for your own customer_id. |
| Channel scope | A channel-bound API key receives only that channel's events. |
If you want one channel and hold an account-wide key, issue a channel-bound key
(POST /v1/channels with issue_scoped_key: true) and stream with that.
Frame format
Standard SSE, with a named event and an id on every frame:
: stream-open
event: channel.qr
id: 1
data: {"channel_id":"8d653c66-e4ff-43ee-97da-3de5ad5680d4","qr_png_base64":"iVBORw0KGgo...","expires_at":1779425257573}
event: channel.connected
id: 2
data: {"channel_id":"8d653c66-e4ff-43ee-97da-3de5ad5680d4","phone_number":"5511999999999"}
event: ping
id: 3
data: {"ts":1779425282573}
: stream-openis a one-shot comment sent when the stream opens.- The keepalive is a real named
pingevent every 25 seconds — not a comment line. Most reverse proxies idle-close at 30s. id:is a per-connection counter starting at 1. It is not a resume cursor:Last-Event-IDis not honoured, and reconnecting restarts at 1.
What's on the wire
The SSE vocabulary is not identical to the webhook vocabulary. Three names
exist only here, several webhook events never appear here, and qr.updated
(webhooks) is called channel.qr on SSE. Do not write one handler keyed on
webhook names and point it at this stream.
| SSE event | Meaning | Webhook equivalent |
|---|---|---|
channel.status_changed | Lifecycle state moved. Carries status. | channel.status |
channel.qr | New QR available (qr_png_base64, expires_at). | qr.updated |
channel.connected | Pairing complete. | channel.connected |
channel.disconnected | Session ended (with reason). | channel.disconnected |
channel.needs_attention | Channel is stuck and needs a human (reason, at). | (SSE only) |
message.received | Inbound from WhatsApp. | message.received |
message.sent | Outbound handed to WhatsApp. | message.sent |
message.status | Delivery state moved (sent/delivered/read/played/failed). | message.status |
group.created, group.updated, group.participant_added, group.participant_removed, group.admin_promoted, group.admin_demoted | Group changes. | same names |
contact.resolved | A LID-only contact resolved to a phone JID. | contact.resolved |
ping | Keepalive every 25s. | (SSE only) |
There is no message.delivered and no message.failed on this stream — both
are message.status frames discriminated by the status field. Chat, order,
cart, story, call and presence events are webhook-only; they do not appear
on SSE.
Every frame's data object carries channel_id at the top level, then the
event payload flattened alongside it — there is no envelope/data nesting and
no signature. The payload shapes match Webhooks → Event payloads;
the wrapper does not.
Limits
- 10 concurrent streams per account. The 11th gets
429withcode: "sse_connection_limit". - Opening a stream costs one rate-limit token like any other authenticated call.
- No replay buffer: events emitted while you are disconnected are lost. Use webhooks for anything that must not be missed.
When to use SSE vs webhooks
| Want | Use |
|---|---|
| First-party app: dashboard, internal tool, your own UI | SSE — no public webhook to maintain |
| Third-party: someone else's HTTPS endpoint | Webhooks — durable, retried, audited |
| Both | Both. They don't conflict. |
Webhooks are the durable record (retried, logged, replayable). SSE is the live feed (best-effort, no buffering — if you disconnect mid-event, you lose it).