/live — Session Management

Endpoints for creating, querying, updating, and deleting caption relay sessions.


POST /live — Register Session

Create a new caption relay session. Returns a JWT token for all subsequent requests in this session. Registration is idempotent: if a session with the same sessionId (derived from apiKey + streamKey + domain) already exists, the existing session is returned.

Authentication: None (uses apiKey in the request body)

Request

POST /live
Content-Type: application/json

Target-array mode (recommended) — pass all YouTube stream keys and optional generic webhook targets inside the targets array and omit streamKey:

{
  "apiKey": "your-api-key",
  "domain": "https://your-app.example.com",
  "targets": [
    { "id": "yt-main",    "type": "youtube", "streamKey": "xxxx-xxxx-xxxx-xxxx" },
    { "id": "yt-backup",  "type": "youtube", "streamKey": "yyyy-yyyy-yyyy-yyyy" },
    {
      "id": "webhook-1",
      "type": "generic",
      "url": "https://webhook.example.com/captions",
      "headers": { "Authorization": "Bearer my-webhook-secret" }
    }
  ]
}

Legacy single-target mode — pass a single stream key as streamKey. Additional targets can still be supplied via the targets array.

{
  "apiKey": "your-api-key",
  "streamKey": "xxxx-xxxx-xxxx-xxxx",
  "domain": "https://your-app.example.com"
}
FieldTypeRequiredDescription
apiKeystringYesAPI key issued by the server admin
streamKeystringNoYouTube Live stream key. Omit in target-array mode.
domainstringYesRegistered origin domain (used for CORS and session isolation)
sequencenumberNoOverride the starting sequence number. When omitted, the server uses the persisted per-API-key sequence (see Per-key Sequence Persistence).
targetsarrayNoArray of caption delivery targets. See Caption Targets below.

Caption Targets

Each entry in the targets array describes one delivery destination. Two target types are supported.

YouTube target — delivers captions via the YouTube Live HTTP caption ingestion API:

FieldTypeRequiredDescription
idstringYesClient-assigned identifier (used for logging and updates)
typestringYesMust be "youtube"
streamKeystringYesYouTube Live stream key for this target

Generic (webhook) target — POSTs caption data as JSON to an arbitrary HTTP endpoint:

FieldTypeRequiredDescription
idstringYesClient-assigned identifier
typestringYesMust be "generic"
urlstringYesDestination URL (must use http or https)
headersobjectNoExtra HTTP headers to include in the webhook request (e.g. Authorization)

Generic targets receive a JSON body structured as follows (see also POST /captions):

{
  "source": "https://your-app.example.com",
  "sequence": 7,
  "captions": [
    {
      "text": "Hello, world!",
      "composedText": "Hello, world! <br>Hei maailma!",
      "timestamp": "2024-01-01T12:00:02.000",
      "translations": { "fi-FI": "Hei maailma!" },
      "captionLang": "fi-FI",
      "showOriginal": true
    }
  ]
}

Viewer target — broadcasts captions to audience members via the public GET /viewer/:key SSE endpoint:

FieldTypeRequiredDescription
idstringYesClient-assigned identifier
typestringYesMust be "viewer"
viewerKeystringYesShort URL-safe key (letters, digits, hyphens, underscores; min 3 characters). Viewers subscribe at GET /viewer/:viewerKey.

Response — 200 OK

{
  "token": "<JWT>",
  "sessionId": "a1b2c3...",
  "sequence": 42,
  "syncOffset": 0,
  "startedAt": "2024-01-01T12:00:00.000"
}
FieldTypeDescription
tokenstringJWT for authenticating subsequent requests
sessionIdstringSHA-256 of apiKey:streamKey:domain
sequencenumberCurrent sequence counter
syncOffsetnumberNTP-style clock offset in milliseconds
startedAtstringSession start time (ISO string)

Error responses

StatusReason
400Missing or invalid fields
401Invalid or expired API key
429Daily or lifetime usage limit exceeded

GET /live — Session Status

Return the current sequence number and clock offset for the authenticated session.

Authentication: Bearer JWT

Request

GET /live
Authorization: Bearer <token>

Response — 200 OK

{
  "sequence": 7,
  "syncOffset": 150
}
FieldTypeDescription
sequencenumberCurrent sequence counter
syncOffsetnumberClock offset in milliseconds

PATCH /live — Update Session

