Skip to content

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 /reference when running the API with NODE_ENV !== 'production' (disabled in production for security). The generated openapi.json documents all ~67 paths.

Response Format

All successful responses wrap the payload in a data key:

json
{ "data": { ... } }

All error responses return an error string:

json
{ "error": "Error message" }

HTTP Status Codes

CodeMeaning
200Success
201Resource created
400Validation error (missing or invalid fields)
401Unauthenticated
403Forbidden (plan limit, disabled registration, etc.)
404Resource not found
409Conflict (e.g. asset already has a source)
501Feature not configured (e.g. TUS upload without SHARED_AUTH_SECRET)
500Internal 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/assets

Creates a new asset in created state with a unique playback ID. Requires auth.

Request Body

FieldTypeRequiredDescription
titlestringYesName of the video (min 1 character)
metadataobjectNoCustom metadata (max 10 entries, 255 chars each)

Response 201

json
{ "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/:id

Returns a single asset with renditions, active job step, latest job metadata, AI job status, and highlight clips.

Response 200

json
{
  "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/:id

Partially 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-url

Generates 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-token

Issues 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

FieldTypeRequiredDescription
fileSizenumberYesFile size in bytes (max 10 GB)

Response 200{ "data": { "token", "sourceKey" } }

Errors409 asset already has a source · 501 SHARED_AUTH_SECRET not configured

Using the token with tus-js-client

ts
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/upload

Streams 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-complete

Verifies 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/import

Sets 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/process

Creates a transcode job and pushes it to BullMQ. Asset moves to queued, then processing, then ready or error.

Request Body (optional)

FieldTypeDescription
aiOptions.transcriptionbooleanEnable AI transcription (default true)
aiOptions.subtitlesbooleanGenerate WebVTT/SRT subtitles (default true)
aiOptions.chaptersbooleanGenerate AI chapters (default true)
aiOptions.highlightsbooleanGenerate AI highlight clips (default true)

Response 200{ "data": { "assetId", "jobId", "status": "queued" } }

Delete Asset

DELETE /v1/assets/:id

Hard 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

EndpointMethodDescription
/v1/assets/:id/thumbnailPUTUpload custom thumbnail (JPG/PNG/WebP, 10 MB)
/v1/assets/:id/thumbnailDELETEReset to auto-generated frame
/v1/assets/:id/download?quality=1080pGETPresigned download URL (source or rendition MP4)
/v1/assets/:id/audioGETPresigned playback audio download URL
/v1/assets/:id/ai-audioGETPresigned AI audio download URL
/v1/assets/:id/audio-configPATCHUpdate playback/AI audio extraction config
/v1/assets/:id/transcriptPATCHReplace transcript + regenerate VTT/SRT
/v1/assets/:id/chaptersPATCHReplace chapters
/v1/assets/:id/highlights/:highlightIdPATCHUpdate highlight title/description
/v1/assets/:id/highlights/:highlightIdDELETEDelete a highlight clip

Collections

List Collections

GET /v1/collections

Lists all collections (folders) for the org with a video count each. Response 200:

json
{ "data": [ { "id": "...", "name": "Tutorials", "color": "#4f46e5", "videoCount": 3, "createdAt": "...", "updatedAt": "..." } ] }

Create Collection

POST /v1/collections

Body: { "name": string (1–255), "color": one of the COLLECTION_COLORS palette }

Response 201{ "data": { "id", "name", "color", "videoCount": 0 } }

Update Collection

PATCH /v1/collections/:id

Partially updates name and/or color. Response 200 · 404 if not found or not in your org.

Delete Collection

DELETE /v1/collections/:id

Deletes 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/:playbackId

Public 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

json
{
  "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

EndpointDescription
GET /v1/playback/:playbackId/audioPresigned audio download URL (if allowDownload)
GET /v1/playback/:playbackId/downloadPresigned source download URL (if allowDownload)
GET /v1/playback/:playbackId/highlightsReady AI highlight clips
GET /v1/playback/:playbackId/commentsPublic comments (timestamped)
POST /v1/playback/:playbackId/commentsPost a comment
GET/POST /v1/playback/:playbackId/reactionsEmoji reactions

Analytics

EndpointMethodDescription
/v1/analytics/eventsPOSTPlayer event ingestion (heartbeat, view_start, seek, …)
/v1/assets/:id/analyticsGETPer-asset analytics (views, watch time, retention, heatmap, quality)
/v1/assets/:id/live-countGETLive viewer count (Redis sorted set)
/v1/assets/:id/live-streamGETSSE live viewer count stream
/v1/analytics/overviewGETOrg-wide analytics overview

Orgs, Webhooks, Settings, Admin & more

EndpointDescription
/v1/orgsList / create organizations
/v1/orgs/:orgIdGet / patch org (name, webhook URL)
/v1/orgs/:orgId/usageUsage vs tier limits
/v1/orgs/:orgId/api-keysCreate / list / delete API keys
/v1/orgs/:orgId/membersList / invite / update role / remove members
/v1/orgs/:id/webhooks/configGet / patch webhook config
/v1/orgs/:id/webhooks/config/testSend test event
/v1/orgs/:id/webhooks/eventsDelivery log
/v1/orgs/:id/webhooks/events/:eventId/retryRetry a failed delivery
/v1/settingsGet / patch branding (primary color, theme, logo)
/v1/settings/aiGet / patch AI provider config
/v1/settings/ai/testTest AI connection
/v1/settings/publicPublic branding (for player)
/v1/backupsList DB backups; /v1/backups/run trigger; download URL
/v1/stats/fleet, /v1/stats/workers, /v1/stats/aiSuperadmin fleet stats
/v1/diagnosticsDB/R2 connectivity + worker status
/v1/diagnostics/workers, /v1/diagnostics/workers/:id/startWorker machine status / force-start
/v1/admin/*Superadmin: orgs, org assets, asset errors, webhook failures, tier/suspend/impersonate
`/v1/billing/checkoutportal
/v1/ai/whisper-callbackPOST — 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/:playbackId

Embed in any page:

html
<iframe
  src="https://player.strum-vod.dev/embed/p1b2c3d4e5f6g7h8"
  width="100%"
  height="450"
  frameborder="0"
  allowfullscreen>
</iframe>

Query parameters supported by the embed URL:

ParameterExampleDescription
color?color=%236366f1Accent color override (6-digit hex)
title?title=My+VideoTitle overlay override (max 200 chars)

Complete Upload & Transcode Workflow

bash
# 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" | jq

Asset Lifecycle

created ──► uploaded ──► queued ──► processing ──► ready

                                                    └──► error
StateDescription
createdAsset record exists, no source file yet
uploadedSource file confirmed in storage (presigned, TUS, direct, or URL import)
queuedTranscode job submitted to BullMQ queue
processingGo transcoder is actively transcoding
readyAll renditions generated, playback available
errorTranscoding 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.

STRUM Proprietary License — © 2026 Strum. All rights reserved.