Tablecast API

Version 0.0.1 · https://api.tablecast.studio

The third-party surface. Authenticate with Authorization: Bearer <api key> or with an access token from POST /v1/oauth/token. The machine-readable contract is at /v1/openapi.json.

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After. Errors are RFC 7807 problem documents, except at the token endpoint, which answers in RFC 6749 error objects.

GET /v1/docs

Human-readable API reference

The same document as `/v1/openapi.json`, rendered. Server-side and self-contained — no CDN script, no external stylesheet — because a docs page that fetches a bundle from somebody else's origin is a supply chain in front of the thing integrators paste credentials next to.

Public — no credential. Responses: 200 500

Operation id: getApiReference

GET /v1/oauth/mcp/principal

Who this credential is, for the MCP tool gate

`apps/mcp` holds no database, no secret and no credential of its own — that is the property that makes a compromised MCP Lambda leak nothing its callers did not already have. So it cannot decide which tools a caller may be *offered* without asking, and this is the ask: one call per MCP request, returning the organisation and the scopes behind the presented credential.

It is a convenience for the tool list, not the security boundary. Every tool call goes to a `/v1/public/*` route that re-checks the same scopes with `app.apiAuth`, so a client that ignores the list gains nothing.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429

Operation id: getMcpPrincipal

POST /v1/oauth/token

Exchange client credentials for an access token

RFC 6749 §4.4. Send the credentials as HTTP Basic (`Authorization: Basic base64(client_id:client_secret)`) or in the form body. The token is a one-hour HS256 JWT carrying the org and the granted scopes, verified with no database read — which is the point of this grant, and the reason revoking a client does not revoke tokens already issued to it.

Errors are RFC 6749 error objects, not RFC 7807 problem documents: every OAuth client library in the world is written against the former.

Public — no credential. Responses: 200 400 401 503

Operation id: issueOAuthToken

GET /v1/openapi.json

This API's OpenAPI 3.1 document

The published contract. Generated from the Zod schemas at build time, from a full app — every route group, whichever Lambda answers — so a client generated from it is complete. `packages/api-client` and the MCP server are both generated from this document. The third-party surface is the set of operations carrying the `apiKeyAuth` scheme.

Public — no credential. Responses: 200 500

Operation id: getPublicOpenApiDocument

GET /v1/public/assets

List the media library

Every file the organisation holds — bumpers, stings, logos, imported designs, and the outputs renders were registered as. Newest first.

An asset sits at one of three levels, and the filters follow: `productionId` for one show's own library, `teamId` for a team's, neither for the organisation-wide shelf **and everything below it**. Narrow further with `kind` and search names with `q`.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 422 429 500 503

Operation id: listPublicAssets

GET /v1/public/assets/{assetId}

Read one library file

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: getPublicAsset

GET /v1/public/assets/{assetId}/download

Get a signed download URL for one library file

A presigned GET on the organisation's own bucket, valid for fifteen minutes, with a filename derived from the asset's name **signed into it** so it cannot be rewritten.

Every call writes an `assets.download` row to `audit_events` whose actor is the credential — `api_key` and the key's id, not whoever created the key, who was asleep. The URL itself is never logged.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 422 429 500 503

Operation id: downloadPublicAsset

GET /v1/public/comment-threads

Read the review threads on one thing

Every thread on one target, newest first, each with its whole conversation inline — so a review report and the markers on a scrub bar come from one call.

`targetKind` is `recording`, `render`, `media_asset`, `graphic_asset` or `edit_doc`. A recording's id is its **session** id. Resolved threads are omitted unless `includeResolved` is true: a review list is a list of things still to do. Deleted comments come back as tombstones — `deletedAt` set and `body` empty — rather than being dropped, because a conversation with a hole in it reads as a different conversation.

**Read-only.** A comment's author is one of three kinds, each of which names a person: a member, a guest in the room, or somebody holding a share link. A credential is none of them, and the database's own check constraint says so. Posting as a credential needs a fourth author kind and the decision that goes with it, which is a deliberate change rather than a side effect of this one.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 422 429 500 503

