Sluby

API conventions

Base URL, authentication, scopes, errors, pagination, rate limits, ETags, CORS and size limits that apply across the Sluby REST API.

These rules apply to every endpoint of the Sluby REST API. Each endpoint's own fields and responses are in the REST API reference.

Base URL

The REST API lives under /api/v1 on your server's public URL. With the Docker Compose stack from Install Sluby, that is http://localhost:8080/api/v1. In production it is https://sluby.example.com/api/v1 for a server at sluby.example.com.

The API and playback paths are grouped by prefix. The server also answers /health and /metrics, described in Monitor and maintain.

Path prefixWhat it servesAuthentication
/api/v1/The REST API, including tus uploads at /api/v1/uploadsAPI key or Studio session, see Authentication
/v1/The delivery gateway: /v1/stream/{id}/master.m3u8, /v1/objects/{objectId}, /v1/share/{id}, /v1/cache/statsNone. Private videos use signed URLs (how playback works)
/watch/The public watch page, /watch/{id}None

A path under /api/v1 that no route serves answers 404 with {"error": "Route GET /api/v1/... not found"}, whether or not you send a key.

Authentication

Every /api/v1 route needs credentials, except these:

  • the sign-in routes under /api/v1/auth (some of them need the Studio session cookie, none of them accept an API key)
  • GET /api/v1/openapi.json, GET /api/v1/docs and the script files that page loads from /api/v1/docs/scalar/
  • tus OPTIONS requests to /api/v1/uploads

The server accepts two kinds of credentials: an API key in the Authorization header, and the Studio session cookie. When a request carries a live session cookie and a key, the session cookie wins.

API keys

Send the key as a bearer token:

Authorization: Bearer sluby_...
PropertyValue
FormatA key the server generates is sluby_ followed by 43 base64url characters (32 random bytes)
StorageThe server stores only the SHA-256 hash of the key, plus its first 12 characters (prefix) so you can tell keys apart
ShownOnce, in the response that creates or rotates the key
ScopesOne or more of upload, read, manage. A new key gets upload and read when you do not list any
Rate limitPer key, requests per minute. Default 100, see Rate limits
RoleA key acts with the current role of the member who created it, and stops working while that member is removed. A key created on the server (BOOTSTRAP_API_KEY or the seed script) has no creator and acts as an admin

The header must start with Bearer exactly. Create, rotate and revoke keys with the keys endpoints or in the Studio (Create and manage API keys). For what roles allow, see Accounts, roles and sessions.

Scopes

A scope check is exact: manage does not include read, and read does not include upload. Give a key every scope it needs. A request without the scope answers 403 with {"error": "Requires 'read' scope"} (naming the missing scope). A tus upload request without upload answers 403 with the plain-text body API key does not have upload scope.

A Studio session holds all three scopes.

Admin-only endpoints

Some endpoints also need the caller to act as an admin. Otherwise they answer 403 with {"error": "Only an admin may use this endpoint"}:

Two endpoints answer everyone with the scope but return less to a member: GET /api/v1/system/status returns the health summary only, and GET /api/v1/reconciliation returns the account-level report instead of the deployment-wide one.

The OpenAPI document marks each operation with x-required-scope and, for admin-only operations, x-required-identity: admin.

Signing in through the /api/v1/auth routes sets a session cookie. A browser on the same site sends it with every request, so the Studio needs no key.

PropertyValue
Namesluby_session
AttributesPath=/, HttpOnly, SameSite=Lax, and Secure when PUBLIC_URL starts with https://
LifetimeMax-Age set to the session's lifetime, see Sessions and cookies
Scopesupload, read, manage
RoleThe account's current role, checked on every request
Rate limit1000 requests per minute per account, see Rate limits

The server checks on every request that the session is still live and the account is not removed. GET /api/v1/auth/sessions, DELETE /api/v1/auth/sessions/{id}, DELETE /api/v1/auth/sessions, GET /api/v1/auth/me and POST /api/v1/auth/password/change work with the session cookie only. For how sessions start and end, see Accounts, roles and sessions.