Update mutable session fields. Supports advancing the sequence counter and replacing the active caption targets at runtime.

Authentication: Bearer JWT

Request

PATCH /live
Authorization: Bearer <token>
Content-Type: application/json
{
  "sequence": 10,
  "targets": [
    { "id": "yt-main", "type": "youtube", "streamKey": "xxxx-xxxx-xxxx-xxxx" }
  ]
}
FieldTypeRequiredDescription
sequencenumberNoNew sequence counter value. Setting 0 explicitly resets the persisted per-key sequence.
targetsarrayNoReplace all active caption targets. Uses the same format as POST /live. Old YouTube senders are stopped before the new ones start.

Response — 200 OK

{
  "sequence": 10,
  "targetsCount": 1
}
FieldTypeDescription
sequencenumberCurrent sequence counter
targetsCountnumberNumber of active targets after the update

DELETE /live — End Session

Tear down the session. The YouTube sender is stopped, final session statistics are written to the database, and the JWT is invalidated.

Authentication: Bearer JWT

Request

DELETE /live
Authorization: Bearer <token>

Response — 200 OK

{
  "removed": true,
  "sessionId": "a1b2c3..."
}

Side effects:

  • Closes the YoutubeLiveCaptionSender for this session (primary sender and all extra YouTube targets)
  • Writes a session_stats record to the database
  • Emits a session_closed event to any connected SSE clients

Session Lifecycle

POST /live  →  JWT token issued

GET/PATCH /live  (optional — inspect or update state)

POST /sync  (recommended after registration)

POST /captions  (send captions — see captions.md)
GET /events     (receive results — see captions.md)

DELETE /live  →  session closed, stats recorded

Sessions expire automatically after SESSION_TTL milliseconds of inactivity (default 2 hours). Expiry emits a session_closed SSE event.


Per-API-key Sequence Persistence

The caption sequence counter is persisted per API key across sessions. When a new session is created via POST /live, the server initialises the sequence from the stored value for that API key rather than always starting from 0.

2-hour inactivity TTL: If no caption has been sent in more than 2 hours, the sequence is automatically reset to 0 on the next session start. This matches YouTube’s own caption sequence reset window.

Explicit override: Pass "sequence": N in the POST /live body to override the persisted value (useful for testing or forced resets).

Explicit reset: PATCH /live with { "sequence": 0 } clears the persisted per-key sequence and records a NULL last-caption timestamp, so the next session will also start from 0.

Automatic update: Every successful caption delivery via POST /captions updates the persisted per-key sequence atomically.


Session Persistence Across Server Restarts

Sessions are stored in the sessions SQLite table and rehydrated automatically when the server starts. On restart:

  1. All previously active sessions are restored into memory (without active YouTube senders — senders are not serialisable).
  2. Sequence counters, clock offsets, and metadata are preserved.
  3. When a client calls POST /live for a rehydrated session, the server issues a fresh JWT and attaches a new YoutubeLiveCaptionSender. Captions can then be sent normally without any client-side change.

This means clients that reconnect after a server restart do not lose caption history or sequence continuity — they simply need to call POST /live again to obtain a new token.

/sync — Clock Sync

NTP-style clock synchronisation. The server sends a caption to YouTube and uses the response timestamp to compute a clock offset. This offset is stored in the session and applied to subsequent caption timestamps.


POST /sync — Clock Sync

Authentication: Bearer JWT

Request

POST /sync
Authorization: Bearer <token>

No body required.

Response — 200 OK

{
  "syncOffset": 150,
  "roundTripTime": 82,
  "serverTimestamp": "2024-01-01T12:00:00.082",
  "statusCode": 200
}
FieldTypeDescription
syncOffsetnumberComputed clock offset in milliseconds. Positive means the server is ahead of the client.
roundTripTimenumberRound-trip latency to YouTube in milliseconds
serverTimestampstringTimestamp returned by YouTube
statusCodenumberHTTP status from YouTube

Side effects: Updates syncOffset in the session store.

/captions — Send Captions

Queue one or more captions for delivery to YouTube. Returns 202 Accepted immediately; the actual YouTube delivery result arrives on the SSE event stream (GET /events).


POST /captions — Send Captions

Queue one or more captions for delivery to YouTube. Returns 202 Accepted immediately; the actual YouTube delivery result arrives on the SSE event stream (GET /events).

Authentication: Bearer JWT

