Skip to content

DocsGet started

Client tokens

Let a mobile app or a web page upload files and follow jobs without an API key: short-lived tokens with a narrow grant, minted by your server.

An API key has every right of your organization, so a key inside an app or a web page is a key published. A client token is what the app holds instead: a short-lived token your server mints for one end user's action. It can create the uploads you allow and read the jobs you name. It cannot create, cancel or change a job, delete a file, or see your other jobs, your webhooks, your usage or your credits, and it spends no credits.

Jobs are always created by your server, with its API key. So your server decides what runs, for which user, and at what cost.

The flow#

  1. Your server mints an upload token and hands the app its token and id.
  2. The app creates its upload and sends the bytes straight to Videxo, with progress.
  3. The app sends the upload's input_url to your server.
  4. Your server creates the job with its API key, and answers the app with the job id and a continuation of the token that adds the job.
  5. The app follows the job and downloads its outputs through their signed links.

Mint a token#

On your server, with your API key, POST /v1/client-tokens:

JSON
{
  "ttl_seconds": 900,
  "subject": "user_8f2c",
  "uploads": { "max_count": 1, "max_size_bytes": 50000000, "content_types": ["video/*"] }
}

The answer is 201, and it is the only time the token is shown:

JSON
{
  "id": "ct_4m8x2k9q0c3v7n1r5t6b2z8w",
  "token": "vdx_ct_AWdK3xA...",
  "chain_id": "ct_4m8x2k9q0c3v7n1r5t6b2z8w",
  "expires_at": "2026-11-05T12:15:00.000Z",
  "grant": {
    "subject": "user_8f2c",
    "allowed_origins": null,
    "uploads": { "max_count": 1, "max_size_bytes": 50000000, "content_types": ["video/*"] },
    "jobs": null
  }
}

Hand the app both token and id: it needs the id to ask for a refresh after the token expired. The token is opaque, so never parse it. A grant needs at least one of uploads and jobs.