When AUTH_DISABLED is on, every request acts as the local development account, which is an admin with all three scopes, whatever credentials it carries (AUTH_DISABLED).

Cross-origin writes

The server refuses a write that only ambient credentials (a cookie) could authorize. A request under /api answers 403 with {"error": "Cross-origin request refused"} when all of these hold:

  • the method is not GET, HEAD or OPTIONS
  • it has an Origin header
  • it has no Authorization header
  • the origin is not the origin of PUBLIC_URL, not the origin the request was addressed to, and not listed in CORS_ORIGIN

A request with an Authorization header, such as an API key, always passes this check.

Errors

Error body

Every REST error is JSON with an error string:

{
  "error": "title must be at most 200 characters",
  "details": [{ "field": "title", "message": "title must be at most 200 characters" }]
}
FieldWhen it is present
errorAlways. A message meant for people. For a validation error it is the first problem, prefixed with the field name when the message does not already name it. For an unexpected server failure it is "Internal Server Error"
detailsA 400 from validation: one entry per problem, with field (dotted path, empty for the whole input) and message. Some sign-in errors also use it to point at a form field
reasonA machine-readable code on some 401, 403, 409 and 410 answers, see Authentication failures
retry_afterA 429: seconds to wait, the same value as the Retry-After header
currentA 412 from PATCH /api/v1/assets/{id}: the asset as it is now, see ETags
message, stackOnly when the server runs with EXPOSE_ERROR_STACKS=true (EXPOSE_ERROR_STACKS)

The tus upload protocol requests answer errors with a plain-text body, as the tus protocol does. These are the HEAD, PATCH and OPTIONS requests to /api/v1/uploads, and any request there with a Tus-Resumable header.

Status codes

StatusMeaning in Sluby
400The body, query or a header is invalid. Also a malformed id, and malformed JSON
401No valid API key or session
403The caller lacks a scope or the admin role, a cross-origin write was refused, or a sign-in check failed (wrong setup code, removed account, wrong current password)
404No such resource in the workspace, or no such route
409The request conflicts with the current state, for example the key limit is reached, a key was already rotated, or the action would leave no admin
410An invite or reset link was used, expired or revoked
412If-Match does not match the asset's current version
413The JSON body is larger than 1 MiB, or a tus upload is larger than the server allows
429Over a rate limit or attempt limit
500An unexpected server failure
502An upstream did not answer, for example the Sia indexer while minting share URLs
503A dependency did not answer, for example the database while minting share URLs

Each endpoint page in the REST API reference lists the codes that endpoint returns. @sluby/sdk turns these into error classes.

Authentication failures

StatuserrorreasonCause
401Missing or invalid Authorization headermissingNo Authorization: Bearer header and no session cookie this server signed
401This session has expiredexpiredThe session cookie is past its expiry
401This session was signed outrevokedThe session was signed out, here or from another browser
401This account was removed from this workspacenot_allowedAn admin removed the account behind the session
401Invalid API keynoneNo key with this value exists (also a revoked key)
401API key is deactivatednoneThe key is marked inactive
401API key has expirednoneThe key was rotated and its grace period is over
401This API key's creator was removed from this workspacenot_allowedThe member who created the key was removed
403Requires '<scope>' scopenoneThe key lacks the scope
403Only an admin may use this endpointnoneThe caller is not an admin

The reason on a session 401 appears only when the request carries no Authorization: Bearer header. The sign-in routes and the member routes use more reason values (bad_credentials, disabled, bad_code, already_set_up, email_taken, no_password, used, last_admin, self); their endpoint pages list them.

Pagination

Two list endpoints page with a cursor:

