Build on the NULX API.
Upload video into your own storage, track it until playback is ready, and serve signed playback URLs — all over a tenant-scoped REST API.
Choose a link or site embed
Look up one video's public player link, protected share links and assigned site destinations before using a playback URL. Both read calls require assets:read.
curl https://nulx.dev/api/v1/assets/<asset_id>/distribution \
-H "X-API-Key: $NULX_API_KEY"
curl https://nulx.dev/api/v1/deliveries \
-H "X-API-Key: $NULX_API_KEY"
Create or edit a site delivery in the owner dashboard. Its ?delivery=slug embed scopes the videos, iframe domains, language and stats; it does not deploy a website. Pausing the site makes its embed return 410.
Public links are stable. Protected share links can have a PIN and expiry. Signed embeds need a server-issued playback token that expires; do not save one as a permanent embed. Domain allowlists restrict iframe placement, not access to a copied URL.
For an external player that only accepts HLS, the account owner can manage an external HLS link in Video → Share → Connect to an external platform. Its playback address is visible to the receiving platform and viewers.
Compare sharing methodsAuthentication
One header, scoped keys.
Send your key on /api/v1 requests. oEmbed needs no key. Keys are created in the dashboard and can carry only the scopes you need.
Example request
curl https://nulx.dev/api/v1/assets \
-H "X-API-Key: $NULX_API_KEY"
Available scopes
| assets:read | List and read video assets, collections, and analytics. |
| assets:write | Create upload sessions, register assets, and edit metadata. |
| playback:read | Fetch playback URLs and signed playback tokens. |
| billing:write | Read billing estimates for your tenant. |
| api_keys:read | List existing API keys. |
| api_keys:write | Create and revoke API keys. |
| webhooks:read | List webhook endpoints. |
| webhooks:write | Create, pause, and delete webhook endpoints. |
API keys are available on paid plans. Free-plan accounts see the key screen but cannot issue keys.
Keep keys on your server. Never put an API key in browser JavaScript or a mobile bundle — a browser may play the short-lived URLs your backend returns, but it must not mint them.
Quickstart
Upload to playback in three calls.
-
1. Initiate the upload
Ask for a presigned PUT URL. The response includes upload_url, upload_headers, and the upload id.
curl -X POST https://nulx.dev/api/v1/uploads/initiate \ -H "X-API-Key: $NULX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename":"launch.mp4","bytes_expected":73400320,"playback_policy":"public"}' -
2. PUT the file, then complete
Send the raw bytes to upload_url with the returned headers, then mark the upload complete to enqueue transcoding.
curl -X PUT "<upload_url>" \ -H "Content-Type: video/mp4" \ --upload-file launch.mp4 curl -X POST https://nulx.dev/api/v1/uploads/<id>/complete \ -H "X-API-Key: $NULX_API_KEY" -
3. Fetch playback when ready
Poll the asset until playback_status is ready, or register a webhook for video.asset.ready. Then request a playback token.
curl https://nulx.dev/api/v1/playback/<asset_id> \ -H "X-API-Key: $NULX_API_KEY"
The example above uses public playback. For lessons restricted to enrolled students, follow the protected playback setup: Connect a course platform
Automatic captions, editing and translation API
Add processing_options when initiating an upload. Transfer the actual file and complete the upload as shown above. Automatic captions are off by default; source_language accepts auto or a language code such as ko or en.
{"filename":"lecture.mp4","bytes_expected":73400320,"content_type":"video/mp4","processing_options":{"auto_captions":true,"source_language":"auto","duration_seconds":600}}
On plans that include AI credits, duration_seconds (the video length) is required with auto_captions so the credits can be reserved before the work starts; the dashboard sends it automatically.
Once video processing completes, list subtitles to obtain a track ID. GET the WebVTT content, PATCH text and timing, then translate that track into another language. Reads require assets:read; writes require assets:write.
curl https://nulx.dev/api/v1/assets/<asset_id>/subtitles \
-H "X-API-Key: $NULX_API_KEY"
curl https://nulx.dev/api/v1/assets/<asset_id>/subtitles/<track_id> \
-H "X-API-Key: $NULX_API_KEY"
curl -X PATCH https://nulx.dev/api/v1/assets/<asset_id>/subtitles/<track_id> \
-H "X-API-Key: $NULX_API_KEY" -H "Content-Type: application/json" \
-d '{"content":"WEBVTT\n\n00:00:00.000 --> 00:00:03.000\nHello, everyone.\n"}'
curl -X POST https://nulx.dev/api/v1/assets/<asset_id>/subtitles/<track_id>/translate \
-H "X-API-Key: $NULX_API_KEY" -H "Content-Type: application/json" \
-d '{"target_language":"ko","label":"한국어"}'
Translation returns 202 with the target track. Check its translation_state (completed/failed) and progress in the list. Duplicate requests, edits and deletion of an active target return 409; invalid captions return 422; another account's captions return 404. Translation reuses caption text without transcribing audio again.
View caption and translation usage ratesConnect a course platform
Instructors upload on your course site, and enrolled students watch there. Keep the NULX API key on your platform backend.
-
Check the instructor's access
After checking instructor permissions, your backend starts the upload with an assets:write key. Use initiate for a single PUT, or initiate_multipart for large or resumable files on either R2 path. Pass storage_connection_id for a customer R2 connection. Set playback_policy to signed for paid lessons.
-
Transfer the file to R2
For default R2 storage, have your platform backend send the file to the short-lived upload URL. With a customer R2 connection, browser uploads need CORS that permits your course site's origin, PUT, and the upload request headers, and exposes the ETag response header. Call the NULX completion API afterward to start transcoding.
-
Add the ready video to a lesson
Wait for video.asset.ready or check the asset status, then associate the NULX asset ID with the instructor's lesson. Enforce instructor ownership in your platform.
-
Issue playback for enrolled students
On each playback request, your backend checks enrollment before using a separate playback:read key and returning embed_url only to the authorized learner. The token lasts 15 minutes by default; reissue the URL server-side and reopen the player when it expires. Put the previous watch time in the new embed_url's t query parameter to resume. Never expose an API key or original download URL to the browser.
{"filename":"lecture.mp4","bytes_expected":73400320,"playback_policy":"signed"}
Show upload and transcoding status
Measure file transfer progress in the instructor's browser. NULX sends video.upload.completed/failed, video.job.queued/started/failed, and video.asset.ready/errored webhooks as the state changes. A job failure may be retried; an asset error means this processing run has no playable output. While transcoding, your backend can poll the numeric asset_id from the upload response with an assets:read key for the current job status and progress. Progress is worker-reported and can drop when a job retries.
GET /api/v1/assets/<asset_id>
{"playback_status":"processing","processing_stage":"transcoding","transcoding_job":{"status":"running","progress_percent":42}}
Upload to R2 with the JavaScript SDK
The /lessons/42/... paths below are example routes to implement on your course backend. The SDK sends the file only to the short-lived upload URL returned by that backend; no NULX API key belongs in browser code.
<script src="https://nulx.dev/player/nulx-player-dd7597fd0f3742ce.js?locale=en"
integrity="sha384-9sVWZ35V6gt1rYswpaB09YVGjGfA3BOvHgdZ7CLbDFy3fMEKJB3VNmRhw2319SCU" crossorigin="anonymous" async></script>
<input id="lesson-file" type="file" accept="video/*">
<p id="upload-status" role="status"></p>
<script type="module">
await customElements.whenDefined("nulx-player")
const post = async (path, body) => {
const headers = { "Content-Type": "application/json" }
const csrf = document.querySelector('meta[name="csrf-token"]')?.content
if (csrf) headers["X-CSRF-Token"] = csrf
const response = await fetch(path, {
method: "POST", credentials: "same-origin", headers, body: JSON.stringify(body)
})
if (!response.ok) {
const error = new Error("Request failed: " + response.status)
error.status = response.status
throw error
}
return response.json()
}
document.querySelector("#lesson-file").addEventListener("change", async (event) => {
const file = event.target.files[0]
if (!file) return
const status = document.querySelector("#upload-status")
try {
await NulxSDK.uploadMultipart(file, {
create: (file) => post("/lessons/42/uploads", { filename: file.name, bytes_expected: file.size, content_type: file.type || "application/octet-stream" }),
signPart: ({ uploadId, partNumber, contentLength }) => post(`/lessons/42/uploads/${uploadId}/parts`, { part_number: partNumber, content_length: contentLength }),
complete: ({ uploadId, bytesReceived, parts }) => post(`/lessons/42/uploads/${uploadId}/complete`, { bytes_received: bytesReceived, parts }),
onProgress: ({ percent }) => { status.textContent = percent + "%" }
})
status.textContent = "Upload finished. Video processing has started."
} catch (_error) {
status.textContent = "Could not upload the video. Try again."
}
})
</script>
Your course backend must check login, instructor ownership, and CSRF. It calls NULX initiate_multipart with a server-enforced signed policy and Idempotency-Key, then proxies sign_part and complete_multipart, returning only needed fields to the browser. The SDK supports R2 s3_multipart uploads. R2 CORS must allow PUT from your course site and expose ETag. The short-lived upload URL is visible in the browser, so do not log or share it. After a failed upload, resume the same session on your backend or clean it up with abort_multipart.
Receive learner player state
The SDK comes in the version-pinned, integrity-checked script above. Older copied player snippets may not contain it. Request playback through your course site's login session. Call lesson.renew() before the token expires to resume at the last reported position.
<div id="lesson-player"></div>
<p id="lesson-status" role="status"></p>
<script type="module">
await customElements.whenDefined("nulx-player")
const host = document.querySelector("#lesson-player")
const status = document.querySelector("#lesson-status")
host.addEventListener("nulx:state", ({ detail }) => {
// detail.state: idle, loading, ready, buffering, playing, paused, ended, error
status.textContent = {
loading: "Loading video…",
buffering: "Getting playback ready…",
error: "Could not play this video. Try again."
}[detail.state] || ""
})
host.addEventListener("nulx:progress", ({ detail }) => {
// detail.currentTime and detail.duration are seconds
})
const lesson = NulxSDK.player(host, async () => {
const response = await fetch("/lessons/42/playback", { credentials: "same-origin" })
if (!response.ok) throw new Error("Playback denied: " + response.status)
return response.json() // { asset_id, playback_policy: "signed", embed_url }
})
try { await lesson.load() }
catch (_error) { status.textContent = "Could not play this video. Try again." }
// Refresh from your backend before the token expires; watch position is kept.
// await lesson.renew()
</script>
For every request, your course backend checks enrollment and uses a separate playback:read key to ask NULX for playback. Return only asset_id, signed playback_policy, and the short-lived embed_url to the browser. The SDK rejects public or foreign-origin embeds, but cannot prevent an authorized viewer from copying a short-lived URL or recording the screen.
Signed playback needs an eligible plan and protected delivery configured for the storage connection. A public source bucket can bypass playback protection. Add your course site's domain to the embed allowlist.
The domain allowlist stops other sites from framing the player. A short-lived media URL delivered to an authorized browser can still be copied until it expires.
The NULX player has no download button, but playable video can still be screen-recorded or saved from network traffic. An assets:write key can also request an original-source download URL; never expose it outside your backend.
Give external players a per-viewer address
For another company's web player (hls.js, Video.js and the like) or its own app playing NULX videos directly. Each time a signed-in viewer presses play, the platform's server asks for a new address with a playback:read key and hands it to that viewer's player only. Private and protected videos only.
- It plays only where it was first played (the same internet connection). Opened anywhere else, it is blocked.
- It works for the video's length plus 30 minutes (at least 2 hours, at most 24).
- It plays in other companies' website players and in their own apps.
- If the viewer's connection changes (Wi-Fi to mobile data), get a new address.
POST /api/v1/playback/<asset_id>/grants
X-API-Key: <playback:read key>
{"viewer":"student-1024"}
201 {"grant_id":"0123456789abcdef","hls_url":"https://cdn.nulx.dev/_nulx_edge/grant-…/master.m3u8",
"expires_at":"…","ttl_seconds":7200,"binding":"first_network","viewer":"student-1024"}
DELETE /api/v1/playback/<asset_id>/grants/<grant_id>
Public videos are refused with 422 public_video, videos on your own connected storage with 422 storage_unsupported. Played anywhere else, media requests get 403; get a new address then. Trashing the video, making it unplayable or deleting the key stops its addresses at once.
oEmbed API
Turn a public video URL into JSON containing its title, thumbnail and iframe code. No API key is required.
oEmbed is available only for ready, public videos. Private, signed and scheduled videos, and protected share links are not supported.
Example request
curl --get https://nulx.dev/oembed \
--data-urlencode "url=https://nulx.dev/watch/<external_id>" \
--data-urlencode "format=json" \
--data-urlencode "maxwidth=640"
Replace <external_id> with the video's external ID. Copy the request URL from video details → Share → Embed this video on a website → Show developer integrations.
Query parameters
- url
- Required. A /watch/<external_id> or /embed/<external_id> URL on the same host. URL-encode this query value. Protected /s/ links and HLS URLs are not supported.
- format
- Optional. Only json is supported; JSON is also the default.
- maxwidth
- Optional. Default width is 640px. Positive integers are clamped to 80–1920px; height is calculated at 16:9.
- maxheight
- Optional. Default height is 360px. Positive integers are clamped to 80–1080px; if smaller than the current height, width is reduced proportionally.
Example success response
{
"version": "1.0",
"type": "video",
"provider_name": "NULX",
"provider_url": "https://nulx.dev/",
"title": "...",
"author_name": "...",
"width": 640,
"height": 360,
"html": "<iframe src=\"https://nulx.dev/embed/<external_id>\" width=\"640\" height=\"360\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture; encrypted-media\" allowfullscreen title=\"...\"></iframe>",
"thumbnail_url": "https://nulx.dev/watch/<external_id>/poster"
}
thumbnail_url is returned only when a poster is configured. Public watch and embed pages advertise an application/json+oembed discovery link. Automatic embedding depends on the consuming service.
Error responses have empty bodies. 404: missing URL, invalid host/path, unknown, private, signed, processing or scheduled video. 501: a format other than JSON. 429: more than 60 requests per minute per IP. Embed domain restrictions still apply.
Reference
Every public endpoint.
Generated from the same OpenAPI document served below — it cannot drift.
Tenants
| GET | /api/v1/tenants | List tenants visible to the caller |
Video Assets
| GET | /api/v1/assets | List video assets for the caller's tenant |
| POST | /api/v1/assets | Create a video asset directly (out-of-band ingestion) |
| GET | /api/v1/assets/export | Export asset metadata (CSV or JSON) |
| GET | /api/v1/assets/graph_export | No-lock-in asset graph export (JSON) |
| GET | /api/v1/assets/duplicates | Duplicate detection lookup (desktop uploader 중복 알림) |
| POST | /api/v1/assets/import | Bulk import asset metadata from CSV |
| GET | /api/v1/assets/{id} | Fetch a single video asset |
| PATCH | /api/v1/assets/{id} | Update a video asset's title, description, or collection |
| PUT | /api/v1/assets/{id} | Update a video asset's title, description, or collection (PUT alias) |
| PATCH | /api/v1/assets/{id}/tags | Add or update schemaless tags on an asset |
| DELETE | /api/v1/assets/{id}/tags/{key} | Remove a tag from an asset |
| GET | /api/v1/assets/trash | List videos in the trash |
| POST | /api/v1/assets/{id}/trash | Move a video to the trash |
| POST | /api/v1/assets/{id}/restore | Restore a video from the trash |
| POST | /api/v1/assets/{id}/retry | Retry encoding after a failure |
| PATCH | /api/v1/assets/{id}/playback_policy | Change who can play a video |
| GET | /api/v1/assets/{id}/distribution | Choose a sharing or embed method for one video |
| GET | /api/v1/assets/{video_asset_id}/share_links | List live share links for a video |
| POST | /api/v1/assets/{video_asset_id}/share_links | Create a share link |
| DELETE | /api/v1/assets/{video_asset_id}/share_links/{id} | Stop a share link |
| GET | /api/v1/assets/{id}/download | Get a short-lived signed download URL for the original source |
| GET | /api/v1/assets/{id}/deovr_manifest.json | Get a DeoVR/HereSphere deep-link manifest for a publicly playable asset |
| GET | /api/v1/analytics | Tenant-wide viewer analytics summary |
| GET | /api/v1/assets/{id}/analytics | Per-asset viewer analytics summary |
| GET | /api/v1/deliveries | List the tenant's site embed destinations |
Collections
| PATCH | /api/v1/collections/{id}/playback_policy | Protect a folder and every video in it |
| GET | /api/v1/collections | List collections (folders) for the caller's tenant |
| POST | /api/v1/collections | Create a collection (folder) for organizing assets |
| GET | /api/v1/collections/tree | Nested collection tree for the tenant |
| PATCH | /api/v1/collections/{id} | Update a collection |
| PUT | /api/v1/collections/{id} | Update a collection (PUT alias) |
| DELETE | /api/v1/collections/{id} | Delete a collection |
Subtitles
| POST | /api/v1/assets/{video_asset_id}/subtitle_requests | Queue caption generation or translation with a durable idempotent receipt |
| GET | /api/v1/assets/{video_asset_id}/subtitle_requests | Recover a request by client idempotency key |
| GET | /api/v1/assets/{video_asset_id}/subtitle_requests/{id} | Poll a subtitle processing attempt without reading caption storage |
| GET | /api/v1/assets/{video_asset_id}/subtitles | List subtitle/caption tracks for a video asset |
| POST | /api/v1/assets/{video_asset_id}/subtitles | Upload a subtitle/caption track for a video asset |
| GET | /api/v1/assets/{video_asset_id}/subtitles/{id} | Read a subtitle track and its editable WebVTT content |
| PATCH | /api/v1/assets/{video_asset_id}/subtitles/{id} | Update a subtitle/caption track |
| PUT | /api/v1/assets/{video_asset_id}/subtitles/{id} | Update a subtitle/caption track (PUT alias) |
| DELETE | /api/v1/assets/{video_asset_id}/subtitles/{id} | Remove a subtitle/caption track |
| POST | /api/v1/assets/{video_asset_id}/subtitles/{id}/sync | Re-time a subtitle/caption track against a reference track |
| POST | /api/v1/assets/{video_asset_id}/subtitles/{id}/translate | Create a new language track by machine-translating an existing one |
Tracks
| GET | /api/v1/assets/{video_asset_id}/tracks | List timecode-anchored tracks for a video asset |
| POST | /api/v1/assets/{video_asset_id}/tracks | Create or replace an asset track (idempotent upsert) |
| PUT | /api/v1/assets/{video_asset_id}/tracks | Create or replace an asset track (idempotent upsert, PUT alias) |
| GET | /api/v1/assets/{video_asset_id}/tracks/{id} | Show a single asset track |
| PATCH | /api/v1/assets/{video_asset_id}/tracks/{id} | Update an asset track by id |
| PUT | /api/v1/assets/{video_asset_id}/tracks/{id} | Update an asset track by id (PUT alias) |
| DELETE | /api/v1/assets/{video_asset_id}/tracks/{id} | Remove an asset track |
Uploads
| POST | /api/v1/mcp_upload_requests | Save a recoverable upload intent and attempt multipart admission |
| GET | /api/v1/mcp_upload_requests/{id} | Read a saved upload request and actual source-upload state |
| POST | /api/v1/mcp_upload_requests/{id}/resume | Retry admission for the original saved upload intent |
| POST | /api/v1/uploads/initiate | Create a tenant-scoped direct upload session |
| POST | /api/v1/uploads/initiate_multipart | Initiate a resumable multipart source upload |
| POST | /api/v1/uploads/{id}/sign_part | Presign a single S3 multipart part PUT |
| POST | /api/v1/uploads/{id}/complete_multipart | Finalize a multipart/resumable upload |
| POST | /api/v1/uploads/{id}/abort_multipart | Abort a multipart/resumable upload |
| GET | /api/v1/uploads/{id} | Fetch an upload session's status |
| POST | /api/v1/uploads/{id}/complete | Mark an upload complete and enqueue transcoding |
Storage Connections
| GET | /api/v1/storage_connections | List the caller's storage connections without credentials |
| POST | /api/v1/storage_connections/{id}/check | Run a scoped read-only provider check |
| GET | /api/v1/storage_connections/cloudflare_options | Discover the owner's approved Cloudflare accounts and zones |
| POST | /api/v1/storage_connections/setup_r2 | Provision dedicated protected R2 storage after explicit owner consent |
API Keys
| GET | /api/v1/api_keys | List API keys for the caller's tenant |
| POST | /api/v1/api_keys | Issue a new API key for a tenant |
| DELETE | /api/v1/api_keys/{id} | Revoke an API key |
Device Tokens
| POST | /api/v1/device_tokens | Redeem a desktop pairing code for a device token |
| DELETE | /api/v1/device_tokens/current | Revoke the API key used for this request |
Billing
| GET | /api/v1/billing/credits | Read the account's NULX AI credit wallet and fixed purchase pack |
| POST | /api/v1/billing/credits/checkout | Create or replay a hosted checkout for 1000 NULX AI credits |
| GET | /api/v1/billing/credits/purchases | Recover an AI credit purchase by idempotency key |
| GET | /api/v1/billing/credits/purchases/{id} | Read a purchase belonging to the authenticated account |
| GET | /api/v1/billing/estimate | Estimate the current billing period charges |
Usage
| GET | /api/v1/setup/status | Nonsecret current account setup and protection counts |
| GET | /api/v1/usage/plan | This month's encoding use and when anything is charged beyond the monthly fee |
| GET | /api/v1/usage/summary | Current-month usage and billing estimate per tenant |
Encoding
| GET | /api/v1/encoding_spec | Canonical transcoding encoding specification |
Playback
| GET | /oembed | Get embed information for a public video |
| GET | /api/v1/assets/{id}/deovr_manifest.json | Get a DeoVR/HereSphere deep-link manifest for a publicly playable asset |
| GET | /api/v1/playback/{id} | Get playback URLs and a signed playback token for an asset |
| POST | /api/v1/playback/{id}/grants | Issue a per-viewer HLS address for an external player (web or app) |
| DELETE | /api/v1/playback/{id}/grants/{grant_id} | Stop a per-viewer address |
| POST | /api/v1/playback_events | Ingest a viewer playback beacon (public, unauthenticated) |
Ingest Batches
| GET | /api/v1/ingest_batches/desktop_storage | Resolve the server-managed desktop upload storage |
| POST | /api/v1/ingest_batches | Create a storage ingest batch lease |
| GET | /api/v1/ingest_batches/{id} | Fetch ingest batch status |
| POST | /api/v1/ingest_batches/{id}/manifest/preview | Validate an ingest manifest without mutation |
| POST | /api/v1/ingest_batches/{id}/manifest/commit | Commit a client encoder manifest to assets |
| POST | /api/v1/ingest_batches/{id}/cancel | Cancel an uncommitted ingest batch |
| POST | /api/v1/ingest_batches/{id}/uploads | Create a server-managed desktop object upload |
| GET | /api/v1/ingest_batches/{id}/uploads | List upload intents recorded for a desktop batch (resume/이어올리기) |
| PUT | /api/v1/ingest_batches/{id}/uploads/{upload_id}/proxy | Stream a small object through the control plane to Bunny |
| PUT | /api/v1/ingest_batches/{id}/uploads/{upload_id}/proxy_parts/{part_number} | Stage one part of a chunked Bunny server-proxy upload |
| POST | /api/v1/ingest_batches/{id}/uploads/{upload_id}/proxy_complete | Assemble and commit a chunked Bunny server-proxy upload |
| POST | /api/v1/ingest_batches/{id}/uploads/{upload_id}/parts | Sign one exact R2 multipart part |
| POST | /api/v1/ingest_batches/{id}/uploads/{upload_id}/parts/{part_number}/complete | Durably record one confirmed multipart part etag (resume/이어올리기) |
| POST | /api/v1/ingest_batches/{id}/uploads/{upload_id}/complete | Complete an R2 multipart upload |
| DELETE | /api/v1/ingest_batches/{id}/uploads/{upload_id} | Abort an active R2 multipart upload |
Webhooks
| GET | /api/v1/webhooks | List webhook endpoints for the caller's tenant(s) |
| POST | /api/v1/webhooks | Register a new webhook endpoint for a tenant |
| DELETE | /api/v1/webhooks/{id} | Delete a webhook endpoint |
| PATCH | /api/v1/webhooks/{id}/active | Activate or deactivate a webhook endpoint |
AI workflows
| POST | /api/v1/source_imports | Import a public HTTPS video |
| GET | /api/v1/source_imports/{id} | Read URL import state |
| POST | /api/v1/source_imports/{id}/resume | Resume a retryable source import |
| POST | /api/v1/assets/{video_asset_id}/caption_workflows | Generate captions then translations automatically |
| GET | /api/v1/assets/{video_asset_id}/caption_workflows/{id} | getCaptionWorkflow |
| POST | /api/v1/assets/{video_asset_id}/caption_workflows/{id}/resume | resumeCaptionWorkflow |
Errors
REST API error responses
/api/v1 failures return a stable error and code pair — match on code, not on the message.
Shape
{
"error": "insufficient_scope",
"code": "forbidden_scope",
"required_scopes": ["assets:write"]
}
Common codes
| unauthorized | Missing, invalid, or expired API key. |
| forbidden_scope | The key is valid but lacks the required scope. |
| ip_not_allowed | The key is valid but was presented from an address outside its allowlist; the response names the observed_ip. |
| parameter_missing | A required parameter is absent or invalid. |
| playback_token_required | The asset uses signed playback — request a token first. |
| rate_limited | Too many requests; back off and retry. |
| not_found | No such resource in your tenant. |
| internal_server_error | Something broke on our side — retry or contact support. |
Webhooks
Subscribe instead of polling.
Register an HTTPS endpoint from the dashboard or the API and NULX posts lifecycle events as signed JSON. 5xx, 429, and transport errors are retried with backoff; other 4xx/3xx responses are not retried.
Delivery format
POST https://your-server.example/hooks/nulx
NULX-Event: video.asset.ready
NULX-Event-Id: <event uuid>
NULX-Delivery-Id: <delivery uuid>
NULX-Timestamp: 1758432000
NULX-Signature: t=1758432000,v1=<hex hmac-sha256>
{
"id": "<event uuid>",
"event": "video.asset.ready",
"created_at": "2026-09-21T04:00:00Z",
"data": { "asset_id": "…", "title": "…", "playback_status": "ready", "playback_url": "…" }
}
Signature verification
Compute hex HMAC-SHA256 of "<timestamp>.<raw body>" with the secret shown once at endpoint creation, and compare it to v1. Reject timestamps older than 5 minutes to stop replays.
expected = "t=" + timestamp + ",v1=" + hex_hmac_sha256(secret, timestamp + "." + raw_body)
Events you can subscribe to
| video.asset.ready |
| video.asset.errored |
| tenant.traffic.anomaly |
| video.upload.completed |
| video.upload.failed |
| video.job.queued |
| video.job.started |
| video.job.failed |
| video.subtitle.ready |
| video.subtitle.failed |
| video.asset.deleted |
| video.track.ready |
OpenAPI
Take the whole contract.
The full OpenAPI 3.0 document for the public surface — import it into Swagger UI, Postman, or a client generator.
Questions about the API? Contact us