FieldRuleWhen left out
ttl_seconds60 to 21600 (6 hours)900 (15 minutes)
subjectYour own id of the end user, 1 to 128 of A-Z a-z 0-9 . _ : @ -. Use an opaque id, never an email. It is stored and shown in your logs, and it lets you revoke the user's tokens at once.none
allowed_originsUp to 5 exact browser origins: https://host[:port], or http://localhost[:port] while developingany origin, and native apps
continuesThe id of a token of the same chain (Refresh)a new chain
uploads.max_count1 to 10 uploads, counted over the chainrequired with uploads
uploads.max_size_bytesAt most 100 MB and your plan's input limitrequired with uploads
uploads.content_types1 to 10 media types, exact or type/*; it checks the declared type, not the bytesvideo/*, audio/*, image/*
jobs.read1 to 10 ids of your jobs, checked when you mintnone

With the SDK, on your server:

JavaScript
import { Videxo } from '@videxo/sdk';

const videxo = new Videxo({ apiKey: process.env.VIDEXO_API_KEY });

// One upload of at most 50 MB by this user:
const { id, token } = await videxo.clientTokens.forUpload({ subject: 'user_8f2c', maxSizeBytes: 50_000_000, contentTypes: ['video/*'] });

What a token can call#

The token travels in Authorization: Bearer vdx_ct_... and works on these routes only:

RouteNeedsWhat it does
POST /v1/client/uploadsuploadsThe body and answer of POST /v1/uploads, within the grant.
GET /v1/client/jobs/{id}jobs.readOne job you named. Any other job answers 404 not_found (job), the same as an unknown id.
GET /v1/client/jobsjobs.readThe jobs you named, newest first, in one page. A cursor answers 400 invalid_request.
GET /v1/client/tokennothingWhat the token may still do: id, chain_id, expires_at, the uploads section with used, and the read ids.

Any other /v1 route that needs a key answers a token 403 permission_denied (token_scope), and /mcp answers it 401. An API key sent to these routes answers 403 permission_denied (client_token_required), so a key shipped in an app shows up on the first request.

A token sees a narrower job than your server does: id, state, progress, error, outputs (alias, filename, size_bytes, content_type, expires_at, deleted_at, download_url, download_url_expires_at), created_at, started_at and finished_at. It never sees the command, the operations body, the inputs, the webhook, the metadata or the usage.

Upload from an app#

bash
upload=$(curl --fail-with-body "$VIDEXO_API_URL/v1/client/uploads" \
  -H "Authorization: Bearer $CLIENT_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{"filename":"clip.mp4","size_bytes":5242880,"content_type":"video/mp4"}')
curl --fail-with-body -X PUT "$(printf '%s' "$upload" | jq -r .upload_url)" \
  -H 'Content-Type: video/mp4' --data-binary @clip.mp4

An Idempotency-Key makes a retry after a lost answer return the same upload (200) instead of spending the count again. It lasts 24 hours and still matches after a refresh; the same key with another body answers 409 conflict (idempotency_key_reused). A file the grant does not allow answers 400 invalid_request with errors pointing at size_bytes or content_type, and an upload past max_count answers 413 limit_exceeded (token_uploads).

The upload belongs to your organization. Send its input_url to your server, which uses it as a job input with its API key. The upload URL works as on Files: one PUT within one hour. It keeps working when the token is refreshed or expires, so a background upload finishes. A PUT that has not started is refused once the chain or the key that minted the token is revoked, or the organization is deleted or disabled. A PUT under way is not interrupted.

Follow a job#

When your server has created the job, it mints a continuation of the app's token that adds the job, again with POST /v1/client-tokens:

JSON
{
  "continues": "ct_4m8x2k9q0c3v7n1r5t6b2z8w",
  "jobs": { "read": ["job_3k9x0c2mfq8w1n7r5t4b6v2z"] }
}

The app then reads the job with the new token, every 2 seconds at most:

bash
curl --fail-with-body "$VIDEXO_API_URL/v1/client/jobs/job_3k9x0c2mfq8w1n7r5t4b6v2z" \
  -H "Authorization: Bearer $CLIENT_TOKEN"

Once the job has state: "succeeded", each output has a signed download_url that needs no token. A job can fail with out_of_credits or usage_cap_reached like any job: a token never sees your balance or plan, so show your own message in the app.

Refresh#

A token's uploads and jobs belong to its chain: the first token and every continuation of it. When a flow outlives a token, your server mints the next one with "continues", naming any token of the chain, even an expired one, up to 24 hours after the chain's newest token expired.

  • The new token must have the same subject and organization, and the chain must not be revoked. You may leave subject out: the chain's is kept.
  • It keeps the chain's jobs.read ids and may add more, up to 10 for the chain.
  • Its grant is never wider than the chain's newest token: each limit is equal or lower, and content_types and allowed_origins are a subset. A section you leave out is not granted, but allowed_origins carries over. Anything wider answers 400; mint a new chain instead.
  • uploads.max_count counts over the whole chain, so a refresh never resets it.

Minting supersedes nothing, so a lost answer costs nothing: mint another one. A token is superseded when a newer token of its chain is used for the first time. From then on the older one answers 401 (client_token_superseded).

Revoke#

bash
# A token and its whole chain; 204, and 204 again if you repeat it.
curl -X DELETE "$VIDEXO_API_URL/v1/client-tokens/ct_4m8x2k9q0c3v7n1r5t6b2z8w" \
  -H "Authorization: Bearer $VIDEXO_API_KEY"

# Every chain of one end user, when you ban or sign out the user; answers { "revoked": 2 }.
curl "$VIDEXO_API_URL/v1/client-tokens/revoke" \
  -H "Authorization: Bearer $VIDEXO_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"subject":"user_8f2c"}'

Revocation works on the next request. A token also ends with the key that minted it (revoked, expired or deleted), and answers 403 permission_denied (account_disabled) when the organization is deleted or disabled.

In a browser#

The client routes answer CORS for any origin, with no credentials or cookies, and signed downloads do too. Your own server's routes that mint tokens stay yours; Videxo's keyed routes answer no CORS, so an API key in a web page fails at once.

  • Keep the token in memory, never in localStorage or a URL.
  • Prefer a short ttl_seconds, and set allowed_origins. A browser request from another origin answers 403 permission_denied (origin_not_allowed). This protects browser tokens only: other clients can send any Origin.

The SDK has a separate entry point for apps and browsers, which a bundle can import without the keyed client. Its constructor refuses a value shaped like an API key.

JavaScript
import { VidexoClient } from '@videxo/sdk/client';

/** Asks your server for a continuation when a token is about to expire or is refused. */
const getToken = async (/** @type {{ tokenId: string | null }} */ { tokenId }) =>
  (await fetch('/api/videxo-token', { method: 'POST', body: JSON.stringify({ tokenId }) })).json();

/** @param {File} file @param {{ token: string, id: string }} minted */
async function uploadAndFollow(file, minted) {
  const uploader = new VidexoClient({ token: minted.token, tokenId: minted.id, getToken });
  const upload = await uploader.uploads.upload(file, {
    filename: 'clip.mp4',
    onProgress: ({ loaded, total }) => console.log(`${Math.round((loaded / total) * 100)}%`),
  });
  // Your server creates the job from upload.input_url and answers its id with a continuation that adds it.
  const answer = await fetch('/api/jobs', { method: 'POST', body: JSON.stringify({ input: upload.input_url }) });
  const { jobId, token, id } = await answer.json();
  const follower = new VidexoClient({ token, tokenId: id, getToken });
  return follower.jobs.wait(jobId);
}

In an iOS or macOS app#

The Swift SDK does the same for an app, with uploads in a background session.

Limits#

  • Every request with a token counts against the token: 60 a minute, enough to follow a job every 2 seconds and more. A 429 carries Retry-After.
  • Minting and revoking are requests of your key and count against its plan's limit.
  • A token can cost your organization storage only: at most max_count times max_size_bytes per chain, for 24 hours.
  • Rows that hold a token's subject are kept until 30 days after the chain's newest token expired.

The Errors page lists every reason, under authentication_failed, permission_denied, limit_exceeded and not_found.