Skip to main content

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

OperationIDMethod + PathTagRBACNotes
start-recordingPOST /recordings/startRecordingsStartRecordingwas /livekit/start-recording; collection action, returns recordingId
stop-recordingPOST /recordings/{recordingId}/stopRecordingsStopRecordingwas /livekit/stop-recording
list-recordingsPOST /recordings/listRecordingssearch filters (name / people / date span) + pagination
get-recording-download-linkPOST /recordings/{recordingId}/download-linkRecordingswas /recordings/download-link
update-recording-presencePOST /recordings/presence/updateRecordingscollection action, keyed by space+zone
reconcilePOST /webhook/reconcile-recordingsstays 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 everywhereStopRecording, 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:

  1. StartRecording already returns the egress id — surfaced as recordingId, no new field needed.
  2. StopRecording already takes the egress id for StopEgress/UpdateOnStop — the recordingId path param feeds it directly; roomName/zoneId for the IsRecordingActive check are read from the row. Body empties.
  3. GetDownloadLink switches from the int64 PK → egress-id lookup (egress_id is uniqueIndex, 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 from spaceId+zoneId; A/V-transport infra, not a client concern — dropped for the same reason as the vendor token name); stoppedAt (when stop was clicked, vs endedAt = 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"
}
// 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

  • status is exactly active | finalizing | completed | failed (domain/database/recording.go:7-10): active at start, finalizing on stop, completed/failed at finalize (UpdateOnEgressEnded). Expiry is not a status — it's the expiresAt/storageExpiresAt timestamps plus a 410 at download (download.go:84).
  • durationSeconds is server-computed int(endedAt − startedAt) at finalize (reconcile.go:105); seconds; null until ended.
  • roomName dropped from the wire (verify): start-recording now takes spaceId+zoneId and 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) — today start.go takes roomName from the client, so this inverts the lookup. If the room name is genuinely client-chosen and not reconstructible, start-recording keeps an explicit input — but a neutral one, never the vendor/infra roomName (mirror the conference-token reasoning).

Conventions exemplified (the pattern other resources follow)

  1. Resource identity = an opaque key — wire name recordingId, value backed by the egress id (C2/C6); raw int64 PKs never cross the wire.
  2. Remaining referenced int64s (initiatedBy, ParticipantInfo.id) are stringified at the boundary.
  3. Timestamps: time.Time Go field + format:"date-time" → RFC3339 wire (C3).
  4. Lists: { data, page } (C4).
  5. Every path carries an explicit verb; item ops {id}/{verb}, collection ops /{verb} (C1).
  6. String status fields are explicit wire enums (C6).