Operation id: listPublicCommentThreads

GET /v1/public/episodes/{sessionId}

Read one episode

The same row `GET /v1/public/sessions/{sessionId}` returns, framed as an episode: the show resolved inline, plus the two counts. Both spellings are supported and neither is deprecated — a scheduling integration thinks in sessions, a catalogue thinks in episodes.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: getPublicEpisode

DELETE /v1/public/event-subscriptions/{subscriptionId}

Stop watching

Closes the subscription. Idempotent: closing one that is already closed answers 200 with the original `revokedAt` rather than 404, because a client retrying an unsubscribe it is not sure landed should not have to tell those two cases apart.

`read` scope, not `control`: this stops the caller's own watch and is invisible to everybody else. A read-only integration that could start a watch and not stop it would be the worse design.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: deletePublicSessionEventSubscription

POST /v1/public/event-subscriptions/{subscriptionId}/poll

Take everything that has happened since the last time you asked

The cursor advances **server-side**, so an agent never has to carry one — which is also why this is a `POST` on a `read` scope rather than a `GET`: it changes something, and a verb that says otherwise invites a retry that would drop a batch. Two concurrent polls of one subscription split the batch between them rather than each getting all of it: **one poller per subscription**.

`wait` long-polls, up to 10000 ms.

When the subscription has expired or been revoked, this answers **once** with `final` set to a sentence saying which and what to do — and the `cursor` in that same answer is still good, so subscribing again with it as `since` resumes without a gap. After that the subscription is gone.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: pollPublicSessionEventSubscription

GET /v1/public/organization

The organisation this credential belongs to

There is no `orgId` on any endpoint here: a credential belongs to exactly one organisation and cannot ask about another, so the tenant is read from the credential rather than claimed by the caller.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: getPublicOrganization

GET /v1/public/productions

List shows

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicProductions

GET /v1/public/productions/{productionId}

Read one show

The show itself. Its episodes are `GET /v1/public/productions/{id}/episodes`.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: getPublicProduction

GET /v1/public/productions/{productionId}/episodes

List a show's episodes

A production **is** the series and a session is one episode of it — Tablecast has no separate `series` row, which is why each episode carries its show inline rather than a `seriesId` pointing at a table that does not exist.

Newest first, and paged on the same `(createdAt, id)` keyset as every other list here. `episodeNo` is the show's own numbering and is **not** the sort order: it is nullable, and a keyset on a nullable column loses rows at the page boundary.

Each entry carries `trackCount` (programme tracks; comms legs are never counted) and `hasTranscript`, so listing a season and deciding what to fetch is one call.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicEpisodes

GET /v1/public/productions/{productionId}/graphics

A show's Supers library

One card per person, newest first. `assetId` points at the transparent PNG in the media library, which is where the bytes are fetched from (`GET /v1/public/assets/{id}/download`).

**Regenerated-over versions are hidden by default.** A regeneration is a new row rather than an overwrite — scenes hold asset ids, and rewriting bytes under a live show would change a take with nothing in the UI having moved — so a show whose straps have been recoloured four times would otherwise render five of everybody. Pass `history=true` for the whole chain.

A `pending` super is a Canva autofill that has not come back yet; a `failed` one carries the reason in `error`.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicGraphics

GET /v1/public/productions/{productionId}/invite-recipients

A show's roster

The people who travel with this show. A production-scoped invitation covers every episode and the planning room, which is what makes a recurring co-host one invitation rather than twelve.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicProductionInviteRecipients

POST /v1/public/productions/{productionId}/invite-recipients

Add people to a show's roster

The same act, scoped to the show rather than to one episode. Their link resolves to whichever episode is open when they tap it: one that is live or in its lobby, else the soonest scheduled one, else the one that was due an hour ago and is running late. The invitation names the show rather than a date, because a roster entry has no one time.

Credential: apiKeyAuth or oauthToken. Responses: 201 401 402 403 404 409 422 429 500 503

