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

                  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
StateMeaning
REQUESTEDConsumer POSTed /sessions. Waiting for an operator. The API has already verified the consumer’s on-chain pool balance covers rate × maxDuration.
ASSIGNEDOperator 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.
LIVEFrames 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.
ENDEDFinal state. Meter populated, relayer queues an on-chain settleFromPool(...) against the consumer’s pool.
CANCELLEDPre-LIVE escape. Consumer DELETE, operator bulk cancel (POST /sessions/cancel-all-assignments), or a tied teardown from those paths.
EXPIREDTwo 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:
{
  "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

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.

Accept (operator side)

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)

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

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.

Cancel (pre-LIVE)

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

EndpointReturns
GET /sessions/:idOne session
GET /sessionsThe 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] 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.
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 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.