Captions are serialised per session (using an internal send queue) to keep sequence numbers monotonic, even if multiple POST /captions requests arrive concurrently.

Request

POST /captions
Authorization: Bearer <token>
Content-Type: application/json

Basic example:

{
  "captions": [
    { "text": "Hello, world!" },
    { "text": "Second line", "timestamp": "2024-01-01T12:00:02.000" },
    { "text": "Third line",  "time": 5000 }
  ]
}

Example with translations (send Finnish translation to YouTube, show original English above it, also save a Spanish translation to the backend file):

{
  "captions": [
    {
      "text": "Welcome to the stream!",
      "timestamp": "2024-01-01T12:00:01.000",
      "translations": {
        "fi-FI": "Tervetuloa streamiin!",
        "es-ES": "¡Bienvenido al stream!"
      },
      "captionLang": "fi-FI",
      "showOriginal": true
    }
  ]
}

In the example above the backend will send "Welcome to the stream!<br>Tervetuloa streamiin!" to YouTube Live. If showOriginal were false, only "Tervetuloa streamiin!" would be sent.

FieldTypeRequiredDescription
captionsarrayYesArray of caption objects (at least one required)
captions[].textstringYesCaption text (original language)
captions[].timestampstring | numberNoISO string (YYYY-MM-DDTHH:MM:SS.mmm) or Unix milliseconds. Defaults to current server time.
captions[].timenumberNoMilliseconds since session startedAt. Resolved by the server as startedAt + time + syncOffset. Cannot be combined with timestamp.
captions[].translationsobjectNoMap of BCP-47 language code → translated text, e.g. { "fi-FI": "Hei maailma!", "es-ES": "¡Hola, mundo!" }. Used for backend file saving and caption composition.
captions[].captionLangstringNoBCP-47 code of the translation to use as the YouTube caption text. The backend looks up this code in translations.
captions[].showOriginalbooleanNoWhen true and captionLang is set, the caption sent to YouTube is "original<br>translated" instead of just the translation.

Caption text composition

The backend composes the final text sent to YouTube as follows:

captionLang settranslations[captionLang] existsshowOriginalResult sent to YouTube
Notext (original)
YesNotext (original, fallback)
YesYesfalsetranslations[captionLang]
YesYestruetext + "<br>" + translations[captionLang]

If backend_file_enabled is set on the API key, the original text and all translations are also written to per-session files under $FILES_DIR/<apiKey>/. See File Saving and GET /file.

Response — 202 Accepted

{
  "ok": true,
  "requestId": "a1b2c3d4e5f6..."
}
FieldTypeDescription
okbooleanAlways true for a 202 response
requestIdstringCorrelates to a caption_result or caption_error SSE event

Error responses

StatusReason
400Invalid or empty captions array
401Missing or invalid JWT
429Daily or lifetime usage limit exceeded

File Saving

When backend_file_enabled is enabled on an API key (set via PATCH /keys/:key), each caption delivery also appends the text to files in $FILES_DIR/<apiKey>/:

  • One file per language (including the original), in YouTube plaintext format (one line per caption).
  • Files are named <date>-<session8chars>-<lang>.<ext> (e.g. 2024-01-01-a1b2c3d4-fi_FI.txt).
  • File metadata is recorded in the caption_files database table.
  • Use GET /file to list, download, or delete stored files.

Free-tier API keys have backend_file_enabled = false (the default). Enable it per-key via the admin PATCH /keys/:key endpoint.


Generic Target Payload

When a session includes a generic target (configured via POST /live or PATCH /live), the backend POSTs a JSON body to the target URL for every POST /captions call. The payload contains the original text, the composed/translated text, and all translation metadata so the receiving service can apply its own logic.

{
  "source": "https://your-app.example.com",
  "sequence": 7,
  "captions": [
    {
      "text": "Welcome to the stream!",
      "composedText": "Welcome to the stream!<br>Tervetuloa streamiin!",
      "timestamp": "2024-01-01T12:00:01.000",
      "translations": {
        "fi-FI": "Tervetuloa streamiin!",
        "es-ES": "¡Bienvenido al stream!"
      },
      "captionLang": "fi-FI",
      "showOriginal": true
    }
  ]
}
FieldTypeDescription
sourcestringThe domain value the session was registered with
sequencenumberSession sequence counter at the time of delivery
captionsarrayArray of caption objects (same length as the POST /captions request)
captions[].textstringOriginal caption text as supplied by the client
captions[].composedTextstringFinal text after translation composition (what YouTube received)
captions[].timestampstring | undefinedISO timestamp string, or omitted if not provided
captions[].translationsobject | undefinedFull translations map, if provided by the client
captions[].captionLangstring | undefinedBCP-47 code of the active translation language, if set
captions[].showOriginalboolean | undefinedWhether the original was combined with the translation, if set