Operation id: addPublicProductionInviteRecipients

GET /v1/public/recordings

List recordings

A recording is a session that has tracks. Archived recordings are included and carry `archivedAt`; soft-deleted ones are not returned at all.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicRecordings

GET /v1/public/recordings/{sessionId}

Read one recording: session, participants, tracks and transcripts

Comms tracks (`kind = comms`) are excluded by construction — they are talkback, not programme, and `packages/db` states that exclusion as a property of the schema rather than as a filter each caller remembers.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: getPublicRecording

POST /v1/public/recordings/{sessionId}/playback-ticket

Presign a batch of CMAF segments for playback

Up to 200 presigned segment GETs from the rendition's manifest starting at `fromIndex`, plus the presigned init segment, all valid for an hour. `nextIndex` is what to ask for next, or null when the batch reached the end. The URLs are signed through the organisation's own storage target, so an R2 org and an S3 org get the same shape and the player never learns which it is.

A **`POST` that reads**: it mints credentials rather than returning a cacheable representation, which is why it is not a `GET`, and it needs only the `read` scope, because playing a recording is reading it. A rendition that is not `ready` is `conflict` — a ticket over a manifest that does not exist yet is a player that 403s on every segment and blames the network.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 409 422 429 500 503

Operation id: issuePublicPlaybackTicket

POST /v1/public/recordings/{sessionId}/rendition-assets

Presign a rendition's waveform or thumbnail sprite

A one-hour presigned GET for one of the three artefacts a rendition carries: `peaks` (the merged waveform JSON), `sprites` (the sprite index, which names the sheets) or `sprite-sheet` (one sheet image, naming its `sheetIndex`).

The request names which asset, **never a storage key** — the server resolves the object from the rendition row. A rendition on another session, or one with no such asset yet, is `not-found`: a caller probing ids learns nothing either way.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 422 429 500 503

Operation id: issuePublicRenditionAssetUrl

GET /v1/public/recordings/{sessionId}/renditions

List a recording's CMAF proxy ladder

Both rungs of every ISO and of the programme master, with each row's status. A rendition is playable once it is `ready`; before that `segmentCount` is 0 and a playback ticket is refused with `conflict` rather than handed out over an empty manifest.

Not paginated: a ladder is bounded by the recording, so a cursor would be ceremony over a list that cannot grow without bound.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicRenditions

GET /v1/public/recordings/{sessionId}/transcripts

A recording's transcripts, with word timings behind a signed URL

One entry per programme track: the sentence-level segments inline, and a fifteen-minute presigned URL for the word-level JSON the caller fetches directly. `vttUrl` is the same for the WebVTT sidecar, where one has been written.

The **speaker comes from the track**, never from diarization: Tablecast records one ISO per person, so who is talking is known exactly rather than guessed at. Comms legs are excluded by construction — a producer's talkback is not programme and must never reach a transcript.

Filter with `trackId` or `language`; every language a recording has been transcribed into is returned when neither is given.

Segment times are **session-clock ms** (`timeBase: "session"`) — the one timeline that means anything across tracks, so a quote from a camera that joined late lines up with one from a camera that was there from the start.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 422 429 500 503

Operation id: listPublicTranscripts

GET /v1/public/render-presets

The export presets this deployment offers

A fixed catalogue in code, so it is not paginated and it does not read the database. `id` is what `POST /v1/public/sessions/{id}/renders` takes as `presetId`.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicRenderPresets

GET /v1/public/renders

List the export queue

Every render the organisation has asked for, newest first — queued, running and finished. Narrow to one recording with `sessionId`, or to one state with `status`. `progressPct` is the durable copy of the worker's progress, so a client that missed every realtime event still shows a bar.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicRenders

GET /v1/public/renders/{renderId}

Read one export

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: getPublicRender

POST /v1/public/renders/{renderId}/cancel

Cancel a queued or running export

Records the request. A render that has not started is cancelled outright; one that is already running stops within the worker's poll interval and writes `canceled` itself — so `stoppedImmediately` tells the two apart rather than leaving a caller to infer it. Idempotent: cancelling twice is not an error.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 402 403 404 409 422 429 500 503

