# stapel-video 0.8.0

Video calls: rooms with shareable join codes, an access-level admission model (public / scope-trusted / restricted lobby) with a realtime waiting room over WebSockets, host admit/deny controls, join-token minting through a pluggable video-provider seam (LiveKit by default), a recording-egress seam (start/stop + a video.egress_ended event) that integrates with stapel-recordings by event and never by import, and a presence meter — per-connection spans fed by the media server's own join/leave webhooks, reconciled by a sweeper so a crashed client cannot bill forever, and read back as unioned presence time and a co-presence matrix for whatever prices them, partitioned by an opaque scope_key carried on the join grant so a workspace administrator can read their own people's monthly call time behind their own mandate.

Contract: axes 3 · surface 31 · extension points 13 · operations 8 · error codes 51.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- DEFAULT_ACCESS_LEVEL [enum, default "restricted"] — How open new rooms are by default
  The admission level a room gets when the creator does not pick one. "public": anyone with the join code joins instantly. "scope_trusted": members of the same workspace/org/tenant join instantly, outsiders wait. "restricted" (default): everyone but the host waits in the lobby. A client can always override per room.
- DEFAULT_ADMIT_REQUIRED [bool, default true] — Whether new rooms start with a waiting room
  Whether a freshly created room turns the lobby on by default (guests who are not auto-admitted wait for a host to let them in). True by default — the safe, gate-the-door setting; set False for open drop-in rooms. A client can override per room.
- VIDEO_PROVIDER [enum, default "stapel_video.providers.livekit.LiveKitProvider"] — Which service runs the video calls
  Selects the video-conferencing vendor behind every call. The default routes calls through LiveKit; point it at another backend that implements the provider seam to switch vendors without code changes. Changes who carries the media, not which endpoints exist.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- admit_participant — stapel_video.services.admit_participant
  The host's yes, as one operation: flips a waiting participant to admitted, mints that guest's join token and pushes lobby.admitted to the room group. A custom host console or moderation bot calls this instead of writing RoomParticipant.status itself — a bare status write admits nobody, because no token is issued and the waiting client is never told.
- backfill_scope_keys — stapel_video.presence.backfill_scope_keys
  Stamp scope_key onto spans recorded before the grant carried one, using a host callable room_key -> scope_key|None (the management command video_backfill_scope is its CLI). Idempotent because the population is defined as scope_key IS NULL, so a crashed run resumes and a second run is a no-op — an UPDATE written by hand is neither.
- close_span — stapel_video.presence.close_span
  instead of: stapel_video.models.ParticipantSpan.save
  End one open span, clamped to be no earlier than its start. A conditional update, so an already-closed span stands and two racing closers resolve to the first — append-only enforced by the database, not by every caller remembering it.
- close_spans_explicitly — stapel_video.presence.close_spans_explicitly
  The host's leave button and kick path: close what this person still has open in this room. Deliberately weak — never reopens or re-dates, so a grace-window policy stays a product policy and a return opens a new span.
- deny_participant — stapel_video.services.deny_participant
  The host's no, and it is sticky: a denied guest's re-join stays denied rather than returning to the lobby, and lobby.denied goes out to the room group. Call it instead of deleting the participant row — a deleted row comes back as a fresh WAITING arrival on the next join, which is a knock the host has to answer again.
- handle_participant_joined — stapel_video.presence.handle_participant_joined
  The built-in reaction to the media server's participant_joined webhook: open a presence span on the PROVIDER's join timestamp. Registered by default; name it in WEBHOOK_HANDLERS only to wrap it. Reading the event yourself is how the join time becomes 'when our queue got round to it'.
- handle_participant_left — stapel_video.presence.handle_participant_left
  The built-in reaction to participant_left — the one departure signal that survives a closed laptop. Closes the open span, or materializes the whole stay when the pair arrived out of order. Deduplicates on (connection_id, joined_at), which is what makes at-least-once delivery harmless.
- handle_webhook — stapel_video.services.handle_webhook
  instead of: livekit.api.WebhookReceiver
  Verify a provider webhook's signature, decode it to a normalized dict and emit video.egress_ended when a recording finished. The shipped ingress view is its only caller in a normal mount; a host that terminates provider webhooks at its own edge (an existing /webhooks router, a queue worker) must feed the raw body and Authorization header through this rather than re-implement verification — an unverified body is an open door into the recording pipeline. A bad signature raises VideoProviderError, which the shipped view maps to a 400.