/events — SSE Event Stream

Open a persistent Server-Sent Events connection to receive real-time caption delivery results and session events.


GET /events — SSE Event Stream

Authentication: Bearer JWT (via Authorization: Bearer <token> header) or query parameter (?token=<JWT>)

The query parameter form is useful for EventSource in browsers, which cannot set custom headers.

Request

GET /events
Authorization: Bearer <token>
Accept: text/event-stream

or

GET /events?token=<JWT>

Response — 200 OK (streaming, Content-Type: text/event-stream)

The connection stays open until the session ends or the client disconnects.


SSE Events

connected

Sent immediately after the SSE connection is established.

event: connected
data: {"sessionId":"a1b2c3...","micHolder":null}
FieldTypeDescription
sessionIdstringThe session identifier
micHolderstring | nullClient ID currently holding the mic lock, or null

caption_result

Sent after a caption (or batch) is successfully delivered to YouTube.

event: caption_result
data: {"requestId":"...","sequence":7,"statusCode":200,"serverTimestamp":"2024-01-01T12:00:00.082","count":1}
FieldTypeDescription
requestIdstringMatches the requestId from POST /captions
sequencenumberSequence number used for this delivery
statusCodenumberHTTP status from YouTube
serverTimestampstringTimestamp returned by YouTube
countnumberNumber of captions in the batch (present for batch sends)

caption_error

Sent when caption delivery to YouTube fails.

event: caption_error
data: {"requestId":"...","error":"HTTP 403: Forbidden","statusCode":403,"sequence":7}
FieldTypeDescription
requestIdstringMatches the requestId from POST /captions
errorstringHuman-readable error message
statusCodenumber | undefinedHTTP status code from YouTube (if available)
sequencenumber | undefinedSequence number at the time of failure

mic_state

Sent when the soft mic lock changes (see POST /mic).

event: mic_state
data: {"holder":"client-abc"}
FieldTypeDescription
holderstring | nullClient ID now holding the mic, or null if released

session_closed

Sent when the session is terminated (by DELETE /live, TTL expiry, or GDPR erasure).

event: session_closed
data: {}

After receiving this event, the client should close the SSE connection and stop sending captions.


Example: Full Client Flow (Browser)

// 1. Register session (target-array mode — no top-level streamKey)
const reg = await fetch('/live', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    apiKey,
    domain: location.origin,
    targets: [
      { id: 'yt-main', type: 'youtube', streamKey: 'xxxx-xxxx-xxxx-xxxx' },
    ],
  }),
});
const { token } = await reg.json();

// 2. Open SSE stream
const es = new EventSource(`/events?token=${token}`);

es.addEventListener('caption_result', (e) => {
  const { requestId, sequence, statusCode } = JSON.parse(e.data);
  console.log('Delivered:', requestId, 'seq', sequence, 'status', statusCode);
});

es.addEventListener('caption_error', (e) => {
  const { requestId, error } = JSON.parse(e.data);
  console.error('Failed:', requestId, error);
});

es.addEventListener('session_closed', () => {
  es.close();
});

// 3. Send a caption with a Finnish translation
const res = await fetch('/captions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`,
  },
  body: JSON.stringify({
    captions: [
      {
        text: 'Hello, world!',
        translations: { 'fi-FI': 'Hei maailma!' },
        captionLang: 'fi-FI',
        showOriginal: false,
      },
    ],
  }),
});
const { requestId } = await res.json();
// Wait for caption_result with matching requestId on the SSE stream

API Key Management

Admin endpoints for managing API keys. All endpoints require the X-Admin-Key header unless noted.

If ADMIN_KEY is not configured in the server environment, all admin routes return 503 Service Unavailable.

See the Free-tier self-service key section for the unauthenticated key creation option.


POST /keys — Create Key

Create a new API key.

Authentication: X-Admin-Key header

Request

