# Events

The connection pushes frames you did not ask for. Each one reports a status change, so you
can stop polling.

An event frame carries an `event` name and a `data` body, and never a `msgid`:

```json
{ "event": "run", "data": { "id": "...", "status": "running", ... } }
```

**There is nothing to subscribe to.** Open the connection and the events arrive. You cannot
turn them off, filter them server-side, or ask for a replay.

**Delivery only happens while you are connected.** An event that fires while you are away is
gone. After a reconnect, read back what you care about instead of waiting for a push that
already happened. See [Reconnect with a fresh URL](/websocket/connecting#4-reconnect-with-a-fresh-url).

## The three events

| Event | Who receives it | Sent when |
|---|---|---|
| `run` | Every connection whose token can view that phone's slot | A phone run is created, and on every status change after that |
| `submission` | Connections opened by the submission's owner | The submission's `status` changes |
| `billing_request` | Connections opened by the request's owner | The request's `status` changes |

### run

`data` is the phone run, the same shape `/app/phones/runs/get` returns. The first one arrives
as soon as the run is created, in `queued`.

```json
{
  "event": "run",
  "data": {
    "id": "0ec4...",
    "slot": "b3f1c0de-0000-4000-8000-000000000001",
    "kind": "macro",
    "status": "running",
    "label": "post-to-story",
    "result": null,
    "error": null,
    "started_at": "2026-01-09T10:15:02+00:00",
    "finished_at": null,
    "created_at": "2026-01-09T10:15:01+00:00"
  }
}
```

`kind` is `macro`, `command` or `agent`. `status` moves through `queued` and `running` to
`succeeded`, `failed` or `cancelled`. Those last three are final, and nothing follows them.
`result` is filled in on success and its shape depends on `kind`. `error` is a readable reason
when the run failed.

### submission

`data` is the submission, the same shape `/app/submissions/get` returns.

```json
{
  "event": "submission",
  "data": {
    "id": 4821,
    "platform": "tiktok",
    "account_id": "9b2e...",
    "caption": "new drop",
    "draft": false,
    "status": "published",
    "attempts": 1,
    "failure": null,
    "started_at": "2026-01-09T10:14:00+00:00",
    "finished_at": "2026-01-09T10:17:40+00:00",
    "created_at": "2026-01-09T10:13:58+00:00",
    "updated_at": "2026-01-09T10:17:40+00:00"
  }
}
```

`published`, `drafted`, `failed` and `cancelled` are final. On `failed`, `failure` holds a
`step` and a `message`. The [status table](/mcp/overview#submission-status) lists every value.

### billing_request

`data` is the billing request, the same shape `/app/billing/requests/get` returns.

```json
{
  "event": "billing_request",
  "data": {
    "request_id": "7c19...",
    "status": "approved",
    "to_phones": 6,
    "approval_url": "https://0bull.net/billing/requests/7c19...",
    "expires_at": "2026-01-10T10:13:58+00:00",
    "resolved_at": "2026-01-09T10:20:11+00:00"
  }
}
```

`status` is `pending`, `approved`, `declined`, `failed` or `expired`. Watch for it instead of
polling after you file a phone count change.

## Worked example: start a macro and wait for it

Start a macro, then sit on the event stream until that run reaches a final status. No
polling, no sleep loop.

`socket.events()` yields typed events and stops after `timeout` of silence, counted in
seconds in Python and in milliseconds in TypeScript. It also yields submission and billing
request events, which is why the loop checks the type before reading `event.run`: Python
matches on `SubmissionEvent` and `BillingRequestEvent`, TypeScript on an `Event` union
discriminated by `type` (`run`, `submission` or `billing_request`).

<CodeTabs syncKey="lang">

```python title="Python"
from zerobull import RunEvent, ZeroBull

with ZeroBull() as client:
    with client.socket() as socket:
        phones = socket.phones.list()
        if not phones:
            raise SystemExit("No phones available")

        run = socket.phones.run_macro(phones[0].slot, workflow="post-to-story")
        print(run.id, run.status)

        for event in socket.events(timeout=300):
            if not isinstance(event, RunEvent) or event.run.id != run.id:
                continue
            print(event.run.status)
            if event.run.is_terminal:
                print(event.run.result, event.run.error)
                break
```

```ts title="TypeScript"
import { TERMINAL_RUN_STATUSES, ZeroBull } from "@0bull/sdk";

const client = new ZeroBull();
const socket = client.socket();
await socket.connect();

try {
  const phones = await socket.phones.list();
  const phone = phones[0];
  if (!phone) throw new Error("No phones available");

  const run = await socket.phones.runMacro(phone.slot, { workflow: "post-to-story" });
  console.log(run.id, run.status);

  for await (const event of socket.events({ timeout: 300_000 })) {
    if (event.type !== "run" || event.run.id !== run.id) continue;
    console.log(event.run.status);
    if (TERMINAL_RUN_STATUSES.has(event.run.status)) {
      console.log(event.run.result, event.run.error);
      break;
    }
  }
} finally {
  socket.close();
}
```

</CodeTabs>

### Without an SDK

The same loop in raw frames: send `/app/phones/macros`, keep the `id` from the `202` reply,
then read every incoming frame and act on the ones where `event` is `"run"` and
`data.id` matches. See [Frames and replies](/websocket/frames) for the envelope and
[Connecting](/websocket/raw-client) for a raw read loop to build on.

## Next

- [Phones](/websocket/phones): the funs that create runs.
- [Submissions](/websocket/submissions): publishing, and the statuses a submission moves through.
- [Billing](/websocket/billing): filing a phone count change and waiting on its approval.