- join_room — stapel_video.services.join_room
  instead of: livekit.api.AccessToken, stapel_video.providers.VideoProvider.mint_join_token
  THE admission decision: resolves a user against the room's access level and lobby switch into admitted (with a freshly minted join token), waiting (the lobby event goes out to the host's clients) or a sticky denied. Every path that puts a user into a call goes through this — minting a token straight off the provider hands out a media credential that no access level, no host denial and no waiting room ever saw.
- notify_lobby — stapel_video.realtime.notify_lobby
  instead of: channels.layers.get_channel_layer
  Push a live event to a room's lobby group — the one call for reaching the clients watching a room, instead of taking the channel layer into your own hands. Note what it deliberately does NOT do: without Channels, or with no channel layer configured, it returns silently. A feature that must not fail quietly (a host kick, a room PIN) pairs it with stapel-core's register_dependency_check and a visible fallback; a silent no-op here is exactly how such a feature spends a day in production doing nothing.
- pseudonymize_user — stapel_video.presence.pseudonymize_user
  Erase a person from the meter without destroying the meter: their user_id becomes a keyed digest, so counters and pair overlaps do not move. Already wired into the GDPR provider; deleting the spans instead silently restates closed periods.
- purge_presence_spans — stapel_video.tasks.purge_presence_spans
  The schedulable form of span retention (400 days by default). A retention window nothing runs is a number in a settings file, and stapel_video.W004 says so at boot.
- stop_egress — stapel_video.services.stop_egress
  instead of: livekit.api.LiveKitAPI.egress.stop_egress, stapel_video.providers.VideoProvider.stop_room_egress
  Stop an active recording through the configured provider — the counterpart of start_egress and the only stop worth calling: the seam is where "stopping an already-finished egress must not raise" is guaranteed, so a stop racing the natural end of a call is not an error the product has to handle.
- sweep_open_spans — stapel_video.presence.sweep_open_spans
  Reconcile open spans against the provider's live roster: confirm who is there, close zombies at their last confirmed moment, open spans for connections whose join webhook was lost. A deployment that meters without it is metering an upper bound, not a duration.
- sweep_presence — stapel_video.tasks.sweep_presence
  The schedulable form of the sweeper — a plain callable for cron, a Celery task where celery is installed. Nothing else closes a span whose departure webhook was lost.
### factory
- create_room — stapel_video.services.create_room
  instead of: stapel_video.models.Room.objects.create, stapel_video.providers.VideoProvider.create_room
  Create a call end to end in one transaction: allocate a collision-free join code, provision the media room through the VIDEO_PROVIDER seam and seat the creator as an already-admitted host. Reach for this whenever a product opens a room from its own code (a booking is confirmed, a calendar event starts) — a hand-written Room row is a room with an empty provider_room_ref whose own creator is not a participant, and it fails at the first join.
- get_room — stapel_video.services.get_room
  Look a room up by the shareable join code — the join code, not the UUID pk, is the identity every client, URL and invitation carries. Returns None instead of raising, so the caller owns the 404.
- get_video_beat_schedule — stapel_video.tasks.get_video_beat_schedule
  Both scheduled jobs as beat entries on the configured cadences — merge into CELERY_BEAT_SCHEDULE instead of hand-writing task paths, which is how a renamed task becomes a job that silently stopped.
- lobby_group — stapel_video.realtime.lobby_group
  The Channels group name for a room's lobby. Call it from any consumer or fan-out of your own so your messages land in the group the shipped LobbyConsumer actually joined — the format is not part of the API, and a hand-written f-string stops matching silently the day it changes.
- month_bounds — stapel_video.presence.month_bounds
  "2026-08" plus an IANA zone to the half-open month as absolute UTC instants. Use it wherever a report names a month in somebody's local calendar — a hand-rolled first-of-the-month is naive about DST, and the hour it misplaces is the hour a customer disputes.
- normalize_scope_key — stapel_video.presence.normalize_scope_key
  The one funnel every writer of ParticipantSpan.scope_key goes through: falsy in, NULL out. Call it before writing a scope from your own ingest path, so "this host partitions nothing" never becomes a tenant whose id is the empty string and whose usage the report happily totals.