POST /keys
X-Admin-Key: <ADMIN_KEY>
Content-Type: application/json
{
  "owner": "Alice",
  "key": "custom-key-value",
  "expires": "2025-01-01",
  "daily_limit": 1000,
  "lifetime_limit": 50000
}
FieldTypeRequiredDescription
ownerstringYesHuman-readable owner name
keystringNoCustom key string. If omitted, a UUID is generated.
expiresstringNoExpiry date in YYYY-MM-DD format. No expiry if omitted.
daily_limitnumberNoMax captions per day. Unlimited if omitted.
lifetime_limitnumberNoMax captions for the key’s lifetime. Unlimited if omitted.

Response — 201 Created

{
  "key": "custom-key-value",
  "owner": "Alice",
  "active": true,
  "createdAt": "2024-01-01T12:00:00.000Z",
  "expires": "2025-01-01T00:00:00.000Z",
  "dailyLimit": 1000,
  "lifetimeLimit": 50000,
  "lifetimeUsed": 0,
  "backendFileEnabled": false,
  "relayAllowed": false,
  "relayActive": false,
  "radioEnabled": false,
  "hlsEnabled": false,
  "cea708DelayMs": 0,
  "embedCors": "*"
}

GET /keys — List Keys

List all API keys.

Authentication: X-Admin-Key header

Request

GET /keys
X-Admin-Key: <ADMIN_KEY>

Response — 200 OK

{
  "keys": [
    {
      "key": "key-abc",
      "owner": "Alice",
      "active": true,
      "createdAt": "2024-01-01T12:00:00.000Z",
      "expires": null,
      "dailyLimit": null,
      "lifetimeLimit": null,
      "lifetimeUsed": 42,
      "backendFileEnabled": false,
      "relayAllowed": false,
      "relayActive": false,
      "radioEnabled": false,
      "hlsEnabled": false,
      "cea708DelayMs": 0,
      "embedCors": "*"
    }
  ]
}

GET /keys/:key — Get Key

Retrieve a single API key with usage statistics.

Authentication: X-Admin-Key header

Request

GET /keys/key-abc
X-Admin-Key: <ADMIN_KEY>

Response — 200 OK

{
  "key": "key-abc",
  "owner": "Alice",
  "active": true,
  "createdAt": "2024-01-01T12:00:00.000Z",
  "expires": null,
  "dailyLimit": null,
  "lifetimeLimit": null,
  "lifetimeUsed": 42,
  "backendFileEnabled": false,
  "relayAllowed": false,
  "relayActive": false,
  "radioEnabled": false,
  "hlsEnabled": false,
  "cea708DelayMs": 0,
  "embedCors": "*"
}

Error responses

StatusReason
404Key not found

PATCH /keys/:key — Update Key

Update mutable fields on an existing API key.

Authentication: X-Admin-Key header

Request

PATCH /keys/key-abc
X-Admin-Key: <ADMIN_KEY>
Content-Type: application/json
{
  "owner": "Alice Smith",
  "expires": "2026-01-01",
  "daily_limit": 2000,
  "lifetime_limit": 100000
}
FieldTypeRequiredDescription
ownerstringNoUpdated owner name
expiresstringNoNew expiry date (YYYY-MM-DD)
daily_limitnumber | nullNoNew daily limit. Pass null to remove the limit.
lifetime_limitnumber | nullNoNew lifetime limit. Pass null to remove the limit.
backend_file_enabledbooleanNoEnable (true) or disable (false) backend caption file saving for this key. Disabled by default. See /file.
relay_allowedbooleanNoGrant permission to use the RTMP relay (/stream endpoints). Disabled by default. Requires RTMP_RELAY_ACTIVE=1 on the server.
radio_enabledbooleanNoEnable audio-only HLS radio streaming for this key (/radio). Disabled by default.
hls_enabledbooleanNoEnable video+audio HLS streaming for this key (/stream-hls). Disabled by default.
graphics_enabledbooleanNoEnable DSK image uploads for this key (POST /images). Disabled by default. Also requires GRAPHICS_ENABLED=1 on the server.
cea708_delay_msnumberNoVideo delay in milliseconds applied in CEA-708 caption mode (default 0). Used to align embedded captions with delayed video.
embed_corsstring | nullNoCORS Access-Control-Allow-Origin value for the per-key public embed endpoints (/stream-hls, /radio). Defaults to '*'. Pass a specific origin (e.g. 'https://yoursite.com') to restrict. Pass null to reset to '*'.