Operation id: cancelPublicRender

GET /v1/public/renders/{renderId}/download

Get a signed download URL for a finished export

The same contract as the asset download, for a `succeeded` render — the programme master, a re-switch, a clip or the caption sidecar. A render that has not succeeded yet is `not-found`: there is no object to sign, and saying so by status would invite polling this endpoint instead of the queue.

Writes a `recordings.download` audit row naming the credential.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 422 429 500 503

Operation id: downloadPublicRender

POST /v1/public/renders/{renderId}/retry

Retry a failed or cancelled export

Puts the **same** row back to `queued` — same id, same preset, same key — so the worker finds the steps its previous attempt completed and skips them. Only a failed or cancelled render can be retried; anything else is `conflict`.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 402 403 404 409 422 429 500 503

Operation id: retryPublicRender

GET /v1/public/sessions

List sessions

Newest first. Narrow with `productionId` and `status`.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicSessions

POST /v1/public/sessions

Schedule a session

The same use case the web app calls, with the same gates: the org must not be read-only (dunning), and the credential must hold `session:schedule`. Needs the `control` scope and the `api_full` entitlement (Business).

Credential: apiKeyAuth or oauthToken. Responses: 201 401 402 403 404 422 429 500 503

Operation id: createPublicSession

GET /v1/public/sessions/{sessionId}

Read a session

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: getPublicSession

GET /v1/public/sessions/{sessionId}/chat

A session's open chat log

The room's **`all`** channel, newest first — the same keyset order as every other list here, which is the reverse of what a chat window renders, so reverse the page client-side.

The `producers` cue channel and private threads between two people are not on this surface and cannot be asked for. On the first-party API they are gated on the caller's *seat in the room* — a guest asking for `producers` by name gets a 403 and no row count — and a credential has no seat, so there is no honest answer to give it but the open channel.

**Read-only**, for the same reason: posting resolves the author to a `participants` row, and fabricating one would put a robot in the grid of a live show.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicChat

GET /v1/public/sessions/{sessionId}/companion-state

Tally and state for a Companion surface

What a Stream Deck's keys are lit from (TAB-234): program and preview scene, per-scene tally, graphic visibility, per-source mute, who talkback is keyed to, the session clock and whether the program feed is live.

Read rather than pushed, because a Companion sits behind the customer's firewall and reaches us. The module refreshes on connect, on reconnect and on a timer, and diffs locally so only changed keys are redrawn. The clock is quantised to whole seconds in the derivation, so polling faster than 1 Hz produces no extra key updates.

Needs the same session pairing the control endpoint does.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 503

Operation id: getCompanionState

POST /v1/public/sessions/{sessionId}/control

Run a batch of Companion ops against a live session

The endpoint a Bitfocus Companion module (or its Generic HTTP module) calls on a key press. Every op becomes an ordinary production-state op tagged `source: companion`, so a take from a Stream Deck and a take from the director's mouse are the same row in the EDL and replay identically in the editor.

Ops are executed where the thing they change lives: the switcher, the mixer, talkback and markers go to the session coordinator; `record.*` writes a `record_commands` row; `live.*` assembles stream URLs server-side. One request, one result per op, in order.

**A batch runs to the end.** A key that is three ops must not lose its take because the graphic next to it was deleted last week, so a refused op is reported rather than aborting the request. Nothing is rolled back — each op is independently atomic.

The credential must be a Companion pairing for this session (or for its production). An ordinary API key with the `control` scope is **not** enough: a pairing names the participant the surface acts as, which is what the EDL records and what the comms plane checks a role against.

Credential: apiKeyAuth or oauthToken. Responses: 200 207 401 402 403 404 409 422 429 500 503

Operation id: sendCompanionControl

POST /v1/public/sessions/{sessionId}/event-subscriptions

Start watching a session

Mints a handle that remembers your place in the feed, so polling it needs nothing but its id and a reconnect loses nothing.