EndpointParametersResponse fields
GET /api/v1/assetslimit (1 to 100, default 20), cursor, or page (1 to 1000000, default 1)data, next_cursor, has_more, total, page, limit
GET /api/v1/keyslimit (1 to 1000, default 1000), cursordata, next_cursor, has_more, limits

To page forward, pass the next_cursor of one response as cursor in the next request, with the same filters. has_more is false and next_cursor is null on the last page. Rows inserted or deleted between requests do not shift a cursor, because the cursor encodes the position of the last row returned (its creation time and id), not an offset.

Rules for GET /api/v1/assets:

  • Send either cursor or page, not both. A request with both answers 400 with Use either cursor or page, not both.
  • A cursor works only with the default order (sort=created_at, order=desc). With another order, use page: a cursor answers 400 with cursor paging only supports the default order; use page instead.
  • With a cursor, page in the response is null.

A cursor the server cannot decode answers 400 with cursor is not valid; start again without it. Treat a cursor as opaque.

GET /api/v1/webhooks/{id}/deliveries takes only a limit (1 to 100, default 50) and returns the newest deliveries.

Rate limits

The backend keeps its counters in memory with fixed windows. A restart of the backend resets every window.

LimiterApplies toLimitCounted per
Per keyAuthenticated REST requests with an API keyThe key's rate_limit per minute (default 100), capped at 10000 per minute (API_KEY_RATE_LIMIT_MAX)API key
Per sessionAuthenticated REST requests with a Studio session1000 per minute, or the key cap when that is lowerAccount
Per IP/api/v1 requests that do not authenticate: the /api/v1/auth routes, the OpenAPI document and docs page, unknown routes, and requests with missing or invalid credentials600 per minute (API_IP_RATE_LIMIT)Client IP address
Sign-in attemptsPOST /api/v1/auth/password and POST /api/v1/auth/password/change10 attempts per 10 minutesClient IP address
Wrong passwordsThe same two routes5 wrong passwords per 15 minutes. A correct password clears the countEmail address
Setup attemptsPOST /api/v1/auth/setup10 attempts per 10 minutesClient IP address
Link attemptsGET /api/v1/auth/invites/{token} and POST /api/v1/auth/invites/{token}/accept30 per 10 minutesClient IP address

Every request through the per-key, per-session or per-IP limiter carries these headers:

HeaderValue
X-RateLimit-LimitThe limit of the current window
X-RateLimit-RemainingRequests left in the window
X-RateLimit-ResetSeconds until the window resets (not a timestamp)

Over the limit, the answer is 429 with a Retry-After header in seconds and this body:

{
  "error": "Too many requests. Try again in a few seconds.",
  "retry_after": 42,
  "retryAfter": 42
}

retryAfter is an older spelling of retry_after. The attempt limits answer 429 with Retry-After and a body of error (for example Too many attempts. Try again in 10 minutes.) and retry_after. The X-RateLimit-* headers on those answers describe the per-IP limit.

The per-IP limiter counts every /api/v1 request first and gives the count back once the request authenticates. So an address that has used up its per-IP window gets 429 for every /api/v1 request until the window resets, keyed requests included.

tus upload requests do not count against the per-key or per-session limit. The per-IP limit counts them unless the upload server answers 401 or 403.

The per-IP limits use the client address Express resolves through the proxies it trusts (TRUST_PROXY). The server operator changes the key cap and the per-IP limit with the variables linked in the table. A key cannot be created with a rate_limit above API_KEY_RATE_LIMIT_MAX (the API answers 400).

Proxy limits

The nginx proxy in the shipped Docker Compose stacks adds per-client-address limits in front of the backend:

PathsRequest rate (burst)Concurrent connections
/v1/ (delivery)100r/s (burst 400), WEB_DELIVERY_RATE and WEB_DELIVERY_BURST200, WEB_DELIVERY_CONN
/api/v1/auth/ and /metrics10r/s (burst 50), WEB_AUTH_RATE and WEB_AUTH_BURST20, WEB_AUTH_CONN