Response — 200 OK — Updated key object (same shape as GET /keys/:key)

Error responses

StatusReason
404Key not found

DELETE /keys/:key — Revoke or Delete Key

Revoke (soft-delete) or permanently delete an API key.

Authentication: X-Admin-Key header

Request

DELETE /keys/key-abc
X-Admin-Key: <ADMIN_KEY>

Query parameters

ParameterTypeDescription
permanentbooleanIf true, hard-delete the key from the database. Default: soft-revoke.

Soft-revoke sets the key’s active flag to 0 and records revoked_at. The key remains in the database for audit purposes and is purged after REVOKED_KEY_TTL_DAYS (default 30 days).

Response — 200 OK

Soft-revoke:

{ "key": "key-abc", "revoked": true }

Hard-delete:

{ "key": "key-abc", "deleted": true }

Error responses

StatusReason
404Key not found

POST /keys?freetier — Free-Tier Key Signup

Self-service key creation for end users. Only available when FREE_APIKEY_ACTIVE=1 is set in the server environment. Does not require admin authentication.

Authentication: None

Request

POST /keys?freetier
Content-Type: application/json
{
  "name": "Alice",
  "email": "alice@example.com"
}
FieldTypeRequiredDescription
namestringYesRequester’s name
emailstringYesRequester’s email address

Response — 201 Created — Key object with default free-tier limits:

  • Expiry: 1 month from creation
  • Daily limit: 200 captions
  • Lifetime limit: 1000 captions

Error responses

StatusReason
400Missing name or email
503Free-tier signup not enabled (FREE_APIKEY_ACTIVE not set)

/stats — Usage Statistics

Per-key usage statistics and GDPR data erasure.


GET /stats — User Statistics

Return usage statistics and session history for the authenticated API key.

Authentication: Bearer JWT

Request

GET /stats
Authorization: Bearer <token>

Response — 200 OK

{
  "apiKey": "key-abc",
  "owner": "Alice",
  "email": "alice@example.com",
  "expires": "2025-01-01T00:00:00.000Z",
  "usage": {
    "lifetimeUsed": 1234,
    "dailyUsed": 42,
    "dailyLimit": 1000,
    "lifetimeLimit": 50000
  },
  "sessions": [
    {
      "sessionId": "a1b2c3...",
      "domain": "https://my-app.example.com",
      "startedAt": "2024-06-01T10:00:00.000Z",
      "endedAt": "2024-06-01T11:00:00.000Z",
      "captionsSent": 120,
      "captionsFailed": 2
    }
  ],
  "captionErrors": [
    {
      "sessionId": "a1b2c3...",
      "error": "HTTP 403",
      "occurredAt": "2024-06-01T10:30:00.000Z"
    }
  ],
  "authEvents": [
    {
      "event": "session_start",
      "domain": "https://my-app.example.com",
      "occurredAt": "2024-06-01T10:00:00.000Z"
    }
  ],
  "viewerStats": [
    {
      "viewerKey": "my-event-key",
      "date": "2024-06-01",
      "opens": 42
    }
  ]
}
FieldTypeDescription
apiKeystringRedacted/aliased API key identifier
ownerstringOwner name associated with the key
emailstring | nullOwner email (if stored)
expiresstring | nullKey expiry timestamp, or null if no expiry
usage.lifetimeUsednumberTotal captions sent across all sessions
usage.dailyUsednumberCaptions sent today
usage.dailyLimitnumber | nullDaily caption limit, or null if unlimited
usage.lifetimeLimitnumber | nullLifetime caption limit, or null if unlimited
sessionsarrayCompleted session records
captionErrorsarrayRecent caption delivery failures
authEventsarrayRecent authentication and usage events
viewerStatsarrayDaily viewer opens per viewer key. Each entry: { viewerKey, date, opens }.

DELETE /stats — GDPR Data Erasure

Permanently anonymise the authenticated API key and delete all associated personal data. This implements the GDPR “right to erasure”.

Authentication: Bearer JWT

Request

DELETE /stats
Authorization: Bearer <token>

Response — 200 OK

{
  "ok": true,
  "message": "Your data has been anonymised and deleted."
}

Side effects:

  • Terminates the active session
  • Anonymises the API key record (owner name and email replaced with placeholder values)
  • Deletes all associated session_stats, caption_errors, and auth_events records
  • The key’s email is retained in minimal form for fraud prevention purposes
  • The active JWT is invalidated

