> ## Documentation Index
> Fetch the complete documentation index at: https://docs.luxxon.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Sessions

> The atomic unit of work. One request → one match → one live stream.

A **session** is one live video feed from one point on Earth, for a
bounded duration, charged at a locked-in per-second rate. Everything
the platform does cashes out into a session lifecycle.

## State machine

```
                  accept        start +              end
REQUESTED ──────▶ ASSIGNED ───▶ first frame ──▶ LIVE ──▶ ENDED
   │              │             decoded                   │
   │ DELETE       │ DELETE / bulk-cancel /        cleanSeconds × rate
   ▼              ▼ pre-LIVE expiry                → chargedMicroUsdc
CANCELLED       CANCELLED / EXPIRED               → settleFromPool() on-chain
```

| State       | Meaning                                                                                                                                                                                                                                                                                                           |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQUESTED` | Consumer POSTed `/sessions`. Waiting for an operator. The API has already verified the consumer's on-chain pool balance covers `rate × maxDuration`.                                                                                                                                                              |
| `ASSIGNED`  | Operator workspace accepted (or auto-dispatched). The operator can `/start` immediately — but `start` does NOT flip the state. The state stays `ASSIGNED` through publisher warmup; it only advances to `LIVE` once the first decoded frame is buffered server-side.                                              |
| `LIVE`      | Frames are flowing. Meter is anchored at `startedAt`, which is stamped at the first-frame transition (NOT at `/start`) so the operator isn't billed for WebRTC negotiation + first-keyframe latency. `get_frame` returns bytes immediately.                                                                       |
| `ENDED`     | Final state. Meter populated, relayer queues an on-chain `settleFromPool(...)` against the consumer's pool.                                                                                                                                                                                                       |
| `CANCELLED` | Pre-LIVE escape. Consumer `DELETE`, operator bulk cancel (`POST /sessions/cancel-all-assignments`), or a tied teardown from those paths.                                                                                                                                                                          |
| `EXPIRED`   | Two flavors: (a) LIVE session overran `maxDurationSeconds` without `/end` — the system finalizes it and queues settlement, OR (b) pre-LIVE session aged past `createdAt + waitTimeoutSeconds` without media ever flowing — the system releases the operator and tears down the live\_input (no meter, no settle). |

`PAUSED` is in the enum and on the roadmap.

## Race safety

All three operator-side transitions (`accept`, `start`, `end`) and
consumer `cancel` use **CAS** (compare-and-swap) updates — the SQL
includes a `WHERE state=...` clause so only one writer wins. The loser
sees a fresh fetch of the actual current state, surfaced as
`INVALID_STATE` with the conflicting state in `detail`:

```json theme={null}
{
  "code": "INVALID_STATE",
  "detail": "session:accept:ASSIGNED"
}
```

You can safely retry an operation that returns `INVALID_STATE` — the
detail tells you what happened.

## Creating a session

```bash theme={null}
curl -X POST .../sessions \
  -H "Authorization: Bearer $KEY" \
  -d '{
    "lat": 4.71,
    "lng": -74.07,
    "maxDurationSeconds": 300,
    "waitTimeoutSeconds": 300
  }'
```

The session is created in `REQUESTED` state. The rate is computed
and stamped (immutable for the rest of the session). The `hold` =
`rate × maxDurationSeconds` is calculated for client-side display.

`maxDurationSeconds` bounds the LIVE meter (worst-case settle hold).
`waitTimeoutSeconds` is independent — it bounds how long the session
sits in `REQUESTED`/`ASSIGNED` before the cron flips it to `EXPIRED`.
Pass it when you want the consumer to wait longer (or shorter) than
the default 5 minutes. Server clamps to `[5, 3600]`.

Before accepting the request, the API reads
`LuxxonSettlement.deposits(consumerWallet)` on-chain and rejects
with `INSUFFICIENT_CREDIT` (HTTP 400) if the pool balance can't
cover the worst-case meter. Top up via
`LuxxonSettlement.deposit(amount)` and retry — the session will
clear immediately once the chain reflects the new balance.

Pass `quoteId` to lock the rate via the quote-locked flow — see
[Pricing](/concepts/pricing/#two-flows-spot-and-quote-locked).

## Accept (operator side)

```bash theme={null}
curl -X POST .../sessions/$ID/accept \
  -H "Authorization: Bearer $OPERATOR_KEY" \
  -d '{}'