- open_span — stapel_video.presence.open_span
  instead of: stapel_video.models.ParticipantSpan.objects.create
  Record a connection's stay idempotently — for a host feeding presence from something other than the shipped webhook path. A hand-written row skips the dedupe key, so a redelivery becomes a second billable stay.
- pairs_export — stapel_video.presence.pairs_export
  The cursor-paged co-presence matrix: one row per pair per room with the raw overlap in seconds. Quadratic in a room's attendees, so rooms are the batch boundary — not arithmetic to re-derive from the span export by hand.
- participants_queryset — stapel_video.services.participants_queryset
  The base roster queryset (participants + their users, unfiltered) the shipped anchor-paginated listing is built on — start from it when mounting a roster view of your own so a page stays one query instead of one per participant. It applies no host or scope check: whoever mounts it owns the access decision.
- period_bounds — stapel_video.presence.period_bounds
  "2026-08" to the half-open UTC calendar month. Use it wherever a report names a month, so a span ending at midnight on the 1st lands in one month and not in both.
- presence_aggregate — stapel_video.presence.presence_aggregate
  Unioned presence seconds for one person or one room over a window, in-process — the same computation video.presence.aggregate exposes on the bus, without the round trip. Summing span durations yourself double-counts anybody on two devices.
- recent_months — stapel_video.presence.recent_months
  The last N month labels in a zone, newest first — the bucket list a month selector renders. Deriving it from timedelta(days=30) drifts a month out of place twice a year.
- spans_export — stapel_video.presence.spans_export
  The cursor-paged raw-span snapshot ({rows, cursor, total}) behind video.presence.spans_export. Keyset paging over (joined_at, id) is what lets a full walk finish exactly once while the sweeper is still closing rows.
- start_egress — stapel_video.services.start_egress
  instead of: livekit.api.LiveKitAPI.egress.start_room_composite_egress, stapel_video.providers.VideoProvider.start_room_egress
  Start recording a room through whichever backend VIDEO_PROVIDER names, writing the file at a storage key the caller owns (typically a stapel-recordings upload session), and hand back the provider egress id to stop it with. Any product that starts recordings on its own schedule calls this: going at the vendor's egress API directly pins the product to one vendor and drops the file where the recordings side is not looking for it.
- usage_rollup — stapel_video.presence.usage_rollup
  One PARTITION's window, one row per person — the answer behind a workspace-administration "who talked how much" screen, and the only presence read that groups by scope_key. Reach for it instead of joining the span table to your own rooms and re-deriving the union: seconds are unioned per person (two devices are one human), rooms counts distinct calls rather than spans, and the arithmetic is the same code the invoice-facing aggregate uses.
- usage_rollup_by_month — stapel_video.presence.usage_rollup_by_month
  usage_rollup cut into calendar months in a caller-named time zone, newest first — the whole table a usage screen draws, in one call. Month boundaries are LOCAL midnight, so a DST month is genuinely an hour short; slicing UTC months yourself puts an hour of March into April for every workspace that is not on UTC.

## Extension points — what a product replaces, fork-free
- SCOPE_PROVIDER [dotted_path]
  Swap how the opaque scope_key (workspace/org/tenant) is resolved from the request and how scope membership is decided — membership is what makes a scope_trusted room auto-admit the caller. The default is a single global scope where every authenticated user is a member.
- USAGE_AUTHORIZER [dotted_path]
  Who may read one partition's usage in a deployment with no workspaces to ask (a callable (request, scope_key) -> bool; staff-only by default). Where workspaces IS present it is not consulted at all — the capability named by USAGE_MANDATE goes to the access registry, so a member of one workspace cannot read another's numbers by typing its id into the URL.
- VIDEO_PROVIDER [dotted_path]
  Swap the video backend (the VideoProvider ABC: mint join token, create room, start/stop recording egress, verify webhook). The default is the LiveKit implementation behind the [livekit] extra; point it at your own backend to change vendor without forking.
- WEBHOOK_HANDLERS [merge_registry]
  Which provider webhook events this deployment reacts to ({event: dotted-path | None to remove}, merged over the built-in egress and participant handlers). Adding a reaction to a room finishing, or replacing how a departure is recorded, is a settings line — not a fork of the webhook ingress, and never a second place where the provider's signature has to be verified.
