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:
{
"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
| 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]
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.