# Events

Every unit transition and every scaling change is written to an append-only log, in the **same database transaction** as the change. Read it with a cursor.

**curl**

```bash
# long-poll: returns as soon as an event after cursor 0 exists, or after 30 s
curl -sS -G "$UNITS/events" -H "$AUTH" \
  --data-urlencode "after=0" --data-urlencode "wait=30" --data-urlencode "limit=100" \
  --data-urlencode "types=unit.ready,unit.exited,unit.destroyed"
```

**TypeScript**

```ts
let after = 0;
for (;;) {
  const { status, body } = await call(
    "GET",
    `${UNITS}/events?after=${after}&wait=30&types=unit.ready,unit.exited,unit.destroyed`,
  );
  if (status === 410) { after = body.resume_after; continue; }      // cursor_expired
  for (const e of body.events) {
    console.log(e.seq, e.type, e.unit?.key, e.data);
    // persist e.seq next to your own state: it is the dedupe key
  }
  after = body.next_after;
}
```

**Python**

```python
after = 0
while True:
    status, body = call(
        "GET", f"{UNITS}/events",
        params={"after": after, "wait": 30, "types": "unit.ready,unit.exited,unit.destroyed"},
    )
    if status == 410:                      # cursor-expired
        after = body["resume_after"]
        continue
    for e in body["events"]:
        print(e["seq"], e["type"], (e["unit"] or {}).get("key"), e["data"])
        # persist e["seq"] next to your own state: it is the dedupe key
    after = body["next_after"]
```


## The envelope (contract v1)

```json
{
  "seq": 1042,
  "id": "evt_0e8b5c6a-6f43-4b6b-8d36-6c9c1f2ad711",
  "type": "unit.exited",
  "contract_version": 1,
  "occurred_at": "2026-10-14T10:31:07Z",
  "service_instance_id": "6c9d...",
  "unit": {"id": "3f2a...", "key": "job-81", "labels": {"queue": "render"}},
  "data": {"code": 0, "restarted": false}
}
```

`seq` is **gapless and strictly increasing per Workload**, so a cursor never skips an event that commits late. Consumers must ignore unknown fields and unknown event types: additions are never a new version.

| Type | `data` |
|---|---|
| `unit.created`, `unit.starting`, `unit.ready`, `unit.stopped`, `unit.idle`, `unit.busy`, `unit.draining` | |
| `unit.accepting`, `unit.not_accepting` | |
| `unit.allocated`, `unit.allocation_delivered`, `unit.allocation_undelivered` | |
| `unit.exited` | `{code, restarted}` |
| `unit.failed` | `{reason}` |
| `unit.destroyed` | `{reason}` (`expired` when destroyed after being stopped too long) |
| `units.scaled` | `{from, to, reason}`; `unit` is `null` |
| `rollout.started`, `rollout.completed`, `rollout.paused` | `unit` is `null` |
| `webhook.ping` (webhook deliveries only) | |
| `events.gap` (webhook deliveries only) | `{from_seq, to_seq}`: events a re-enabled webhook missed because they were no longer kept |

`unit.ready` and `unit.allocated` carry the unit's private name and public address, so a matchmaker needs no second read. `idle` and `busy` are emitted on **transitions only**.

## Parameters

| Parameter | Meaning |
|---|---|
| `after` | the last `seq` you saw; `0` starts at the oldest kept event |
| `types` | comma-separated list to keep |
| `limit` | up to 500 |
| `wait` | 0 to 30 seconds: with nothing new, hold the request and return the moment an event arrives |

The response is `{events, next_after, oldest_retained_seq}`. Send `next_after` as the next `after`. Events are kept **30 days**. A cursor older than that answers `410 cursor_expired` with `resume_after`.

## Delivery is at-least-once

Persist the last `seq` you processed **in the same transaction as your own effect**, and treat a repeated `seq` as already done. This is the same rule that makes webhooks safe.