- serializer_seams [class_override]
  Every view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.
- video.egress_ended [comm_event]
  The recording seam: when a room recording finishes, the module emits this event carrying the storage key — stapel-recordings (or any subscriber) finalizes the upload. This library ships no recording pipeline and imports no recordings model.
- video.participant.joined [comm_event]
  Somebody's connection joined a call. Ids and timestamps only, on the media server's clock — the opening half of a presence span, for a host that wants to react live rather than read the meter later.
- video.participant.left [comm_event]
  Somebody's connection left a call, with the finished interval and how we learned of it (the media server observed it, the person said so, or the sweeper reconciled it). The closing half of a presence span.
- video.presence.aggregate [comm_function]
  How long one person — or one room — was actually in calls over a reporting period. Time is unioned, so a laptop and a phone are one person present, not two.
- video.presence.pairs_export [comm_function]
  Who actually spent time in a call with whom, as one row per pair per room with the raw overlap in seconds. The answer to "how many real conversation partners did this customer have this month", with the "real" threshold left to whoever asks.
- video.presence.spans_export [comm_function]
  The raw presence intervals, cursor-paged as {rows, cursor, total}. The feed an external billing or analytics service reads: full durations, no threshold applied, so the consumer's pricing rules can change without a release here.
- video.presence.usage_rollup [comm_function]
  One tenant's calls, one row per person: unioned minutes, distinct calls attended and distinct connections over any window. The dimension the first three reads did not have — they answer about a person, a room or the whole instance, and a workspace administrator asks about a workspace.
- video.presence.usage_rollup_by_month [comm_function]
  The same answer cut into calendar months in the customer's own time zone, newest first — the table a workspace-administration usage screen renders. Boundaries are local midnight, so a month that crosses a daylight-saving change is genuinely an hour short.

## Fits with — fleet dependencies
- stapel-auth (optional) — every endpoint but the provider webhook requires an authenticated user (IsAuthenticated); stapel-auth is the shelf's session issuer — any stapel-core-compatible JWT issuer satisfies the check
- stapel-core (required) — comm bus (video.egress_ended emit; user.deleted and profile.changed consume), JWT authentication (HTTP and Channels), AppSettings config layer, AnchorPagination
- stapel-profiles (optional) — publishes profile.changed, which video consumes to carry a renamed person's new name onto the connections they already hold — the display name is a claim frozen inside the join token, so without that event a rename reaches a live call only when the person happens to reconnect
- stapel-recordings (optional) — subscribes to video.egress_ended to finalize the recording upload the egress wrote; recording degrades to a no-op emit without a subscriber
- stapel-workspaces (optional) — answers the two authorization questions the per-scope usage read asks: workspaces.check_mandate (is the caller a principal at all) and workspaces.check_capability (does the caller hold STAPEL_VIDEO['USAGE_MANDATE'] in the scope they named). Without workspaces installed or routed, the read falls back to STAPEL_VIDEO['USAGE_AUTHORIZER'] — staff-only by default

## HTTP operations (8) — call by operationId, never by a typed path
Paths are relative to `/video/api/v1/`.
### Video
- POST /rooms — video_api_v1_rooms_create
- POST /rooms/{join_code}/join — video_api_v1_rooms_join_create
- POST /rooms/{join_code}/lobby/admit — video_api_v1_rooms_lobby_admit_create
- POST /rooms/{join_code}/lobby/deny — video_api_v1_rooms_lobby_deny_create
- GET /rooms/{join_code}/participants — video_api_v1_rooms_participants_retrieve
- GET /rooms/{join_code} — video_api_v1_rooms_retrieve
- GET /scopes/{scope_key}/usage/ — video_api_v1_scopes_usage_retrieve
- POST /webhook — video_api_v1_webhook_create

## Error codes (51) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.400.video_invalid_access_level [400] fix_input
- error.400.video_invalid_usage_period [400] fix_input
- error.400.video_invalid_webhook [400] fix_input
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.403.video_join_denied [403] retry
- error.403.video_not_room_host [403] retry
- error.403.video_not_room_participant [403] retry
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.404.video_participant_not_found [404] retry
- error.404.video_room_not_found [404] retry
- error.404.video_scope_not_found [404] retry
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