Over these limits nginx answers 429 with Retry-After: 1 and {"error":"Too many requests. Try again in a few seconds."}. The server operator changes them with the variables in the table (Web proxy configuration).

ETags and If-Match

Assets carry a version so two editors cannot overwrite each other's changes.

RequestETag behavior
GET /api/v1/assets/{id}Returns ETag with the asset's version and Cache-Control: no-store. Ignores If-None-Match (never answers 304)
PATCH /api/v1/assets/{id}Accepts If-Match. Returns the new ETag

The version is the asset's updated_at in milliseconds since the Unix epoch, in double quotes, for example "1767225600000". You can build it from any asset you already hold, including one from the list endpoint. Plays do not change it.

If-Match rules on PATCH:

  • Without If-Match, or with If-Match: *, the update applies unconditionally.
  • A comma-separated list matches when any entry matches. A weak tag (W/"1767225600000") counts as its strong form.
  • A value that is not a quoted version answers 400 with If-Match must be the ETag of the video, such as "1767225600000".
  • When the asset changed since that version, the answer is 412 with the current ETag header and this body:
{
  "error": "This video was changed somewhere else.",
  "current": { "id": "...", "title": "...", "updated_at": "..." }
}

current holds the full asset in the same shape as GET /api/v1/assets/{id}. Retry with its version once you have merged your change.

CORS

The REST API (/api) decides CORS per request origin:

Request originAnswer
Listed in CORS_ORIGINAccess-Control-Allow-Origin set to that origin, with Access-Control-Allow-Credentials: true. The session cookie works from that origin
Any other originAccess-Control-Allow-Origin: * without credentials. Requests with an API key work; requests that rely on the session cookie do not
Any other origin while AUTH_DISABLED is onNo CORS headers
SettingValue
Allowed methodsGET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
Allowed request headersContent-Type, Authorization, Tus-Resumable, Upload-Length, Upload-Offset, Upload-Metadata, Upload-Concat, Upload-Defer-Length, X-Requested-With, If-Match
Exposed response headersUpload-Offset, Upload-Length, Upload-Metadata, Tus-Version, Tus-Resumable, Tus-Max-Size, Tus-Extension, Location, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, ETag
Preflight cacheAccess-Control-Max-Age: 600

When CORS_ORIGIN lists any origin, or AUTH_DISABLED is on, responses carry Vary: Origin. See CORS_ORIGIN to set the list.

The delivery gateway (/v1/objects/, /v1/stream/, /v1/share/) answers any origin with Access-Control-Allow-Origin: *, never with credentials. It allows the request headers Range and Content-Type. /v1/objects/ and /v1/stream/ allow GET, HEAD and OPTIONS and expose Content-Length, Content-Range, Accept-Ranges and ETag. Behind the nginx proxy of the shipped stacks, /v1/share/ allows GET and OPTIONS and exposes the same headers without ETag.

Request size limits

BodyLimitOver the limit
JSON request body (Content-Type: application/json) on any REST route1 MiB (1048576 bytes)413 with {"error": "request entity too large"}
Upload through tus10 GiB (10737418240 bytes), set by MAX_UPLOAD_SIZE413 with the plain-text body Maximum size exceeded

A tus OPTIONS request to /api/v1/uploads returns the upload limit in the Tus-Max-Size header. The nginx proxy in the shipped stacks sets no body size limit of its own.

OpenAPI document and interactive reference

The server describes its API in an OpenAPI 3.1 document:

URLWhat it returnsAuthentication
GET /api/v1/openapi.jsonThe OpenAPI document as JSONNone
GET /api/v1/docsAn interactive reference page (Scalar) that reads /api/v1/openapi.json. The backend serves its script, so the page loads nothing from a CDNNone

Both count against the per-IP limit. The interactive page uses bearerAuth by default, so you can paste a key and send requests from it.

The REST API reference on this site is generated from the same document, published here as /openapi.json.

On this page