API Reference
Strum VOD exposes a RESTful API over HTTP. All endpoints are prefixed with /v1/ and return JSON.
Base URL: http://localhost:13002 (docker compose) or http://localhost:3000 (all-in-one image / local dev without Docker)
Auth: Most endpoints require Authorization: Bearer <JWT> (session token) or X-Api-Key: <key> (org API key). Public endpoints (signup, login, playback, config, health) need no auth.
An interactive OpenAPI reference is available at
/referencewhen running the API withNODE_ENV !== 'production'(disabled in production for security). The generatedopenapi.jsondocuments all ~67 paths.
Response Format
All successful responses wrap the payload in a data key:
{ "data": { ... } }All error responses return an error string:
{ "error": "Error message" }HTTP Status Codes
| Code | Meaning |
|---|---|
200 | Success |
201 | Resource created |
400 | Validation error (missing or invalid fields) |
401 | Unauthenticated |
403 | Forbidden (plan limit, disabled registration, etc.) |
404 | Resource not found |
409 | Conflict (e.g. asset already has a source) |
501 | Feature not configured (e.g. TUS upload without SHARED_AUTH_SECRET) |
500 | Internal server error |
Health & Config
GET /health/live
Liveness probe. Response 200 — { "ok": true }
GET /health/ready
Readiness probe (DB connectivity). Response 200 — { "ok": true }
GET /v1/config
Public config: whether AI is available, org limits, etc. Response 200
Auth
POST /v1/auth/signup
Creates a user + default organization. Gated by REGISTRATION_ENABLED / REGISTRATION_ALLOWED_DOMAINS. The very first org on an install promotes its owner to superadmin.
Body: { "email": string, "password": string (min 8), "name": string }
Response 201 — { "data": { "token", "user": {...}, "org": { "id", "slug" } } }
POST /v1/auth/login
Body: { "email", "password" } → Response 200 — { "data": { "token", "user", "org" } }
POST /v1/auth/otp/send / POST /v1/auth/otp/verify / POST /v1/auth/otp/verify-signup
Email magic-code flow (OTP codes sent via Resend/SMTP/console).
POST /v1/auth/switch-org
Issues a new JWT scoped to another org the user belongs to. Body: { "orgId" }
GET /v1/auth/me
Response 200 — current user + memberships.
POST /v1/auth/change-password
Body: { "currentPassword", "newPassword" }
Assets
Create Asset
POST /v1/assetsCreates a new asset in created state with a unique playback ID. Requires auth.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Name of the video (min 1 character) |
metadata | object | No | Custom metadata (max 10 entries, 255 chars each) |
Response 201
{ "data": { "id": "a1b2c3d4e5f6", "playbackId": "p1b2c3d4e5f6g7h8", "status": "created" } }List Assets
GET /v1/assets?collectionId=<id|none>Returns all assets ordered by creation date (newest first). Optionally filtered by collection: pass a collection id, or none for uncategorized assets.
Response 200 — array with thumbnailUrl and hasCustomThumbnail added per asset.
Search Assets
GET /v1/assets/search?q=<query>&status=&limit=&offset=Full-text search over title/description (PostgreSQL GIN), with LIKE fallback.
Get Asset
GET /v1/assets/:idReturns a single asset with renditions, active job step, latest job metadata, AI job status, and highlight clips.
Response 200
{
"data": {
"id": "a1b2c3d4e5f6",
"title": "My Video",
"status": "ready",
"playbackId": "p1b2c3d4e5f6g7h8",
"collectionId": null,
"durationSec": 142,
"thumbnailUrl": "https://.../thumbnail.jpg",
"currentStep": null,
"job": { "status": "completed", "workerKind": "go", "hwAccelUsed": false },
"renditions": [
{ "quality": "360p", "width": 640, "height": 360, "bitrateKbps": 1000, "codec": "h264" },
{ "quality": "1080p", "width": 1920, "height": 1080, "bitrateKbps": 6000, "codec": "h264" }
],
"aiJob": { "status": "completed", "transcriptionStatus": "completed", "subtitlesStatus": "completed", "chaptersStatus": "completed", "highlightsStatus": "completed" },
"highlights": []
}
}Update Asset
PATCH /v1/assets/:idPartially updates title, description, public playback settings, custom metadata, and/or collection membership.
Body fields: title, description, publicSettings ({ allowDownload, showTranscript, showChapters, showHighlights, showComments }), metadata, collectionId (string or null to unassign).
Get Presigned Upload URL (Standard)
POST /v1/assets/:id/upload-urlGenerates a pre-signed S3/R2 PUT URL (valid for 1 hour). The browser uploads the raw video file directly to storage.
Response 200 — { "data": { "uploadUrl", "sourceKey", "method": "PUT" } }
Get TUS Upload Token (Resumable)
POST /v1/assets/:id/upload-tokenIssues a short-lived JWT (15 minutes) that authorizes a TUS resumable upload to POST/PATCH /upload/videos (served by this same API, src/routes/tus.ts). Requires SHARED_AUTH_SECRET to be configured.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
fileSize | number | Yes | File size in bytes (max 10 GB) |
Response 200 — { "data": { "token", "sourceKey" } }
Errors — 409 asset already has a source · 501 SHARED_AUTH_SECRET not configured
Using the token with tus-js-client
import * as tus from 'tus-js-client';
const upload = new tus.Upload(file, {
endpoint: `${TUS_SERVER_URL}/upload/videos`,
metadata: {
filename: sourceKey, // must match JWT sub claim
filetype: file.type,
},
headers: {
Authorization: `Bearer ${token}`,
},
uploadSize: file.size,
onProgress(sent, total) { console.log(`${Math.round(sent/total*100)}%`); },
onSuccess() { /* call /upload-complete */ },
});
upload.start();Direct Upload (raw body)
PUT /v1/assets/:id/uploadStreams the raw video body to a shared volume the worker reads directly (no S3 round-trip). Body is a raw octet stream. 5 GB limit.
Confirm Upload Complete
POST /v1/assets/:id/upload-completeVerifies the source file exists in storage (via HeadObject) and transitions the asset status to uploaded.
Response 200 — { "data": { "id": "...", "status": "uploaded" } }
Import from URL
POST /v1/assets/:id/importSets the asset source to an external URL and transitions status to uploaded. The transcoder downloads from this URL (via yt-dlp) during processing.
Body: { "sourceUrl": "https://..." }
Start Transcoding
POST /v1/assets/:id/processCreates a transcode job and pushes it to BullMQ. Asset moves to queued, then processing, then ready or error.
Request Body (optional)
| Field | Type | Description |
|---|---|---|
aiOptions.transcription | boolean | Enable AI transcription (default true) |
aiOptions.subtitles | boolean | Generate WebVTT/SRT subtitles (default true) |
aiOptions.chapters | boolean | Generate AI chapters (default true) |
aiOptions.highlights | boolean | Generate AI highlight clips (default true) |
Response 200 — { "data": { "assetId", "jobId", "status": "queued" } }
Delete Asset
DELETE /v1/assets/:idHard deletes the asset: removes the DB row (FK CASCADE removes renditions/jobs/ai_jobs/highlights) and deletes the sources/{id}/ and playback/{id}/ objects from R2. Emits an asset.deleted domain event. This is not recoverable.
Response 200 — { "data": { "id": "...", "deleted": true } }
Other Asset Endpoints
| Endpoint | Method | Description |
|---|---|---|
/v1/assets/:id/thumbnail | PUT | Upload custom thumbnail (JPG/PNG/WebP, 10 MB) |
/v1/assets/:id/thumbnail | DELETE | Reset to auto-generated frame |
/v1/assets/:id/download?quality=1080p | GET | Presigned download URL (source or rendition MP4) |
/v1/assets/:id/audio | GET | Presigned playback audio download URL |
/v1/assets/:id/ai-audio | GET | Presigned AI audio download URL |
/v1/assets/:id/audio-config | PATCH | Update playback/AI audio extraction config |
/v1/assets/:id/transcript | PATCH | Replace transcript + regenerate VTT/SRT |
/v1/assets/:id/chapters | PATCH | Replace chapters |
/v1/assets/:id/highlights/:highlightId | PATCH | Update highlight title/description |
/v1/assets/:id/highlights/:highlightId | DELETE | Delete a highlight clip |
Collections
List Collections
GET /v1/collectionsLists all collections (folders) for the org with a video count each. Response 200:
{ "data": [ { "id": "...", "name": "Tutorials", "color": "#4f46e5", "videoCount": 3, "createdAt": "...", "updatedAt": "..." } ] }Create Collection
POST /v1/collectionsBody: { "name": string (1–255), "color": one of the COLLECTION_COLORS palette }
Response 201 — { "data": { "id", "name", "color", "videoCount": 0 } }
Update Collection
PATCH /v1/collections/:idPartially updates name and/or color. Response 200 · 404 if not found or not in your org.
Delete Collection
DELETE /v1/collections/:idDeletes the collection. Videos inside it are kept (FK sets collection_id to NULL — they move to "no collection"), never deleted.
Assign videos to collections via PATCH /v1/assets/:id with { "collectionId": "<id>" } (or null).
Playback
Get Public Playback
GET /v1/playback/:playbackIdPublic endpoint. Returns HLS manifest, thumbnail, subtitle, chapter URLs, public settings, and org branding for the player app. Only returns data when asset status is ready.
Response 200
{
"data": {
"playbackId": "p1b2c3d4e5f6g7h8",
"manifestUrl": "https://pub-hash.r2.dev/playback/a1b2c3d4e5f6/master.m3u8",
"thumbnailUrl": "https://pub-hash.r2.dev/playback/a1b2c3d4e5f6/thumbnail.jpg",
"title": "My Video",
"description": null,
"durationSec": 142,
"canEdit": false,
"publicSettings": { "allowDownload": false, "showTranscript": true, "showChapters": true, "showHighlights": true, "showComments": true },
"settings": { "primaryColor": "#6366f1", "theme": "dark", "logoUrl": null },
"audio": { "codec": "aac", "bitrateKbps": 128, "sampleRate": 48000, "channels": 2 },
"ai": {
"status": "completed",
"subtitlesUrl": "https://.../subtitles.vtt",
"srtSubtitlesUrl": "https://.../subtitles.srt",
"chaptersUrl": "https://.../chapters.json",
"transcriptUrl": "https://.../transcript.json",
"language": "en"
}
}
}Public audio / download / highlights
| Endpoint | Description |
|---|---|
GET /v1/playback/:playbackId/audio | Presigned audio download URL (if allowDownload) |
GET /v1/playback/:playbackId/download | Presigned source download URL (if allowDownload) |
GET /v1/playback/:playbackId/highlights | Ready AI highlight clips |
GET /v1/playback/:playbackId/comments | Public comments (timestamped) |
POST /v1/playback/:playbackId/comments | Post a comment |
GET/POST /v1/playback/:playbackId/reactions | Emoji reactions |
Analytics
| Endpoint | Method | Description |
|---|---|---|
/v1/analytics/events | POST | Player event ingestion (heartbeat, view_start, seek, …) |
/v1/assets/:id/analytics | GET | Per-asset analytics (views, watch time, retention, heatmap, quality) |
/v1/assets/:id/live-count | GET | Live viewer count (Redis sorted set) |
/v1/assets/:id/live-stream | GET | SSE live viewer count stream |
/v1/analytics/overview | GET | Org-wide analytics overview |
Orgs, Webhooks, Settings, Admin & more
| Endpoint | Description |
|---|---|
/v1/orgs | List / create organizations |
/v1/orgs/:orgId | Get / patch org (name, webhook URL) |
/v1/orgs/:orgId/usage | Usage vs tier limits |
/v1/orgs/:orgId/api-keys | Create / list / delete API keys |
/v1/orgs/:orgId/members | List / invite / update role / remove members |
/v1/orgs/:id/webhooks/config | Get / patch webhook config |
/v1/orgs/:id/webhooks/config/test | Send test event |
/v1/orgs/:id/webhooks/events | Delivery log |
/v1/orgs/:id/webhooks/events/:eventId/retry | Retry a failed delivery |
/v1/settings | Get / patch branding (primary color, theme, logo) |
/v1/settings/ai | Get / patch AI provider config |
/v1/settings/ai/test | Test AI connection |
/v1/settings/public | Public branding (for player) |
/v1/backups | List DB backups; /v1/backups/run trigger; download URL |
/v1/stats/fleet, /v1/stats/workers, /v1/stats/ai | Superadmin fleet stats |
/v1/diagnostics | DB/R2 connectivity + worker status |
/v1/diagnostics/workers, /v1/diagnostics/workers/:id/start | Worker machine status / force-start |
/v1/admin/* | Superadmin: orgs, org assets, asset errors, webhook failures, tier/suspend/impersonate |
| `/v1/billing/checkout | portal |
/v1/ai/whisper-callback | POST — HMAC-signed Modal transcription callback (public, signed) |
Embeddable Player
The standalone player app serves embeds at:
https://player.strum-vod.dev/embed/:playbackId
https://player.strum-vod.dev/watch/:playbackIdEmbed in any page:
<iframe
src="https://player.strum-vod.dev/embed/p1b2c3d4e5f6g7h8"
width="100%"
height="450"
frameborder="0"
allowfullscreen>
</iframe>Query parameters supported by the embed URL:
| Parameter | Example | Description |
|---|---|---|
color | ?color=%236366f1 | Accent color override (6-digit hex) |
title | ?title=My+Video | Title overlay override (max 200 chars) |
Complete Upload & Transcode Workflow
# 0. Login / signup to get a token
TOKEN=$(curl -s -X POST http://localhost:13002/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}' | jq -r '.data.token')
# 1. Create an asset
ASSET=$(curl -s -X POST http://localhost:13002/v1/assets \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"title": "Demo Video"}')
ASSET_ID=$(echo $ASSET | jq -r '.data.id')
# 2. Get a signed upload URL
UPLOAD=$(curl -s -X POST http://localhost:13002/v1/assets/$ASSET_ID/upload-url \
-H "Authorization: Bearer $TOKEN")
UPLOAD_URL=$(echo $UPLOAD | jq -r '.data.uploadUrl')
# 3. Upload the video
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @my-video.mp4
# 4. Confirm upload
curl -s -X POST http://localhost:13002/v1/assets/$ASSET_ID/upload-complete \
-H "Authorization: Bearer $TOKEN"
# 5. Start transcoding
curl -s -X POST http://localhost:13002/v1/assets/$ASSET_ID/process \
-H "Authorization: Bearer $TOKEN"
# 6. Poll until ready
while true; do
STATUS=$(curl -s http://localhost:13002/v1/assets/$ASSET_ID \
-H "Authorization: Bearer $TOKEN" | jq -r '.data.status')
echo "Status: $STATUS"
[ "$STATUS" = "ready" ] && break
sleep 5
done
# 7. Get playback info
curl -s http://localhost:13002/v1/assets/$ASSET_ID/playback \
-H "Authorization: Bearer $TOKEN" | jqAsset Lifecycle
created ──► uploaded ──► queued ──► processing ──► ready
│
└──► error| State | Description |
|---|---|
created | Asset record exists, no source file yet |
uploaded | Source file confirmed in storage (presigned, TUS, direct, or URL import) |
queued | Transcode job submitted to BullMQ queue |
processing | Go transcoder is actively transcoding |
ready | All renditions generated, playback available |
error | Transcoding failed (see errorMessage field) |
DELETE /v1/assets/:id is a hard delete (DB row + R2 objects). The deleted status constant exists only for the Go-side constant mirror; no current code path writes it.