Note: After calling this endpoint, the API key can no longer be used. Contact your server admin if you need a new key.

/file — Caption File Management

List, download, and delete caption and translation files that were saved on the backend during a session.

Backend file saving is only active for API keys that have backend_file_enabled = true. By default this is disabled (free-tier keys). An admin can enable it via PATCH /keys/:key with { "backend_file_enabled": true }.

All /file routes are rate-limited to 60 requests per minute per IP.


GET /file — List Files

Return all caption files stored for the authenticated API key.

Authentication: Bearer JWT

Request

GET /file
Authorization: Bearer <token>

Response — 200 OK

{
  "files": [
    {
      "id": 1,
      "filename": "2024-01-01-a1b2c3d4-fi_FI.txt",
      "lang": "fi-FI",
      "format": "youtube",
      "type": "captions",
      "createdAt": "2024-01-01T12:00:00",
      "updatedAt": "2024-01-01T12:05:30",
      "sizeBytes": 1024
    }
  ]
}
FieldTypeDescription
idnumberUnique file identifier
filenamestringFilename as stored on the server
langstring | nullBCP-47 language code of the content, or null for the original language
formatstring"youtube" (plain text) or "vtt" (WebVTT)
typestring"captions"
createdAtstringISO datetime when the file entry was created
updatedAtstringISO datetime of the last write
sizeBytesnumberApproximate file size in bytes

Error responses

StatusReason
401Missing or invalid JWT
404Session not found
429Rate limit exceeded

GET /file/:id — Download File

Download a specific caption file. Supports the ?token= query parameter for use in direct download links (e.g. <a href="/file/1?token=...">Download</a>).

Authentication: Bearer JWT or ?token=<jwt> query parameter

Request

GET /file/1
Authorization: Bearer <token>

Or as a direct link:

GET /file/1?token=<jwt>

Response — 200 OK

The file is returned as a file download with the appropriate Content-Type:

FormatContent-Type
youtubetext/plain; charset=utf-8
vtttext/vtt; charset=utf-8

The Content-Disposition header is set to attachment; filename="<filename>".

Error responses

StatusReason
400Invalid file id
401Missing or invalid token
404File not found (in database or on disk)
429Rate limit exceeded

DELETE /file/:id — Delete File

Delete a caption file. Removes both the database record and the file from disk.

Authentication: Bearer JWT

Request

DELETE /file/1
Authorization: Bearer <token>

Response — 200 OK

{ "ok": true }

Error responses

StatusReason
400Invalid file id
401Missing or invalid JWT
404File not found or does not belong to this API key
429Rate limit exceeded

File Naming

Files are stored under $FILES_DIR/<sanitised-api-key>/ on the server. The filename format is:

<YYYY-MM-DD>-<session8chars>-<lang>.<ext>

For example:

  • 2024-01-01-a1b2c3d4-fi_FI.txt — Finnish translation in YouTube format
  • 2024-01-01-a1b2c3d4-original.txt — Original language in YouTube format
  • 2024-01-01-a1b2c3d4-en_US.vtt — English in WebVTT format

Files are appended as captions arrive during the session. A new file is created for each unique combination of session, language, and format.


Enabling Backend File Saving

Backend file saving is controlled by the backend_file_enabled flag on each API key. It is disabled by default.

To enable for a specific key (admin only):

PATCH /keys/my-api-key
X-Admin-Key: <ADMIN_KEY>
Content-Type: application/json

{ "backend_file_enabled": true }

The backend_file_enabled field is included in all key read responses (GET /keys and GET /keys/:key).

See /keys for full API key management reference.


Server Configuration

VariableDefaultDescription
FILES_DIR/data/filesBase directory where caption files are stored. Each API key gets its own subdirectory.

/usage — Domain Usage

Return aggregated caption statistics broken down by domain and time period.


GET /usage — Domain Usage Statistics

Authentication:

  • If USAGE_PUBLIC environment variable is set: no authentication required (CORS limited to ALLOWED_DOMAINS)
  • Otherwise: X-Admin-Key header required

Request

GET /usage?from=2024-01-01&to=2024-01-31&granularity=day
X-Admin-Key: <ADMIN_KEY>

Query Parameters

