Resource: Recordings (reference)
Tag: Recordings · Module: recording
The reference resource — it exemplifies the Part 0 conventions every other
resource follows: opaque/stringified ids (C2), RFC3339 timestamps (C3), the {data, page} list envelope
(C4), wire enums (C6), explicit verb paths (C1/C1a).
Operations
| OperationID | Method + Path | Tag | RBAC | Notes |
|---|---|---|---|---|
start-recording | POST /recordings/start | Recordings | StartRecording | was /livekit/start-recording; collection action, returns recordingId |
stop-recording | POST /recordings/{recordingId}/stop | Recordings | StopRecording | was /livekit/stop-recording |
list-recordings | POST /recordings/list | Recordings | — | search filters (name / people / date span) + pagination |
get-recording-download-link | POST /recordings/{recordingId}/download-link | Recordings | — | was /recordings/download-link |
update-recording-presence | POST /recordings/presence/update | Recordings | — | collection action, keyed by space+zone |
POST /webhook/reconcile-recordings | — | — | stays plain gin (N5) — not in typed contract |
Consolidation (N2): all five typed ops now live under /recordings and in the
recording module. The /livekit/* start/stop paths are retired. Every path carries an explicit
verb (C1) — start / stop / list / download-link (artifact) / presence/update.
Identifier — recordingId (opaque, backed by the egress id)
Recordings have Id int64 as PK, no UUID (domain/database/recording.go:15; the RoomId UUID is
the FK to the space, not the recording's own id). But there's a unique, not-null, opaque EgressId
(:16) that the codebase already keys on everywhere — StopRecording, reconcile/
UpdateOnEgressEnded, and the active-check all use it; only download uses the int64 PK.
Per C2 the wire id is opaque and non-enumerable (never the raw int64 PK). Per C6 its name is the neutral
recordingId, not egressId — "egress" is LiveKit's term and the wire shouldn't carry it. The value
is the egress id under the hood, so this is name-only on the wire, and the backend can later swap the backing
key (e.g. to a real UUID) with no contract break. Mechanics:
StartRecordingalready returns the egress id — surfaced asrecordingId, no new field needed.StopRecordingalready takes the egress id forStopEgress/UpdateOnStop— therecordingIdpath param feeds it directly;roomName/zoneIdfor theIsRecordingActivecheck are read from the row. Body empties.GetDownloadLinkswitches from the int64 PK → egress-id lookup (egress_idisuniqueIndex, trivial). The only real backend delta.
DTOs (conventions applied)
start-recording
// Request body
{
"spaceId": "string", // required — the space whose conference is recorded (backend derives the internal LiveKit room name)
"zoneId": "string", // required — the zone within the space
"conferenceTopic": "string", // required
"participantIds": ["string"], // required
"participants": "string" // required — display string for the recording renderer
// (start.go:279, NOT redundant with participantIds)
}
// Response
{
"recordingId": "string", // the resource identifier (opaque, C2; backed by the egress id, C6)
"startedAt": "2026-06-13T10:00:00Z" // C3 — RFC3339 (was `timestamp` int64)
}
stop-recording ({recordingId} in path)
// Request body — empty (recordingId in path; roomName/zoneId read from the row)
{}
// Response
{
"recordingId": "string",
"endedAt": "2026-06-13T10:42:00Z" // C3 — RFC3339
}
list-recordings
// Request body — all filters optional (C8), AND-combined
{
"query?": "string", // substring match on conferenceTopic (the recording title)
"participantUserIds?": ["string"], // recordings that include ANY of these users
"startedAfter?": "2026-06-01T00:00:00Z", // startedAt lower bound (RFC3339)
"startedBefore?": "2026-06-14T23:59:59Z", // startedAt upper bound — pair = a date span
"limit": 20, // 1..100, default 20
"offset": 0
}
// Response — generic envelope (C4)
{
"data": [ /* RecordingListItem */ ],
"page": { "limit": 20, "offset": 0, "total": 137 }
}
Filter semantics: query is a case-insensitive substring on conferenceTopic; participantUserIds matches
recordings including any of the given users (join on recording_participants); startedAfter/
startedBefore bound startedAt (either alone = open-ended, both = a closed span). Results are ordered by
startedAt descending (most recent first). Backend delta: ListRecordings + the repository gain these
params (topic ILIKE, participant join, started_at range).
RecordingListItem
{
"recordingId": "string", // identifier (opaque, backed by egress id; no int64 `id` on the wire)
"spaceId": "string", // space UUID
"conferenceTopic": "string",
"zoneId": "string",
"initiatedBy": "string | null", // C2 — user int64 stringified (was *int64)
"durationSeconds": "number | null", // server-computed int(endedAt-startedAt) at finalize; null until ended
"status": "active | finalizing | completed | failed", // C6 — wire enum (see Notes)
"startedAt": "2026-06-13T10:00:00Z", // C3
"endedAt": "string | null", // C3 — set at finalize
"expiresAt": "string | null", // C3 — download-access expiry (drives 410)
"participants": [ /* ParticipantInfo */ ]
}
Internal-only, not exposed:
roomName(the LiveKit room identifier — backend-derived fromspaceId+zoneId; A/V-transport infra, not a client concern — dropped for the same reason as the vendor token name);stoppedAt(when stop was clicked, vsendedAt= egress actually finished);storageExpiresAt(R2 file-deletion time). Add later only if the FE needs them.
ParticipantInfo
{
"id": "string", // C2 — was int64; FE already used z.string(), now correct-by-construction
"email": "string",
"avatarUrl": "string",
"displayName": "string"
}
get-recording-download-link ({recordingId} in path)
// Request body — empty (recordingId in path)
{}
// Response
{
"url": "string",
"expiresAt": "2026-06-13T11:00:00Z" // C3 (was time.Time — already RFC3339, TODO removed)
}
update-recording-presence
// Request body
{ "spaceId": "string", "zoneId": "string" } // both required
// Response — 204 No Content (or { "ok": true } if Huma needs a body type)
Notes
statusis exactlyactive | finalizing | completed | failed(domain/database/recording.go:7-10):activeat start,finalizingon stop,completed/failedat finalize (UpdateOnEgressEnded). Expiry is not a status — it's theexpiresAt/storageExpiresAttimestamps plus a410at download (download.go:84).durationSecondsis server-computedint(endedAt − startedAt)at finalize (reconcile.go:105); seconds; null until ended.roomNamedropped from the wire (verify):start-recordingnow takesspaceId+zoneIdand the backend derives the LiveKit room name internally (it already stores it on the row). Confirm the LiveKit room name is deterministically reconstructible server-side from(spaceId, zoneId)— todaystart.gotakesroomNamefrom the client, so this inverts the lookup. If the room name is genuinely client-chosen and not reconstructible,start-recordingkeeps an explicit input — but a neutral one, never the vendor/infraroomName(mirror theconference-tokenreasoning).
Conventions exemplified (the pattern other resources follow)
- Resource identity = an opaque key — wire name
recordingId, value backed by the egress id (C2/C6); raw int64 PKs never cross the wire. - Remaining referenced int64s (
initiatedBy,ParticipantInfo.id) are stringified at the boundary. - Timestamps:
time.TimeGo field +format:"date-time"→ RFC3339 wire (C3). - Lists:
{ data, page }(C4). - Every path carries an explicit verb; item ops
{id}/{verb}, collection ops/{verb}(C1). - String status fields are explicit wire enums (C6).