```

CAS: `REQUESTED → ASSIGNED`. The operator workspace's id gets
stamped on `operator_workspace_id`. There is no signature step —
the consumer's pool already covers the worst-case meter, and the
on-chain contract enforces the meter invariant at settle time, so
no per-session authorization payload needs to flow back to the
consumer. The operator can `/start` immediately.

There's no SpiceDB write at accept time; session visibility is a
direct FK match against `consumer_workspace_id` /
`operator_workspace_id`.

The workspace must have `SUPPLIER` in `roles`, else
`session:notSupplier` (403).

## Start (operator side)

```bash theme={null}
curl -X POST .../sessions/$ID/start \
  -H "Authorization: Bearer $OPERATOR_KEY"
```

Provisions the Cloudflare Stream `live_input`, returns the WHIP +
WHEP URLs, flips the operator workspace to `BUSY`. **State stays
`ASSIGNED`** — the transition to `LIVE` only fires once the
server-side WHEP subscriber decodes a frame off the wire. That
means consumers polling for `state === "LIVE"` won't see it until
`get_frame` will actually return bytes — no more 3-5s "LIVE but
404 on /frame" race.

`startedAt` is stamped at the LIVE transition (not at `/start`), so
the meter only charges from media-flowing time. WebRTC negotiation

* first-keyframe latency is free.

`start` is **idempotent**: if a `live_input` was already minted for
this session, the second call returns the existing WHIP/WHEP URLs
instead of creating a new one. Operator can safely re-fetch (page
reload mid-warmup, double-click on the Start button, etc.).

<Warning>
  Once the **telemetry preflight** ships (roadmap), `start` will reject
  unless a fresh in-geofence telemetry sample exists. Today the check
  is wall-clock-only.
</Warning>

## End

```bash theme={null}
curl -X POST .../sessions/$ID/end \
  -H "Authorization: Bearer $KEY"   # operator OR consumer key
```

Required scope: `sessions:operate` OR `sessions:create` (either side
may end — operator hangs up, or consumer ends the call). Wallet-session
callers pass by FK match against the consumer or operator workspace
on the session.

CAS: `LIVE → ENDED`. The meter runs:

```
cleanSeconds       = floor((endedAt − startedAt) / 1000)
failedSeconds      = 0                              # roadmap: QoS-driven
chargedMicroUsdc   = cleanSeconds × ratePerSecond
```

Once `ENDED`, the settlement payload is composable — see
[Settlement](/concepts/settlement/).

## Cancel (pre-LIVE)

```bash theme={null}
curl -X DELETE .../sessions/$ID \
  -H "Authorization: Bearer $CONSUMER_KEY"
```

Allowed in `REQUESTED` or `ASSIGNED`. Transitions to `CANCELLED`. No
meter, no charge. Tears down the Cloudflare `live_input` and
releases the operator workspace (`BUSY → ONLINE`) in the same
transaction so the workspace can take new work immediately.

`DELETE` on a LIVE session is **not** end — it returns `INVALID_STATE`.
Use `POST /:id/end` for that.

### Operator bulk cancel

When an operator's device crashes mid-stream they're left with stuck
`ASSIGNED` rows that pin the workspace as `BUSY` until the consumer
cancels or the pre-LIVE expiry fires. The operator can reclaim the
workspace immediately:

```bash theme={null}
curl -X POST .../sessions/cancel-all-assignments \
  -H "Authorization: Bearer $OPERATOR_KEY"