`read` scope, and no scope is widened by watching: everything the feed carries is already readable through the endpoints beside this one.

**It expires.** A subscription is a row with a lifetime — 3600 s by default — and the poll route says how long is left on every answer, then delivers one final batch naming the expiry rather than going quiet. Watching a whole show means either asking for a longer `ttlSeconds` up front or subscribing again with the last `cursor`, which costs one call and skips nothing.

`fromNow: true` starts at the present moment. The default starts at the beginning of the session, which is what you want when you are asked to catch up on a show already running.

Credential: apiKeyAuth or oauthToken. Responses: 201 401 403 404 422 429 500 503

Operation id: createPublicSessionEventSubscription

GET /v1/public/sessions/{sessionId}/events

What has happened in a session, since a cursor

A merged, ordered feed of the durable rows a live show writes: the switching EDL (`production.state`), record commands (`session.record`), joins and leaves (`session.participant`), the room's open chat (`session.chat`) and the coordinator's own lifecycle (`session.coordinator`).

**Oldest first**, which is the opposite of every other list on this surface and is the point: this is a log being followed forwards, not a page being browsed backwards.

Pass the `cursor` from the previous answer as `since` and you get exactly what has happened since — nothing repeated, nothing skipped. Omit it to start from the beginning of the session.

`wait` turns the call into a long poll: the server holds the request open until an event arrives or the budget runs out, so a watcher sees a director's cut within about a second without hammering the endpoint. It is clamped to 10000 ms by the Lambda's own timeout.

This is **not** a socket, and the difference is in your favour: a dropped connection costs you nothing, because the cursor is a position in a table rather than a place in a stream that was still running while you were away.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 422 429 500 503

Operation id: listPublicSessionEvents

GET /v1/public/sessions/{sessionId}/invite-recipients

The guest funnel for one episode

Everybody named on this episode and how far through they have got: `invited` → `opened` → `checked_in` → `in_lobby` → `ready` → `on_air`, or one of the terminal states. `lastDeliveryError` says why the last invitation did not go, for the failures that happen before there is a message id to look up.

**Never their token.** Only its sha256 is stored, and the link exists exactly once — in the response that mints it.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicSessionInviteRecipients

POST /v1/public/sessions/{sessionId}/invite-recipients

Invite people to an episode

One link and one branded email per person. Each link admits exactly the address it was sent to, in the role given, and `joinUrl` is returned **once** — only the token's sha256 is stored, so a lost link is re-sent rather than looked up.

A send that fails does not fail the call: the people exist and their links are in the response, and the reason lands on the row as `lastDeliveryError`. An address already on this episode is rotated rather than duplicated, and gets the re-send copy — their previous link has just been retired.

Needs `control` and `session:schedule`, the same pair scheduling the episode needs. The session must still be joinable: a `complete`, `canceled` or `failed` one is `conflict`.

Credential: apiKeyAuth or oauthToken. Responses: 201 401 402 403 404 409 422 429 500 503

Operation id: addPublicSessionInviteRecipients

POST /v1/public/sessions/{sessionId}/invites

Issue a join link for a session

Returns the token **once**; only its sha256 is stored, so a lost link is revoked and re-created rather than looked up. The link admits whoever holds it, in the role given, until it expires or its uses run out.

Credential: apiKeyAuth or oauthToken. Responses: 201 401 402 403 404 409 422 429 500 503

Operation id: createPublicSessionInvite

GET /v1/public/sessions/{sessionId}/participants

List a session's participants

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicParticipants

POST /v1/public/sessions/{sessionId}/record

Arm, start, stop or mark a recording

Persists a `record_commands` row tagged `source: api` and stamped with the issuing credential, which is what devices in the room act on. Acknowledgements arrive asynchronously from each device and appear in `acks`; the response is the accepted command, not a report that every camera has rolled.

`start` additionally checks the org's recording-minute cap, exactly as the first-party session start does — an API caller must not be able to route around a hard cap.

