Skip to main content
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

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:
You can safely retry an operation that returns INVALID_STATE — the detail tells you what happened.

Creating a session

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.

Accept (operator side)

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)

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.).
Once the telemetry preflight ships (roadmap), start will reject unless a fresh in-geofence telemetry sample exists. Today the check is wall-clock-only.

End

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:
Once ENDED, the settlement payload is composable — see Settlement.

Cancel (pre-LIVE)

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:
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

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] 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.

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 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 for endpoint reference.