ParameterTypeDefaultDescription
fromstring30 days agoStart date in YYYY-MM-DD format
tostringtodayEnd date in YYYY-MM-DD format
granularitystring'day'Aggregation level: 'hour' or 'day'
domainstringall domainsFilter to a specific domain

Response — 200 OK

{
  "from": "2024-01-01",
  "to": "2024-01-31",
  "granularity": "day",
  "public": false,
  "data": [
    {
      "domain": "https://my-app.example.com",
      "date": "2024-01-15",
      "sessions_started": 12,
      "sessions_ended": 11,
      "captions_sent": 480,
      "captions_failed": 3,
      "batches_sent": 120,
      "total_duration_ms": 3600000,
      "peak_sessions": 4
    }
  ]
}

When granularity=hour, each record also includes an hour field (integer 0–23).

FieldTypeDescription
domainstringOrigin domain
datestringDate in YYYY-MM-DD format
hournumberHour of day (only present when granularity=hour)
sessions_startednumberSessions created in this period
sessions_endednumberSessions closed in this period
captions_sentnumberCaptions successfully delivered
captions_failednumberCaptions that failed delivery
batches_sentnumberNumber of batch requests
total_duration_msnumberSum of session durations in milliseconds
peak_sessionsnumberHighest concurrent session count observed

The response also includes a top-level viewerStats array with anonymous daily viewer open counts:

{
  "from": "2024-01-01",
  "to": "2024-01-31",
  "granularity": "day",
  "public": false,
  "data": [...],
  "viewerStats": [
    { "date": "2024-01-15", "opens": 38 }
  ]
}
FieldTypeDescription
viewerStats[].datestringDate in YYYY-MM-DD format
viewerStats[].opensnumberTotal anonymous viewer SSE opens on that date

/mic — Mic Lock

Claim or release the soft mic lock for a collaborative session. The mic lock is advisory — it signals which client should be considered the active speaker, but does not block other clients from sending captions.


POST /mic — Mic Lock

Authentication: Bearer JWT

Request

POST /mic
Authorization: Bearer <token>
Content-Type: application/json
{
  "action": "claim",
  "clientId": "client-abc"
}
FieldTypeRequiredDescription
actionstringYes'claim' to acquire the lock, 'release' to relinquish it
clientIdstringYesUnique identifier for the calling client

Behavior:

  • claim: Sets the session’s micHolder to clientId, overwriting any existing holder. All connected SSE clients receive a mic_state event.
  • release: Clears micHolder only if the caller is the current holder. If the caller is not the holder, the request is a no-op.

Response — 200 OK

{
  "ok": true,
  "holder": "client-abc"
}
FieldTypeDescription
okbooleanAlways true
holderstring | nullThe current mic holder after this operation

Side effects: A mic_state SSE event is broadcast to all SSE clients in the session.

/health — Health Check

Server health information. Suitable for load balancer health probes and uptime monitoring.


GET /health — Health Check

Return server health information. Suitable for load balancer health probes and uptime monitoring.

Authentication: None

Request

GET /health

Response — 200 OK

{
  "ok": true,
  "uptime": 3600.42,
  "activeSessions": 3
}

When RTMP_RELAY_ACTIVE=1 is set, the response also includes an rtmpIngest object:

{
  "ok": true,
  "uptime": 3600.42,
  "activeSessions": 3,
  "rtmpIngest": {
    "host": "rtmp.example.com",
    "app": "stream"
  }
}
FieldTypeDescription
okbooleanAlways true when the server is healthy
uptimenumberServer uptime in seconds
activeSessionsnumberNumber of currently active caption relay sessions
rtmpIngest.hoststringRTMP ingest hostname (from RTMP_HOST env var)
rtmpIngest.appstringRTMP application name (from RTMP_APP env var)

/contact — Contact

Return the operator’s contact details. Fields are configured via environment variables and are optional.


GET /contact — Contact Information

Authentication: None

Request

GET /contact

Response — 200 OK

{
  "name": "LCYT Service Operator",
  "email": "operator@example.com",
  "phone": "+1-555-0100",
  "website": "https://example.com"
}

Fields that are not configured in the server environment are omitted from the response.

FieldTypeSource env varDescription
namestringCONTACT_NAMEOperator name
emailstringCONTACT_EMAILOperator email address
phonestringCONTACT_PHONEOperator phone number
websitestringCONTACT_WEBSITEOperator website URL

If none of the contact environment variables are set, the response body will be an empty object ({}).