```

Cancels every `ASSIGNED` session where the caller's workspace is
the operator. Returns `{ count, cancelled[] }`. `LIVE` sessions are
NOT touched — those still finish via `/end` so the meter can run.

Scope: `sessions:operate`.

### Pre-LIVE expiry

A background cron sweeps `REQUESTED` / `ASSIGNED` rows where
`createdAt + waitTimeoutSeconds < NOW()` and moves them to
`EXPIRED`. This is the backstop for an operator that called
`/start` but never published, or a `REQUESTED` session that nothing
ever picked up. No meter is run (`startedAt` is null), but the
operator workspace + Cloudflare `live_input` are still torn down.

## Reading

| Endpoint            | Returns                                           |
| ------------------- | ------------------------------------------------- |
| `GET /sessions/:id` | One session                                       |
| `GET /sessions`     | The caller's workspace's sessions (consumer-side) |

Both gated by direct FK match against `consumer_workspace_id` /
`operator_workspace_id`. Either side of the session sees it; nothing
else does.

## Video plane

`POST /sessions/:id/start` returns the WebRTC URLs the operator
uses to publish and any consumer uses to subscribe:

* `whipUrl` — the operator POSTs an SDP offer here to publish
  (WHIP). The URL itself is the auth bearer; treat it as secret.
* `whepUrl` — anyone with this URL can subscribe (WHEP). Treat
  as a session-scoped secret.

Both URLs are **stable for the lifetime of the underlying
live\_input**, so either side can re-fetch them mid-session if a
browser tab crashes or a device hands off to another:

* `GET /sessions/:id/viewer-token` → `{ whepUrl }` (consumer + operator)
* `GET /sessions/:id/producer-token` → `{ whipUrl }` (operator only)

The edge is Cloudflare Stream WebRTC. Sub-second latency,
open protocols on both sides, no proprietary SDK to integrate.

**From the console:** open
[**console.luxxon.dev → /sessions/\[sid\]**](https://console.luxxon.dev)
for the embedded media surface. The page renders the WHEP viewer
when you're the consumer side and the in-page WHIP publisher when
you're the operator side (toggle via the Request / Operate switch).
The publisher opens `getUserMedia` (rear cam preferred), fetches
the WHIP URL via `/producer-token`, negotiates the publish, and
posts location heartbeats every \~5s while publishing.

## Frames for AI agents

`GET /sessions/:id/frame` returns `image/jpeg` of the latest
decoded video frame from the operator's feed. Designed for
agents that poll a single observation rather than consuming a
continuous WebRTC stream — the JPEG goes straight to a vision
model without a media-decoding stack on the caller's side.

Implementation: lx-api runs a per-session WHEP subscriber that
decodes and caches the latest frame (\~1 fps). The session's
`state` transition to `LIVE` is itself gated on the first buffered
JPEG — so the first `get_frame` call after `state === "LIVE"`
always returns bytes, with `Cache-Control: no-store`. Calls during
`ASSIGNED` (pre-first-frame) return `404 FRAME_NOT_AVAILABLE`,
which is also what consumers waiting via the SDK's `waitFor` see
until `LIVE` lands.

```bash theme={null}
curl -H "Authorization: Bearer $KEY" \
  $API/sessions/$SESSION_ID/frame -o frame.jpg
```

## Telemetry-driven meter

`cleanSeconds` is the wall-clock duration minus the sum of
`LxDisconnectWindow` rows opened during the session.
Disconnect windows have a `reason`:

* `NETWORK_ERROR` — Cloudflare's edge stopped seeing media
* `STALE_TELEMETRY` — the operator's location heartbeats
  stopped advancing
* `OUTSIDE_GEOFENCE` — the operator strayed outside the
  consumer's requested radius

The relayer reads `cleanSeconds` directly and passes it as
`chargeableSeconds` in `settleFromPool()`. See
[Trust model](/concepts/trust-model/) for the discriminator.

## Not in v1 yet

* `PAUSED` state (consumer pauses mid-flight — drones,
  bookended outages)
* Per-frame `/observations` SKU (priced separately from
  per-second video)
* H.264 server-side frame decode for the agent-frame endpoint
  (works today for desktop publishers via VP8; iOS Safari
  publishers fall back to H.264, where `GET /frame` returns
  `FRAME_NOT_AVAILABLE` until we wire a second decode path —
  browser-to-browser WHEP playback is unaffected)

See [/sessions](/api-reference/sessions/list-workspace-sessions) for endpoint reference.
