Skip to main content
Developers

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 methods

Authentication

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. 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. 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. 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 rates

Connect a course platform

Instructors upload on your course site, and enrolled students watch there. Keep the NULX API key on your platform backend.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Configure Cloudflare R2 CORS

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