/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"
}
| Field | Type | Required | Description |
|---|---|---|---|
apiKey | string | Yes | API key issued by the server admin |
streamKey | string | No | YouTube Live stream key. Omit in target-array mode. |
domain | string | Yes | Registered origin domain (used for CORS and session isolation) |
sequence | number | No | Override the starting sequence number. When omitted, the server uses the persisted per-API-key sequence (see Per-key Sequence Persistence). |
targets | array | No | Array 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:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Client-assigned identifier (used for logging and updates) |
type | string | Yes | Must be "youtube" |
streamKey | string | Yes | YouTube Live stream key for this target |
Generic (webhook) target — POSTs caption data as JSON to an arbitrary HTTP endpoint:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Client-assigned identifier |
type | string | Yes | Must be "generic" |
url | string | Yes | Destination URL (must use http or https) |
headers | object | No | Extra 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:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Client-assigned identifier |
type | string | Yes | Must be "viewer" |
viewerKey | string | Yes | Short 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"
}
| Field | Type | Description |
|---|---|---|
token | string | JWT for authenticating subsequent requests |
sessionId | string | SHA-256 of apiKey:streamKey:domain |
sequence | number | Current sequence counter |
syncOffset | number | NTP-style clock offset in milliseconds |
startedAt | string | Session start time (ISO string) |
Error responses
| Status | Reason |
|---|---|
400 | Missing or invalid fields |
401 | Invalid or expired API key |
429 | Daily 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
}
| Field | Type | Description |
|---|---|---|
sequence | number | Current sequence counter |
syncOffset | number | Clock 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" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
sequence | number | No | New sequence counter value. Setting 0 explicitly resets the persisted per-key sequence. |
targets | array | No | Replace 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
}
| Field | Type | Description |
|---|---|---|
sequence | number | Current sequence counter |
targetsCount | number | Number 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
YoutubeLiveCaptionSenderfor this session (primary sender and all extra YouTube targets) - Writes a
session_statsrecord to the database - Emits a
session_closedevent 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:
- All previously active sessions are restored into memory (without active YouTube senders — senders are not serialisable).
- Sequence counters, clock offsets, and metadata are preserved.
- When a client calls
POST /livefor a rehydrated session, the server issues a fresh JWT and attaches a newYoutubeLiveCaptionSender. 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
}
| Field | Type | Description |
|---|---|---|
syncOffset | number | Computed clock offset in milliseconds. Positive means the server is ahead of the client. |
roundTripTime | number | Round-trip latency to YouTube in milliseconds |
serverTimestamp | string | Timestamp returned by YouTube |
statusCode | number | HTTP 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.
| Field | Type | Required | Description |
|---|---|---|---|
captions | array | Yes | Array of caption objects (at least one required) |
captions[].text | string | Yes | Caption text (original language) |
captions[].timestamp | string | number | No | ISO string (YYYY-MM-DDTHH:MM:SS.mmm) or Unix milliseconds. Defaults to current server time. |
captions[].time | number | No | Milliseconds since session startedAt. Resolved by the server as startedAt + time + syncOffset. Cannot be combined with timestamp. |
captions[].translations | object | No | Map 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[].captionLang | string | No | BCP-47 code of the translation to use as the YouTube caption text. The backend looks up this code in translations. |
captions[].showOriginal | boolean | No | When 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 set | translations[captionLang] exists | showOriginal | Result sent to YouTube |
|---|---|---|---|
| No | — | — | text (original) |
| Yes | No | — | text (original, fallback) |
| Yes | Yes | false | translations[captionLang] |
| Yes | Yes | true | text + "<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..."
}
| Field | Type | Description |
|---|---|---|
ok | boolean | Always true for a 202 response |
requestId | string | Correlates to a caption_result or caption_error SSE event |
Error responses
| Status | Reason |
|---|---|
400 | Invalid or empty captions array |
401 | Missing or invalid JWT |
429 | Daily 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_filesdatabase table. - Use
GET /fileto 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
}
]
}
| Field | Type | Description |
|---|---|---|
source | string | The domain value the session was registered with |
sequence | number | Session sequence counter at the time of delivery |
captions | array | Array of caption objects (same length as the POST /captions request) |
captions[].text | string | Original caption text as supplied by the client |
captions[].composedText | string | Final text after translation composition (what YouTube received) |
captions[].timestamp | string | undefined | ISO timestamp string, or omitted if not provided |
captions[].translations | object | undefined | Full translations map, if provided by the client |
captions[].captionLang | string | undefined | BCP-47 code of the active translation language, if set |
captions[].showOriginal | boolean | undefined | Whether 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}
| Field | Type | Description |
|---|---|---|
sessionId | string | The session identifier |
micHolder | string | null | Client 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}
| Field | Type | Description |
|---|---|---|
requestId | string | Matches the requestId from POST /captions |
sequence | number | Sequence number used for this delivery |
statusCode | number | HTTP status from YouTube |
serverTimestamp | string | Timestamp returned by YouTube |
count | number | Number 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}
| Field | Type | Description |
|---|---|---|
requestId | string | Matches the requestId from POST /captions |
error | string | Human-readable error message |
statusCode | number | undefined | HTTP status code from YouTube (if available) |
sequence | number | undefined | Sequence 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"}
| Field | Type | Description |
|---|---|---|
holder | string | null | Client 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
}
| Field | Type | Required | Description |
|---|---|---|---|
owner | string | Yes | Human-readable owner name |
key | string | No | Custom key string. If omitted, a UUID is generated. |
expires | string | No | Expiry date in YYYY-MM-DD format. No expiry if omitted. |
daily_limit | number | No | Max captions per day. Unlimited if omitted. |
lifetime_limit | number | No | Max 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
| Status | Reason |
|---|---|
404 | Key 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
}
| Field | Type | Required | Description |
|---|---|---|---|
owner | string | No | Updated owner name |
expires | string | No | New expiry date (YYYY-MM-DD) |
daily_limit | number | null | No | New daily limit. Pass null to remove the limit. |
lifetime_limit | number | null | No | New lifetime limit. Pass null to remove the limit. |
backend_file_enabled | boolean | No | Enable (true) or disable (false) backend caption file saving for this key. Disabled by default. See /file. |
relay_allowed | boolean | No | Grant permission to use the RTMP relay (/stream endpoints). Disabled by default. Requires RTMP_RELAY_ACTIVE=1 on the server. |
radio_enabled | boolean | No | Enable audio-only HLS radio streaming for this key (/radio). Disabled by default. |
hls_enabled | boolean | No | Enable video+audio HLS streaming for this key (/stream-hls). Disabled by default. |
graphics_enabled | boolean | No | Enable DSK image uploads for this key (POST /images). Disabled by default. Also requires GRAPHICS_ENABLED=1 on the server. |
cea708_delay_ms | number | No | Video delay in milliseconds applied in CEA-708 caption mode (default 0). Used to align embedded captions with delayed video. |
embed_cors | string | null | No | CORS 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
| Status | Reason |
|---|---|
404 | Key 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
| Parameter | Type | Description |
|---|---|---|
permanent | boolean | If 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
| Status | Reason |
|---|---|
404 | Key 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Requester’s name |
email | string | Yes | Requester’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
| Status | Reason |
|---|---|
400 | Missing name or email |
503 | Free-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
}
]
}
| Field | Type | Description |
|---|---|---|
apiKey | string | Redacted/aliased API key identifier |
owner | string | Owner name associated with the key |
email | string | null | Owner email (if stored) |
expires | string | null | Key expiry timestamp, or null if no expiry |
usage.lifetimeUsed | number | Total captions sent across all sessions |
usage.dailyUsed | number | Captions sent today |
usage.dailyLimit | number | null | Daily caption limit, or null if unlimited |
usage.lifetimeLimit | number | null | Lifetime caption limit, or null if unlimited |
sessions | array | Completed session records |
captionErrors | array | Recent caption delivery failures |
authEvents | array | Recent authentication and usage events |
viewerStats | array | Daily 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, andauth_eventsrecords - 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
}
]
}
| Field | Type | Description |
|---|---|---|
id | number | Unique file identifier |
filename | string | Filename as stored on the server |
lang | string | null | BCP-47 language code of the content, or null for the original language |
format | string | "youtube" (plain text) or "vtt" (WebVTT) |
type | string | "captions" |
createdAt | string | ISO datetime when the file entry was created |
updatedAt | string | ISO datetime of the last write |
sizeBytes | number | Approximate file size in bytes |
Error responses
| Status | Reason |
|---|---|
401 | Missing or invalid JWT |
404 | Session not found |
429 | Rate 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:
| Format | Content-Type |
|---|---|
youtube | text/plain; charset=utf-8 |
vtt | text/vtt; charset=utf-8 |
The Content-Disposition header is set to attachment; filename="<filename>".
Error responses
| Status | Reason |
|---|---|
400 | Invalid file id |
401 | Missing or invalid token |
404 | File not found (in database or on disk) |
429 | Rate 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
| Status | Reason |
|---|---|
400 | Invalid file id |
401 | Missing or invalid JWT |
404 | File not found or does not belong to this API key |
429 | Rate 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 format2024-01-01-a1b2c3d4-original.txt— Original language in YouTube format2024-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
| Variable | Default | Description |
|---|---|---|
FILES_DIR | /data/files | Base 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_PUBLICenvironment variable is set: no authentication required (CORS limited toALLOWED_DOMAINS) - Otherwise:
X-Admin-Keyheader required
Request
GET /usage?from=2024-01-01&to=2024-01-31&granularity=day
X-Admin-Key: <ADMIN_KEY>
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
from | string | 30 days ago | Start date in YYYY-MM-DD format |
to | string | today | End date in YYYY-MM-DD format |
granularity | string | 'day' | Aggregation level: 'hour' or 'day' |
domain | string | all domains | Filter 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).
| Field | Type | Description |
|---|---|---|
domain | string | Origin domain |
date | string | Date in YYYY-MM-DD format |
hour | number | Hour of day (only present when granularity=hour) |
sessions_started | number | Sessions created in this period |
sessions_ended | number | Sessions closed in this period |
captions_sent | number | Captions successfully delivered |
captions_failed | number | Captions that failed delivery |
batches_sent | number | Number of batch requests |
total_duration_ms | number | Sum of session durations in milliseconds |
peak_sessions | number | Highest 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 }
]
}
| Field | Type | Description |
|---|---|---|
viewerStats[].date | string | Date in YYYY-MM-DD format |
viewerStats[].opens | number | Total 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | 'claim' to acquire the lock, 'release' to relinquish it |
clientId | string | Yes | Unique identifier for the calling client |
Behavior:
claim: Sets the session’smicHoldertoclientId, overwriting any existing holder. All connected SSE clients receive amic_stateevent.release: ClearsmicHolderonly 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"
}
| Field | Type | Description |
|---|---|---|
ok | boolean | Always true |
holder | string | null | The 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"
}
}
| Field | Type | Description |
|---|---|---|
ok | boolean | Always true when the server is healthy |
uptime | number | Server uptime in seconds |
activeSessions | number | Number of currently active caption relay sessions |
rtmpIngest.host | string | RTMP ingest hostname (from RTMP_HOST env var) |
rtmpIngest.app | string | RTMP 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.
| Field | Type | Source env var | Description |
|---|---|---|---|
name | string | CONTACT_NAME | Operator name |
email | string | CONTACT_EMAIL | Operator email address |
phone | string | CONTACT_PHONE | Operator phone number |
website | string | CONTACT_WEBSITE | Operator website URL |
If none of the contact environment variables are set, the response body will be an empty object ({}).