Credential: apiKeyAuth or oauthToken. Responses: 202 401 402 403 404 409 422 429 500 503

Operation id: sendPublicRecordCommand

POST /v1/public/sessions/{sessionId}/renders

Queue an export of a recording

Opens a `renders` row in `queued` and hands it to a worker. Needs the `control` scope, the `editor:export` permission and the `editor` entitlement; the organisation must not be read-only, and may have ten renders outstanding at once.

`mode` is not on the body. An API caller always gets a **server** render: `browser` mode reserves a row that the calling page then fills in with bytes it encoded itself, and there is no page here.

202-shaped work behind a 201: the row exists when this returns, the file does not. Poll `GET /v1/public/renders/{id}` or list the queue.

Credential: apiKeyAuth or oauthToken. Responses: 201 401 402 403 404 409 422 429 500 503

Operation id: createPublicRender

POST /v1/public/sessions/{sessionId}/tally-ticket

Open the pushed tally feed instead of polling

The replacement for polling `companion-state` (TAB-445). Answers a `wss://` URL and a short-lived ticket; the module opens it and receives a full snapshot on connect and a **diff whenever something changes** — and nothing at all while nothing does.

The split exists because the two halves of the check live in different processes. Only the API can authenticate a pairing (it needs the database, and the coordinator has none), and only the coordinator holds the production document. So the pairing is checked here, exactly as it is for a key press, and the ticket that comes out of it is what the coordinator can verify on its own — an HMAC over `(session, org, pairing, expiry)` with the service token the two already share. It names one session, it lasts five minutes, and the only thing that accepts it is a read-only feed.

Ask for a new one on every dial rather than keeping it. That is also what makes revocation work: a revoked pairing's next reconnect fails *here*, where pairings are checked.

**503 when this deployment has no public tally endpoint configured** — the module then keeps polling, which is slower and entirely correct. The pushed feed is an optimisation over a path that already works.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 503

Operation id: createCompanionTallyTicket

GET /v1/public/shares

Every share link on one thing

Newest first, **revoked ones included** — 'this was public for a fortnight in March' is a fact somebody will need back. A live public share carries its `url`; a private one carries only `recipientCount`, because its links are per-person tokens whose plaintext exists exactly once, in the response that minted them.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 404 429 500 503

Operation id: listPublicShares

POST /v1/public/shares

Publish a recording, render, file or edit as a public link

Needs the **`admin`** scope and `recordings:manage`, not `control`. Every other write on this surface runs the show for the organisation; this one takes its media *outside* the organisation, to whoever holds a URL, and that is the same class of act as deleting a recording — not undoable by the person it happened to.

**Public is a toggle, not a mint.** If a live public share already covers this target, that one comes back with `created: false` rather than a second URL nobody could tell apart from the first. The `url` is returned on every read, deliberately: a public token is not a secret about a person, and a link that could be shown only once would turn 'copy link' into 'rotate link'.

Private shares — one token per named person, each emailed — are a first-party act: the delivery report is the point of that dialog, and a credential fanning branded mail out to addresses it named is a different thing from publishing a link.

Credential: apiKeyAuth or oauthToken. Responses: 200 201 401 402 403 404 409 422 429 500 503

Operation id: createPublicShare

DELETE /v1/public/shares/{shareId}

Revoke a share link

Stamps `revokedAt`; the row stays, so the history of who could see what does not disappear with the link, and `url` reads null from then on. Takes effect on the viewer's **next** request — there is no cached credential to outlive it, which is exactly why a share token is a row rather than a signed JWT — and a comment already left through it is not withdrawn.

Every named recipient of a private share is revoked with it. Idempotent: revoking twice is one revocation, and the **first** `revokedAt` is the honest one — the second call withdrew nothing.

Credential: apiKeyAuth or oauthToken. Responses: 200 401 402 403 404 409 422 429 500 503

Operation id: revokePublicShare

GET /v1/public/teams

List teams

Credential: apiKeyAuth or oauthToken. Responses: 200 401 403 429 500 503

Operation id: listPublicTeams