# Push stream

> Receive events over a WebSocket, with replay from the last event you processed.

Canonical: https://docs.whabuzz.com/stream/

The push stream delivers the same events as [webhooks](https://docs.whabuzz.com/guides/webhooks.md) over one WebSocket you open from your server. It needs no public URL, so it suits workers behind a firewall and local development. Use it next to webhooks or instead of them.

`wss://api.whabuzz.com/v1/stream?since=<seq>` is a WebSocket endpoint, not a REST endpoint, so it is not an operation in the OpenAPI file.

## Connect {#connect}

Authenticate with your API key in the `Sec-WebSocket-Protocol` header: offer `whabuzz.v1` and then the key. This works in browsers and in the standard `WebSocket` of Node 22 and later, which cannot set an `Authorization` header. Server-side clients that can set headers may send `Authorization: Bearer wb_live_...` instead.

```js
const key = process.env.WHABUZZ_API_KEY
let since = 0 // persist this between runs

function connect() {
  const ws = new WebSocket(`wss://api.whabuzz.com/v1/stream?since=${since}`, ['whabuzz.v1', key])
  ws.onmessage = (m) => {
    const frame = JSON.parse(m.data)
    if (frame.type === 'stream.replay_truncated') return // the socket closes next; reconnect from since
    handle(frame) // your code; dedupe on frame.dedupe_key
    since = frame.seq
  }
  ws.onclose = (e) => {
    if (e.code === 4429) return console.error('too many open streams for this tenant')
    setTimeout(connect, 1000) // reconnect; since resumes where you stopped
  }
  setInterval(() => ws.readyState === 1 && ws.send('ping'), 25_000) // answered with "pong"
}
connect()
```

## Frames {#frames}

Each frame is one JSON text message: the exact [webhook envelope](https://docs.whabuzz.com/events.md) plus `seq`, the event's position in your tenant's own log. `seq` only grows. Frames arrive in `seq` order, with no duplicates within one connection. Delivery is at least once across reconnects, so keep deduplicating on `dedupe_key`.

## Resume with since {#since}

Pass `since=<seq>` (the `seq` of the last frame you processed) to receive everything after it, oldest first, and then live events on the same socket. With no `since`, you get live events only. A `since` beyond the newest event is treated as the newest event.

A single connection replays at most 10,000 events. If you are further behind, the stream sends `{"type":"stream.replay_truncated","last_seq":N}` and closes with code 1000. Reconnect with `since=N` and carry on.

Replayed frames are rebuilt from your event log, so `display_phone_number` can be `null` and `raw` can be `null` when the raw body has moved to archive storage. Live frames carry the full envelope. Fetch the event by id from the [events API](https://docs.whabuzz.com/events.md) when you need the archived body.

## Limits and close codes {#limits}

- 10 open streams per tenant. The 11th is accepted and closed at once with code **4429** (`stream_cap`). Close one of your own streams, then reconnect.
- Frames above 1 MB drop the `raw` field.
- Idle sockets cost nothing on our side. Send `ping` as a text message to get `pong`; there is no server ping.

| Code | Meaning |
|---|---|
| 1000 | Normal close, including `replay_truncated`. Reconnect with `since`. |
| 4429 | Tenant is at its 10-stream cap. |

Before the socket opens, errors are ordinary HTTP responses: `401` for a bad key, `403` for a suspended tenant, `400` for a bad `since`, `426` when the request is not a WebSocket upgrade.

## Sandbox {#sandbox}

With a `wb_test_` key, an open stream counts as a connected webhook endpoint, so the tenant shows as connected without a public URL. Live keys are unchanged: a live tenant still needs a webhook endpoint to count as connected.
