@hostr/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +53 -14
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3621,18 +3621,18 @@ async function uploadGui(ctx) {
|
|
|
3621
3621
|
});
|
|
3622
3622
|
});
|
|
3623
3623
|
let port = 0;
|
|
3624
|
-
for (
|
|
3625
|
-
const e4 = await listen(server, "127.0.0.1",
|
|
3624
|
+
for (let attempt = 0; attempt < 5 && !port; attempt++) {
|
|
3625
|
+
const e4 = await listen(server, "127.0.0.1", 0);
|
|
3626
3626
|
if (e4) continue;
|
|
3627
|
+
const candidate = server.address().port;
|
|
3627
3628
|
const e6 = await listen(server6, "::1", candidate);
|
|
3628
3629
|
if (e6 && e6 !== "EADDRNOTAVAIL") {
|
|
3629
3630
|
await new Promise((r) => server.close(() => r(null)));
|
|
3630
3631
|
continue;
|
|
3631
3632
|
}
|
|
3632
3633
|
port = candidate;
|
|
3633
|
-
break;
|
|
3634
3634
|
}
|
|
3635
|
-
if (!port) throw new Error("
|
|
3635
|
+
if (!port) throw new Error("could not bind one port on both 127.0.0.1 and ::1");
|
|
3636
3636
|
const addr = `http://localhost:${port}/?s=${secret}`;
|
|
3637
3637
|
process.stderr.write(`upload page ready: ${addr}
|
|
3638
3638
|
(drop a video in the browser; Ctrl+C here when done)
|
|
@@ -3642,12 +3642,11 @@ async function uploadGui(ctx) {
|
|
|
3642
3642
|
await new Promise(() => {
|
|
3643
3643
|
});
|
|
3644
3644
|
}
|
|
3645
|
-
var
|
|
3645
|
+
var page;
|
|
3646
3646
|
var init_gui = __esm({
|
|
3647
3647
|
"src/gui.ts"() {
|
|
3648
3648
|
"use strict";
|
|
3649
3649
|
init_client();
|
|
3650
|
-
CORS_PORTS = [3005, 3004, 3003, 3002, 3001, 5173, 3e3, 8899];
|
|
3651
3650
|
page = (apiBase, secret) => `<!doctype html>
|
|
3652
3651
|
<meta charset="utf-8"><title>hostr upload</title>
|
|
3653
3652
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -3835,7 +3834,7 @@ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
|
3835
3834
|
import { homedir } from "node:os";
|
|
3836
3835
|
import { join } from "node:path";
|
|
3837
3836
|
var DEFAULT_API_BASE = "https://api.hostr.sh";
|
|
3838
|
-
var CLI_VERSION = "0.
|
|
3837
|
+
var CLI_VERSION = "0.9.0";
|
|
3839
3838
|
var configDir = (env = process.env) => env.HOSTR_CONFIG_DIR ?? join(homedir(), ".config", "hostr");
|
|
3840
3839
|
async function loadConfig(dir = configDir()) {
|
|
3841
3840
|
try {
|
|
@@ -3899,6 +3898,7 @@ function makeClient(opts) {
|
|
|
3899
3898
|
return {
|
|
3900
3899
|
account: () => req2("GET", "/v1/account"),
|
|
3901
3900
|
billing: () => req2("GET", "/v1/billing"),
|
|
3901
|
+
captionsSet: (body) => req2("PUT", "/v1/account/captions", { body }),
|
|
3902
3902
|
assets: (params = {}) => req2("GET", "/v1/assets", { params }),
|
|
3903
3903
|
asset: (id) => req2("GET", `/v1/assets/${id}`),
|
|
3904
3904
|
assetTokens: (id, body = {}) => req2("POST", `/v1/assets/${id}/tokens`, { body }),
|
|
@@ -4126,7 +4126,7 @@ ${billingLine(i.account.billing)}` : "");
|
|
|
4126
4126
|
}
|
|
4127
4127
|
function captionsLine(c) {
|
|
4128
4128
|
const used = Math.round(c.minutesUsed * 10) / 10;
|
|
4129
|
-
if (c.minutesCap === 0) return "captions: off for this account (
|
|
4129
|
+
if (c.minutesCap === 0) return "captions: off for this account (billed per minute when on) \u2014 turn on with: hostr captions enable";
|
|
4130
4130
|
if (c.minutesCap === null) return `captions: on, no limit (${used} min used this month)`;
|
|
4131
4131
|
return `captions: on, ${used}/${c.minutesCap} min this month`;
|
|
4132
4132
|
}
|
|
@@ -5243,7 +5243,7 @@ var UploadEngine = class {
|
|
|
5243
5243
|
code: "systemic-transport-failure",
|
|
5244
5244
|
consecutiveNetworkFailures: this.consecutiveNetworkFailures,
|
|
5245
5245
|
bytesEverConfirmed: this.bytesConfirmed(),
|
|
5246
|
-
hint:
|
|
5246
|
+
hint: "every part PUT is failing at the network layer and no bytes have ever been confirmed for this session \u2014 check the connection; in a browser, check that the page CSP connect-src allows the storage host, and (self-hosted buckets only) bucket CORS"
|
|
5247
5247
|
});
|
|
5248
5248
|
}
|
|
5249
5249
|
bytesConfirmed() {
|
|
@@ -5512,7 +5512,7 @@ function explainCaptionsError(error, body) {
|
|
|
5512
5512
|
const window = typeof b.minutesCap === "number" ? ` (${b.minutesUsed ?? 0} of ${b.minutesCap} minutes used this month${b.resetsAt ? `; resets ${b.resetsAt.slice(0, 10)}` : ""})` : "";
|
|
5513
5513
|
switch (error) {
|
|
5514
5514
|
case "captions_not_enabled":
|
|
5515
|
-
return `captions are
|
|
5515
|
+
return `captions are off for this account${window} \u2014 turn them on with: hostr captions enable (billed per minute; needs a full-scope key)`;
|
|
5516
5516
|
case "captions_cap_exceeded":
|
|
5517
5517
|
return `this would go over the account's monthly captions minutes${window}`;
|
|
5518
5518
|
case "captions_in_progress":
|
|
@@ -5535,6 +5535,23 @@ function explainCaptionsError(error, body) {
|
|
|
5535
5535
|
return null;
|
|
5536
5536
|
}
|
|
5537
5537
|
}
|
|
5538
|
+
function captionsInfo(a, prices) {
|
|
5539
|
+
const used = Math.round(a.minutesUsed * 10) / 10;
|
|
5540
|
+
const state = a.minutesCap === 0 ? "off" : a.minutesCap === null ? `on, no limit (${used} transcription min this month)` : `on, ${used}/${a.minutesCap} transcription min this month`;
|
|
5541
|
+
const lines = [`captions ${state}; the count resets ${a.resetsAt.slice(0, 10)}`];
|
|
5542
|
+
for (const k of ["transcription", "translation"]) {
|
|
5543
|
+
const p = prices?.find((x) => x.key === k);
|
|
5544
|
+
if (p) lines.push(`${k.padEnd(12)} $${p.usd.toFixed(3)} per ${p.unit}`);
|
|
5545
|
+
}
|
|
5546
|
+
lines.push(a.minutesCap === 0 ? "turn on hostr captions enable [--limit <minutes>]" : "turn off hostr captions disable \xB7 change limit: hostr captions enable --limit <minutes>");
|
|
5547
|
+
lines.push("run hostr captions request <assetId> [--translate es,fr] \xB7 hostr upload <file> --captions --translate es");
|
|
5548
|
+
return lines.join("\n");
|
|
5549
|
+
}
|
|
5550
|
+
function parseLimitFlag(v) {
|
|
5551
|
+
if (v === void 0) return void 0;
|
|
5552
|
+
if (!/^\d+$/.test(v.trim()) || Number(v) < 1) throw new Error("--limit must be a whole number of minutes, at least 1");
|
|
5553
|
+
return Number(v);
|
|
5554
|
+
}
|
|
5538
5555
|
|
|
5539
5556
|
// src/commands-media.ts
|
|
5540
5557
|
var MIME = {
|
|
@@ -5784,13 +5801,32 @@ skipped ${r.skippedLangs.join(", ")} (your own tracks; delete them first to repl
|
|
|
5784
5801
|
ctx.out.print(`${queued}${skipped}
|
|
5785
5802
|
watch: hostr tail --follow --type captions.ready`, r);
|
|
5786
5803
|
}
|
|
5804
|
+
async function captionsShowInfo(ctx) {
|
|
5805
|
+
const [a, billing] = await Promise.all([ctx.client.account(), ctx.client.billing().catch(() => null)]);
|
|
5806
|
+
if (!a.captions) throw new Error("this hostr deployment reports no captions allowance");
|
|
5807
|
+
const prices = billing?.prices?.filter((p) => p.key === "transcription" || p.key === "translation");
|
|
5808
|
+
ctx.out.print(captionsInfo(a.captions, prices), { ...a.captions, enabled: a.captions.minutesCap !== 0, prices: prices ?? null });
|
|
5809
|
+
}
|
|
5810
|
+
async function captionsEnable(ctx, opts) {
|
|
5811
|
+
const monthlyMinutes = parseLimitFlag(opts.limit);
|
|
5812
|
+
const r = await ctx.client.captionsSet({ enabled: true, ...monthlyMinutes ? { monthlyMinutes } : {} });
|
|
5813
|
+
ctx.out.print(
|
|
5814
|
+
`captions on${r.minutesCap === null ? ", no monthly limit" : `, limit ${r.minutesCap} transcription min/month`} \u2014 billed per minute transcribed and per language-minute translated (hostr captions info)
|
|
5815
|
+
reaches every API edge within a minute; then: hostr captions request <assetId> --translate es`,
|
|
5816
|
+
{ ...r, enabled: true }
|
|
5817
|
+
);
|
|
5818
|
+
}
|
|
5819
|
+
async function captionsDisable(ctx) {
|
|
5820
|
+
const r = await ctx.client.captionsSet({ enabled: false });
|
|
5821
|
+
ctx.out.print("captions off \u2014 new runs are refused; runs already submitted finish and are billed", { ...r, enabled: false });
|
|
5822
|
+
}
|
|
5787
5823
|
|
|
5788
5824
|
// src/commands-init.ts
|
|
5789
5825
|
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
|
|
5790
5826
|
import { join as join3 } from "node:path";
|
|
5791
5827
|
|
|
5792
5828
|
// ../../docs/integrate.md
|
|
5793
|
-
var integrate_default = '# hostr \u2014 integration guide\n\n<!-- GENERATED FILE \u2014 DO NOT EDIT.\n Source: apps/docs/src/content/docs/** (pages with `agentGuide: true`)\n packages/openapi/src/spec.ts (the route table)\n Regenerate: pnpm build:agent-guide \xB7 CI checks this with --check -->\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs https://docs.hostr.sh \xB7 GET {API base}/llms-full.txt (this file)\n```\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\n## Quickstart\n\nBases you will need:\n\n```\nAPI base https://api.hostr.sh (override with HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\n```\n\n### 1. Authorize\n\n```bash\nnpx @hostr/cli login\n```\n\nThis opens your browser and mints the CLI\'s own key once you approve it. Nothing\nis pasted, which matters when a coding agent is driving your terminal \u2014 a key\ntyped into an agent\'s session lands in its context and its transcript.\n\nThe key appears on the dashboard Keys page as `CLI \u2014 <hostname>` and is revocable\nlike any other. For CI, `--key <k>` or `HOSTR_API_KEY` skips the browser.\n\nConfirm it worked:\n\n```bash\nnpx @hostr/cli whoami --json\n```\n\n### 2. Upload\n\n```bash\nnpx @hostr/cli upload video.mp4 --wait --json\n```\n\n`--wait` blocks until the transcode reaches `ready` or `failed`. Without it the\ncommand returns as soon as the bytes are verified in storage.\n\n```json\n{ "assetId": "9f8c1e2a4b6d47f0a1b2c3d4e5f60718", "status": "ready", "bytes": 104857600 }\n```\n\nThe upload is resumable. Kill it mid-transfer and run it again \u2014 it asks storage\nwhich parts landed and continues from there.\n\n### 3. Play it\n\nMint a playback token, then build the URL:\n\n```bash\nnpx @hostr/cli assets token 9f8c1e2a4b6d47f0a1b2c3d4e5f60718 --json\n```\n\n```json\n{ "playback": "eyJhbGci...", "thumbnail": "...", "storyboard": "...", "expiresAt": 1756134000000 }\n```\n\n```\nhttps://stream.hostr.sh/{assetId}.m3u8?token={playback}\n```\n\nHand that to any HLS player. Safari and iOS play it natively; elsewhere use\nhls.js. To check a video without writing any of that, open it in the dashboard\nand press Play \u2014 the asset page mints a token and plays it in place.\n\nThe token is checked once, when playback starts. It does not need to stay valid\nfor the whole viewing session, and the default one-hour lifetime is not a\none-hour viewing limit.\n\n### 4. Wire up events, not polling\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nThe signing secret is generated, written to `.env`, and never printed \u2014 only the\nvariable name is. Then verify signatures before trusting a delivery: see\n[Verifying webhooks](#verifying-webhook-signatures).\n\nProve your endpoint end to end without uploading anything else:\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\n### No Node available?\n\nEvery flow here has a raw-HTTP equivalent. Start at\n[Upload from a server](#upload-from-a-server).\n\n## Authentication\n\nEvery call sends `Authorization: Bearer <credential>` over HTTPS. There are three\ncredential classes and **no shared master key**.\n\n### API keys\n\n`hostr_sk_live_\u2026`, minted per account. SHA-256-hashed at rest; the plaintext is\nshown exactly once, at mint. Verified in constant time and cached about 60 seconds\nin the edge Worker \u2014 so **revocation takes up to a minute** to take effect.\n\nScopes are a ladder, each rung including the ones below:\n\n| Scope | Grants |\n|---|---|\n| `read` | GETs only \u2014 asset status, listing, usage, events |\n| `upload` | read, plus the whole upload lifecycle |\n| `playback` | upload, plus minting playback tokens |\n| `full` | everything: deletion, key management, webhook config |\n\n**`playback` is what an application key should be.** It can ingest video and it\ncan let viewers watch, but it cannot delete assets or mint more keys. Reach for\n`full` only where you actually manage the account.\n\n**Keep keys server-side.** Never ship one in a browser or a mobile app.\n\nRotate by creating the new key and revoking the old one:\n\n```bash\nnpx @hostr/cli keys create --name ci --scopes upload --write-env .env\nnpx @hostr/cli keys revoke <old-id>\n```\n\nRevoking the key that makes the request is allowed \u2014 it is rotation\'s last step.\n\n### Upload tokens\n\nA stateless HMAC scoped to a single `assetId`, returned as `uploadToken` from\n`POST /v1/uploads`. **This is the only credential an untrusted client should ever\nhold.** It can drive that one upload and nothing else \u2014 it grants nothing on\nplayback, and nothing on any other asset.\n\nTokens expire. For uploads that outlive one, re-mint in place with\n`POST /v1/uploads/:id/token` (an API key with `upload` scope, not the expiring\ntoken itself) \u2014 no progress is lost. The SDK does this for you through its\n`onTokenExpired` hook.\n\n### Service key\n\nThe internal transcoder credential. Never issued to tenants; documented only so\nits one route is not mistaken for a public one.\n\n### Login without pasting a key\n\n```bash\nnpx @hostr/cli login\n```\n\nBrowser-authorized, using the RFC 8628 device flow. The CLI never accepts a\npasted key from a human or an agent, for the reason given in the\n[quickstart](#quickstart). `--key` and `HOSTR_API_KEY` remain the escape\nhatch for CI and other headless callers.\n\nCredential precedence everywhere: `--key` flag, then `HOSTR_API_KEY`, then the\nsaved profile.\n\n### Auth errors\n\n| Status | Error | Meaning |\n|---|---|---|\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `account_suspended` | Contact the operator |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\nThat 401 conflation is deliberate: distinguishing "wrong key" from "not your\nasset" would let anyone with a valid key enumerate other accounts\' asset ids.\n\n**`control_plane_unavailable` fails closed for API keys only.** If the control\nplane is unreachable, key-authenticated calls 503 \u2014 but in-flight uploads using\nupload tokens keep working, and playback is untouched, because the media Worker\nnever queries it.\n\n## Assets and lifecycle\n\nAn asset is created when you open an upload and lives until you erase it.\n\n```\nuploading -> uploaded -> queued -> processing -> playable -> ready\n \\-> failed\n \\-> aborted (deleted, from anywhere)\n```\n\n| Status | Means |\n|---|---|\n| `uploading` | Multipart session open, parts landing |\n| `uploaded` | Bytes verified against storage; part count and total size both agree |\n| `queued` | Handed to the transcode queue |\n| `processing` | Encoding. Carries live `progress` |\n| `playable` | Watchable now, at one quality. The rest of the ladder is still encoding |\n| `ready` | HLS renditions are live and playable |\n| `failed` | Transcode failed. `error` is always present |\n| `aborted` | Upload abandoned before completion \u2014 you aborted it, or it was collected after 14 days idle |\n| `deleted` | Erased. Terminal and irreversible |\n\n### Status only moves forward\n\nStatus only ever moves forward. Both encode passes report independently and\nqueue delivery is at-least-once, so a late or redelivered report that would move\na `playable` or `ready` asset backwards is ignored \u2014 including a failure. An asset\nthat is watchable is never marked `failed`; the error is recorded on the record\ninstead. Once `playable`, an asset stays playable.\n\n### Reading progress\n\nWhile `processing` or `playable`, the record carries\n`progress: { encodedSec, totalSec, speed }`, where `speed` is the encode rate as\na multiple of realtime. While `playable` it tracks the remaining qualities.\nOutside those two it is null or absent \u2014 a terminal status never shows a stale\nbar.\n\nThat is enough for a real percentage instead of a spinner, which matters more\nthan it sounds: most "the transcode is slow" reports turn out to be missing\nprogress feedback rather than slow encoding.\n\n### Stalled encodes are re-queued\n\nAn encode that stops reporting \u2014 a transcoder machine restarted mid-encode,\nsay \u2014 is noticed within about ten minutes of its last progress post and the\npass that was running is re-queued. Nothing about the asset changes: status\nstays where it was, progress resumes, and `video.playable` / `video.ready`\nstill fire exactly once. An encode that stays silent through three re-queues\n(roughly an hour) is given up on and the asset records an `error`, exactly as\na transcode failure would: it becomes `failed` if nothing was playable yet\n(`video.errored` fires), or stays `playable` with the error if it was.\n\n### Abandoned uploads are collected after 14 days\n\nAn upload that shows no activity for **14 days** is moved to `aborted`, its\nmultipart session is released, and an `upload.aborted` event fires carrying\n`reason: "abandoned"`. The asset\'s `error` says the same thing in words. An\n`aborted` asset stops counting against your asset quota \u2014 it holds no bytes.\n\n"Activity" means a call about that upload: asking for the next batch of\npresigned part URLs, or re-minting its upload token. Sending bytes to the\npresigned URLs is not visible to the API, so a client that goes quiet for a\nfortnight without asking for anything is what gets collected. In practice an\nuploader hits the API at least twice a day, because presigned part URLs expire\nin at most 12 hours.\n\nThe window is deliberately double the 7-day upload token lifetime. An upload\nwhose token expired is not lost \u2014 re-mint at `POST /v1/uploads/:id/token` and\nresume \u2014 so a shorter window would destroy uploads that were still resumable.\nIf you know an upload is finished with, `DELETE /v1/uploads/:id` releases the\nstorage immediately rather than waiting.\n\n### Prefer events to polling\n\n`GET /v1/assets/:id` returns the authoritative record, and\n`GET /v1/assets?limit=&status=&cursor=` lists your assets newest-first with\nkeyset pagination. But polling is the fallback, not the design \u2014\n[webhooks](#webhooks-and-the-event-log) push the same transitions.\n\n### playbackId is its own field\n\n`GET /v1/assets/:id` always carries `playbackId`, and carries `durationSec`\n(seconds, the same value as `renditions.durationSec`) from `playable` on. The\n`video.playable` and `video.ready` events carry both.\n\n`playbackId` equals `assetId` today. **Store it as its own field anyway.** It is\nnamed separately so the two can diverge later without forcing a migration on\nevery consumer.\n\n### Re-encoding\n\n`POST /v1/assets/:id/reencode` (`hostr assets reencode <id>`) builds a fresh\nladder from the original upload, which stays in storage. The asset keeps its id,\nits playback URLs and its subtitles; the current ladder keeps serving until the\nnew one is live, then playback tokens switch over. Nothing is overwritten and\nnothing needs purging \u2014 the new ladder lands under a new encode revision, and\nthe record\'s `rev` says which one is live.\n\nUse it when an encode was wrong without the source being wrong. The first case\nwas HDR: assets uploaded before 2026-09-10 from an iPhone in HDR were relabelled\nrather than tone-mapped and play back washed out. Re-encoding them fixes it.\n\nThe video ladder is always the current default, but the body may carry `audio`\n\u2014 the same field `POST /v1/uploads` takes, see [audio\noptions](https://docs.hostr.sh/concepts/ladder/#audio-options) \u2014 to set the asset\'s audio options for\nthis and every later encode:\n\n```\nPOST /v1/assets/:id/reencode { "audio": "asmr" } # hostr assets reencode <id> --audio asmr\n```\n\nThat is how an asset uploaded before the option existed gets normalised: the\nsource is still in storage. The new ladder\'s measurement and gain replace the\nrecord\'s `loudness` block when it lands.\n\nOnly a `ready` asset can be re-encoded, one re-encode at a time. The request\nreturns `202` and the record carries a `reencode` block while the job runs;\n`video.ready` fires again with `reencoded: true` and the new `rev` when the\nladder is live, and the block clears. Poster and storyboard images keep their\nURLs but change content \u2014 refresh anything you cached from them.\n\n### Editing: renders\n\nAn edit is an ordered list of clips \u2014 each a window of one of your own assets,\nwith its own gain \u2014 that hostr cuts and joins into one video. Nothing is\ntranscoded while the edit is being made (build and preview it however you like);\na render happens once, when you ask.\n\n```\nPOST /v1/renders { "clips": [ \u2026 ], "audio": "asmr" } # a new asset\nPOST /v1/assets/:id/render { "clips": [ \u2026 ] } # re-edit an existing one\n```\n\nEach clip is `{ assetId, inSec, outSec, gainDb? }`. A source must be `playable`\nor `ready`, and is cut from what it plays **now** \u2014 an asset that was already\nre-edited contributes its edited version, and an asset may be a source of its\nown re-edit (the join never overwrites anything). Cuts are frame-accurate. The\nfirst clip sets the picture\'s orientation, size and frame rate; later clips are\nfitted inside it, letterboxed rather than stretched or cropped. Audio is joined\nat 48 kHz stereo, each clip at its `gainDb`; a clip with no audio contributes\nsilence. `audio` is applied once, over the whole joined programme \u2014 so\nnormalisation sees the finished video, not each clip.\n\n**A new asset** (`POST /v1/renders`) behaves like an upload from the moment\nits join exists: `video.uploaded` (with `rendered: true`), then\n`video.playable` and `video.ready`. It sits `queued` while the join runs; if the\njoin fails the asset goes `failed` and `video.errored` fires. Store the\n`assetId` from the `202` straight away.\n\n**A re-edit** (`POST /v1/assets/:id/render`) is a\n[re-encode](#re-encoding) with a new edit as its source: same one-at-a-time\nrule, same `reencode` block, and the current version keeps serving until the\nnew one lands \u2014 then `video.ready` fires with `reencoded: true`,\n`rendered: true` and the new `rev`. A failure at either step leaves the current\nversion live. Because the content changed, a poster you chose is dropped (the\nnew ladder\'s own poster serves) and **subtitles stay attached but no longer\nline up** \u2014 replace them after the new version lands. The record\'s `render`\nblock describes the edit that is live, and later re-encodes build from it.\n\n### Choosing the poster\n\n`POST /v1/assets/:id/poster` with `{ "time": <seconds> }` (`hostr assets poster\n<id> --time <seconds>`) re-takes the poster\nfrom that second of the published ladder \u2014 no re-encode, no source download,\nseconds of work. The picture lands under its own immutable key and the\nrecord\'s `posterRev`/`posterTime` move to it; `thumbnail.jpg` keeps its URL,\nand thumbnail tokens minted from then on serve the new frame.\n`video.poster_ready` fires when it is live. See\n[Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Downloading the original\n\n`POST /v1/assets/:id/download` returns a presigned URL for the file exactly as\nit was uploaded \u2014 not a rendition \u2014 valid for 60 to 3600 seconds (default 900).\nThe response saves as an attachment under `filename`; pass your own to choose\nit, and the source\'s extension is added if yours has none.\n\n**This is off for every account until a hostr operator enables it.** It needs\na `playback` key that owns the asset *and* that grant; without the grant the\nowner gets `403 download_not_enabled`. Anyone else gets the usual `401`, so\nthe route never reveals whether an asset exists or whether its owner has\ndownloads on. Mint the URL server-side, only for callers you have authorised,\nand hand it straight to the browser \u2014 it is a bearer credential for the\noriginal bytes until `expiresAt`.\n\n### Deletion\n\n`DELETE /v1/assets/:id` purges the source and every rendition from storage, frees\nthe storage quota, and emits `video.deleted`. Idempotent \u2014 repeat calls report\n`deleted` again with `objectsDeleted: 0`.\n\nNew playback tokens stop minting immediately. Tokens already issued can replay\nedge-cached segments for at most a few hours until they expire. **There is no\nundelete.**\n\n## Quotas and limits\n\nAccounts carry three ceilings, visible any time:\n\n```bash\nnpx @hostr/cli whoami --json # or: GET /v1/account\n```\n\n```json\n{\n "limits": { "maxAssets": 10000, "maxSourceBytes": 5497558138880, "maxUploadsPerDay": 500 },\n "usage": { "assetCount": 1711, "storageBytes": 742381129728, "uploadsToday": 12 }\n}\n```\n\n| Limit | Counts |\n|---|---|\n| `maxAssets` | Assets held at once. Deletion frees this immediately |\n| `maxSourceBytes` | Total source bytes stored |\n| `maxUploadsPerDay` | Upload sessions created per UTC day |\n\n### Hitting one\n\nQuotas are checked when the upload is created, not when bytes arrive. A tripped\nquota is a `403` naming which one and its ceiling:\n\n```json\n{ "error": "quota_storage", "limit": 5497558138880 }\n```\n\nThe three codes are `quota_uploads_daily`, `quota_assets`, and `quota_storage`.\n\n**This means the account needs raising, not that the request was malformed.**\nDistinguish it from a `400` in your error handling \u2014 retrying a quota error\nwithout changing anything will fail identically forever.\n\n### Request-shaped limits\n\nThese are properties of the API rather than of your plan:\n\n| Limit | Value |\n|---|---|\n| Parts per presign request | 100 |\n| Parts per upload | 10,000 (a storage limit; raise `partSize` for very large files) |\n| Subtitle file size | 2,000,000 bytes |\n| Playback token lifetime | 60 s to 86,400 s, default 3,600 |\n| Event page size | 1 to 200, default 50 |\n\nExceeding the part ceiling for a given part size is `413 file_too_large`, and the\nresponse carries the maximum byte count that part size can address.\n\n### Usage over time\n\n```\nGET /v1/usage?from=2026-08-01&to=2026-08-25 (read)\n -> { from, to, days: [{ day, uploads, bytesUploaded, encodedSeconds,\n captionSeconds, deliveredMinutes, deliveredBytes }] }\n```\n\nDefaults to the last 30 days. Dates are UTC and must be `YYYY-MM-DD`; anything\nelse is `400 bad_date`.\n\n**Delivery** is what viewers watched. `deliveredMinutes` counts whole video\nsegments served, on any rung, so one viewer watching one minute is one minute\nwhatever quality they got; audio, posters, storyboards and subtitles add to\n`deliveredBytes` but not to minutes. Edge-cache hits count: a byte served from\nthe cache was still delivered. Delivery is rolled up hourly, so today\'s figure,\nand up to the three hours before the current one, can still grow.\n\n### Not your fault\n\nTwo failures look like integration bugs and are not:\n\n`503 control_plane_unavailable` is transient \u2014 retry with backoff. In-flight\nuploads holding upload tokens are unaffected, and playback is untouched.\n\n`500 signing_not_configured` means the playback signing key is not loaded on the\nAPI deployment. It is operator-side and not retryable from a client. Report it\nrather than working around it.\n\n## Billing\n\nhostr is pay-as-you-go: no plan and no monthly fee. You pay for what you\nencode, store, deliver and caption, and **the first $5 is free**.\n\n| Line | Price (USD) |\n|---|---|\n| Encoding | $0.015 per source minute |\n| Video storage | $0.003 per minute stored, per month |\n| Original files | $0.03 per GB stored, per month |\n| Delivery | $0.0005 per minute watched |\n| Captions: transcription | $0.006 per minute |\n| Captions: translation | $0.001 per minute, per language |\n\nStorage is the daily average over the month. Delivery counts whole video\nsegments served, on any quality rung, so one viewer watching one minute is one\nminute. Original files are the uploads hostr keeps so it can re-encode or hand\nthem back; they are billed by size because a 4K original can be many times\nlarger than its encoded video.\n\n### The free allowance, then a card\n\nEvery account\'s first **$5** of usage is free, once: about 330 minutes encoded,\nor 10,000 minutes watched. That is enough to build and test a whole integration,\nupload to playback to webhook, without a card. It counts every line above.\n\nWithout a card you can always sign in, run `npx @hostr/cli login`, create keys,\nconfigure webhooks and read the docs. Once the allowance is used up, an upload,\nrender, re-encode or captions request answers:\n\n```\n402 { "error": "payment_method_required", "billingUrl": "https://dashboard.hostr.sh/billing", "hint": "\u2026" }\n```\n\nA human adds the card at `billingUrl`. Uploads work within a minute of it being\nadded. An agent setting up an integration should finish its testing, then hand\nthe human that link before the integration goes live.\n\n`GET /v1/billing` reports where you stand: `allowance.leftUsd`, and\n`cardRequired`, which says whether the card requirement is on at all. Read\nthose rather than `status`: `needs_card` means only that no card is on file.\n\n#### During the beta\n\n**The card requirement is off, and usage is not billed.** Uploads work without a\ncard whatever you use, `cardRequired` is `false`, and a month that closes during\nthe beta is recorded as not chargeable. It does not draw on the allowance\neither: the $5 starts counting when the beta ends. Adding a card early is fine;\nnothing is charged for beta usage.\n\n### How a month becomes an invoice\n\nEach UTC month closes on the 1st. Its charges are invoiced and charged to the\ncard once the unbilled balance, after the free allowance, reaches **$5**; a\nsmaller month carries over and is added to the next. The first invoice shows the\nallowance as its own credit line. Sales tax is added where it applies.\n\nA failed charge does not stop anything: the account shows as `past_due`, the\ncharge is retried, and the dashboard says so.\n\n### Checking what you owe\n\n```\nGET /v1/billing (read)\n -> { status, cardRequired, allowance: { totalUsd, leftUsd }, billingUrl, thresholdUsd, carriedUsd,\n currentMonth: { month, lines: [{ key, quantity, unitUsd, amountUsd }], accruedUsd },\n nextInvoiceUsd, prices }\n```\n\n`currentMonth` is what has accrued so far, from the same meters as\n`GET /v1/usage`. `status` is `needs_card`, `active`, `past_due` or `exempt`;\n`cardRequired` says whether a missing card is refused right now.\n\nThe `plan` field on `GET /v1/account` (and in `whoami`) names the account\'s\nquota tier, the set of limits on [Quotas and limits](#quotas-and-limits). It is\nnot a billing plan: there is one price list, above.\n\n## Upload from a server\n\nFour calls. Bytes go straight to storage via presigned URLs and never transit\nhostr\'s compute.\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,3]} (at most 100 per call)\n -> { urls: [{ partNumber, url }], expiresIn }\n\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nPOST /v1/uploads/:id/complete {"sha256":"<hex of the whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\n```\n\nPlus `GET /v1/uploads/:id` for resume and `DELETE /v1/uploads/:id` to abort.\n\n### Completion recovery for mobile uploads\n\nSet `autoComplete: true` on **creation** to let Hostr finalize the upload when\nstorage contains every numbered part at exactly the declared sizes. It defaults\nto false, preserving the existing client-driven completion and SHA-256 flow.\nThe phone may still call `/complete`; it is serialized with server recovery.\nDo not opt in if completion must wait for a later client SHA-256 assertion:\nautomatic completion may win that race and that later assertion is not retained.\n\nAn alarm starts checking after one minute. Checks back off to fifteen minutes\nwithout progress and stop after seven days without observed progress; a new\npresign or token request rearms them. These checks never discard incomplete\nparts. The existing abandonment policy remains separate. GET upload status\nincludes `activity` with the last check, last observed progress, stored bytes,\npart numbers, and a waiting flag. That flag diagnoses inactivity, not its cause.\n\nRecovery persists the verified part manifest before completing storage, confirms\nthe object\'s size and composite ETag after a lost completion response, and\nresumes a partially published transcode job. The standard `video.uploaded` event\nand usage metering share a stable completion event ID; queue messages remain\nat-least-once. Cancel and complete are serialized for opted-in sessions.\n\nOn iOS, enqueue all available file-backed part tasks while execution is\navailable, with bounded staging concurrency and a disk-space reserve. Do not\ngate preparation of the next part on the previous network completion. iOS still\ncontrols when transfers run; force-quitting requires relaunch to resume.\n\n### The rules that bite\n\n**Every part is exactly `partSize` bytes except the last.** Use the `partSize`\nthe server returned, not one you picked.\n\n**`size` must be exact.** Completion verifies both the part count and the total\nbyte count against what actually landed. Mismatches are `409`:\n\n- `incomplete_upload` \u2014 `missing` lists the absent part numbers, first 50. Upload\n them and complete again.\n- `size_mismatch` \u2014 `declaredSize` and `receivedBytes` are both reported. Abort\n and restart; the file you declared is not the file you sent.\n\nPart count alone would accept N parts of the wrong sizes, which is why both are\nchecked.\n\n**Request presigned URLs only for the parts you are about to send.** They expire.\nMinting all of them up front for a large file wastes most of them.\n\n**Completion is idempotent.** A client that crashed after bytes landed but before\nit saw the response can safely retry and will get the stored result back rather\nthan a conflict.\n\n### Worked example\n\n```bash\nAPI=https://api.hostr.sh\nKEY=$HOSTR_API_KEY\nFILE=video.mp4\nSIZE=$(wc -c < "$FILE")\n\n# 1. Create the session.\nCREATE=$(curl -sS -X POST "$API/v1/uploads" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"filename\\":\\"$FILE\\",\\"contentType\\":\\"video/mp4\\",\\"size\\":$SIZE}")\n\nASSET=$(echo "$CREATE" | jq -r .assetId)\nPART_SIZE=$(echo "$CREATE" | jq -r .partSize)\nPARTS=$(echo "$CREATE" | jq -r .expectedParts)\n\n# 2. Split, presign, and PUT each part.\nsplit -b "$PART_SIZE" "$FILE" part-\ni=1\nfor p in part-*; do\n URL=$(curl -sS -X POST "$API/v1/uploads/$ASSET/parts" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"partNumbers\\":[$i]}" | jq -r \'.urls[0].url\')\n curl -sS -X PUT --data-binary "@$p" "$URL" -o /dev/null\n i=$((i + 1))\ndone\n\n# 3. Finalise.\ncurl -sS -X POST "$API/v1/uploads/$ASSET/complete" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"sha256\\":\\"$(shasum -a 256 "$FILE" | cut -d\' \' -f1)\\"}"\n```\n\nBatch the presign calls in real code \u2014 up to 100 part numbers per request.\n\n### Send the sha256\n\nThe `etag` completion returns is a *composite* multipart ETag: a hash of hashes,\nand dependent on the part size you used. It is not a content identity and cannot\nbe compared against a hash you computed locally.\n\nIf you want real integrity, send `sha256` \u2014 the lowercase hex SHA-256 of the\nwhole file. It is stored as object metadata and is what an independent\nverification would check against. A malformed value is ignored rather than\nrejected, since retrying the call cannot fix it.\n\n### Credentials\n\n`POST /v1/uploads` needs an API key with `upload` scope. The remaining upload\nroutes accept **either** that key or the `uploadToken` returned at creation.\n\nRe-minting a token (`POST /v1/uploads/:id/token`) requires the API key \u2014 not the\nexpiring token, which would otherwise be able to renew itself forever.\n\nUploading from a browser or mobile client? Do not ship the API key. See\n[Upload from a browser](#upload-from-a-browser).\n\n## Upload from a browser\n\nCreate the upload on your server, hand the client a narrow credential, and let it\nPUT parts directly. Upload bytes never touch your servers.\n\n### Forward only the client-safe fields\n\n`POST /v1/uploads` returns more than a client should see:\n\n```json\n{\n "assetId": "...", // safe\n "uploadToken": "...", // safe\n "tokenExpiresAt": 1756130400000, // safe\n "partSize": 8388608, // safe\n "expectedParts": 13, // safe\n\n "key": "acct_7Kd2/.../source.mp4", // storage internal \u2014 do NOT forward\n "uploadId": "2~aBcDeF..." // storage internal \u2014 do NOT forward\n}\n```\n\nForward exactly `assetId`, `uploadToken`, `tokenExpiresAt`, `partSize`, and\n`expectedParts`. Passing the whole body through leaks your storage layout to\nevery client.\n\nThe `uploadToken` is scoped to that one asset and grants nothing else \u2014 no other\nasset, and nothing on playback. That is why it is safe to hand out and an API key\nis not.\n\n### Bucket CORS is a hard prerequisite\n\nPart PUTs go **directly to object storage**, not through the API. The source\nbucket must allow `PUT` from every origin your uploading page runs on, and must\nexpose the `ETag` response header:\n\n```json\n{\n "AllowedOrigins": ["https://yourapp.example", "http://localhost:3000"],\n "AllowedMethods": ["PUT"],\n "AllowedHeaders": ["*"],\n "ExposeHeaders": ["ETag"]\n}\n```\n\n`ExposeHeaders: ["ETag"]` is not optional \u2014 the client verifies each part\'s ETag\nand cannot read it otherwise.\n\n**On hosted hostr the source bucket is hostr\'s, not yours**, and there is no\nAPI, CLI or dashboard setting for its origins yet. Ask hostr to add each origin\nyour upload page runs on \u2014 production, staging, and any preview host \u2014 before\nyou test from it. Origins are listed exactly: a wildcard on a shared host such\nas `*.vercel.app` (or `*-yourteam.vercel.app`) would admit any other project on\nthat host, so it is not accepted. If your previews get a new URL per branch,\nserve them under a domain you own (e.g. a custom preview suffix,\n`*.preview.yourapp.example`) and ask for that one wildcard. Self-hosting, you\nown the bucket and set the rule above yourself.\n\n### The failure mode, stated plainly\n\n**A missing origin is silent from the API\'s perspective.** The upload session\nexists, zero parts ever land, and the client cycles running/stalled as though the\nnetwork were flaky. A CORS-blocked PUT is indistinguishable from a network error\nto `XMLHttpRequest` \u2014 the browser deliberately withholds the detail.\n\n**First move when a browser upload stalls at 0%: check the preflight, not the\nWorker.** Open the network tab and look for the `OPTIONS` request.\n\nThis one cost real time on the first customer integration, which is why the SDK\nnow surfaces it itself. After three consecutive network-class part failures with\n**zero bytes ever confirmed** for the session, it emits:\n\n```js\nupload.on(\'diagnostic\', d => {\n // { code: \'systemic-transport-failure\', consecutiveNetworkFailures, bytesEverConfirmed, hint }\n console.warn(d.hint);\n});\n```\n\nZero-ever-confirmed is what separates this from ordinary mid-upload network loss.\nIt is **advisory, never terminal**: a client that is genuinely offline from byte\nzero looks identical, so the upload keeps retrying regardless. The event is the\nhook for telling the user to check their connection \u2014 or for telling *you* to\ncheck bucket CORS.\n\n### Token expiry mid-upload\n\nLong uploads outlive their token. Handle it on your server:\n\n```js\nconst upload = createUpload({\n file,\n assetId,\n uploadToken,\n onTokenExpired: async () => {\n const r = await fetch(\'/api/hostr/refresh-token\', {\n method: \'POST\',\n body: JSON.stringify({ assetId }),\n });\n return (await r.json()).uploadToken; // your server calls POST /v1/uploads/:id/token\n },\n});\n```\n\nThe SDK re-mints and retries in place. Nothing is lost even without this \u2014 a\nkilled client resumes from what storage reports on the next launch \u2014 but the hook\navoids the interruption.\n\nSee the [SDK reference](#sdk-reference) for the full option surface.\n\n## Resume and integrity\n\n### Resume asks storage, not the client\n\n```\nGET /v1/uploads/:id\n -> { status, partSize, expectedParts, uploadedParts, parts, bytesUploaded }\n```\n\n`uploadedParts` is part numbers only; `parts` is the richer form, each entry\ncarrying `partNumber`, `size`, and `etag`.\n\nThe API derives this by listing what is actually in storage, not by reading\nbookkeeping the client sent. That is the whole design: a crash, a refresh, a\ndropped connection, or a client that lied all converge on the same truth. Resume\nis simply "send the parts that are not in that list".\n\nParts are listed only while `uploading`. Once completed or aborted the list is\nempty, because there is nothing left to resume.\n\nResume has an outer limit: an upload with no activity for 14 days is collected\nand moved to `aborted`. That window is twice the upload token\'s lifetime, so an\nexpired token never costs you the upload \u2014 re-mint at\n`POST /v1/uploads/:id/token` and carry on. See\n[Assets and lifecycle](#assets-and-lifecycle).\n\n### Three layers, three different claims\n\nEach layer proves something the others do not.\n\n**Per-part ETag.** Storage returns an ETag on every part PUT. The client compares\nit against the part it just sent, so a corrupted or truncated part is caught\nimmediately rather than at the end of a multi-gigabyte upload.\n\n**Part count and total size at completion.** Completion refuses to finalise\nunless the number of parts and the total byte count both match what was declared\nat creation. Count alone would accept N parts of the wrong sizes.\n\n**Whole-file SHA-256, if you send it.** This is the only one that proves content\nidentity.\n\n### Why the ETag is not enough\n\nThe ETag on a completed multipart object is a **hash of the part hashes**, then\nthe part count \u2014 `b8f1c0d9e2a34567-13`. Two consequences:\n\n- It cannot be compared against a hash you compute locally.\n- The same file uploaded with a different `partSize` produces a different ETag.\n\nSo it is a useful transfer check and a useless content identity. Send `sha256`\n(lowercase hex of the whole file) at completion if identity matters; it is stored\nas object metadata.\n\nIndependent verification, deriving both from the bytes actually in storage rather\nthan from anything the client claimed:\n\n```bash\npnpm verify:upload <assetId>\n```\n\n### Completion is idempotent\n\nA client that crashed after the bytes landed but before it saw the response\nretries and gets the stored `etag` and `bytes` back, not a `409`. Any genuinely\nunfinished status still conflicts with `not_uploading`.\n\nThis matters more than it sounds: the window between "storage has everything" and\n"the client knows" is exactly where flaky mobile connections die.\n\n### Aborting\n\n```\nDELETE /v1/uploads/:id\n```\n\nAn in-flight multipart session holds part storage that is invisible in normal\nlistings. Abandoning an upload without aborting leaks it. The SDK and CLI do this\nfor you; a hand-rolled client should too.\n\n## Webhooks and the event log\n\nhostr POSTs lifecycle events to one endpoint per account so you do not have to\npoll.\n\n```\nPUT /v1/webhooks {"url": "https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables; delivery history is retained\n```\n\nSupply `secret` in the PUT to bring your own. Omit it and a **live endpoint\nkeeps the secret it already has**, so changing the URL or re-running setup never\nbreaks signature checks; a new endpoint (or one switched off with DELETE) gets a\ngenerated one. `rotate: true` (CLI: `--rotate`) replaces a live secret \u2014 deploy\nthe new value promptly, since deliveries are signed with it at once. Configuration is cached about 60 seconds in the Worker, the same\npropagation window as key revocation.\n\nA URL configured without a secret logs and skips: **unsigned events are never\nsent.**\n\n### The log is written first\n\nEvery event becomes a durable row **before** delivery is attempted. A consumer\noutage cannot lose one.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n**The webhook is the wake-up signal; the log is the ledger.** Delivery is\nat-least-once \u2014 three attempts with short backoff, each recorded and inspectable.\nA consumer that must not miss anything reconciles against `GET /v1/events` (or\n`GET /v1/assets/:id`) rather than trusting that every POST arrived.\n\n`npx @hostr/cli tail --follow` polls the log for you.\n\n### Building a consumer that behaves\n\n**Verify the signature before parsing.** See\n[Verifying webhooks](#verifying-webhook-signatures) \u2014 use the raw body bytes.\n\n**Dedupe on `id`.** It is the durable event row\'s id, and a redelivery reuses it\ndeliberately so your dedupe still works.\n\n**Respond `2xx` fast.** Do the real work asynchronously. Anything else is\nretried.\n\n**Expect at-least-once, and design for it.** `video.ready` and `video.errored`\nfire on the status *transition* only, so a transcoder re-posting a terminal state\ncannot duplicate them \u2014 but the network can still deliver the same event twice.\n\n### Testing locally\n\nBoth `https` and `http` endpoints are accepted, including private addresses.\nhostr does not block internal ranges, so a local receiver works in development\nexactly as production does.\n\n```bash\nnpx @hostr/cli webhooks test # redelivers the newest event to your endpoint\n```\n\nSee the [events reference](#event-reference) for every type and payload.\n\n## Verifying webhook signatures\n\nEvery delivery carries a `hostr-signature` header:\n\n```\nhostr-signature: t=1756130400,v1=3b1f2c9d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c\n```\n\n`v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`, hex-encoded. `t` is a unix\ntimestamp in **seconds**. This is the Stripe scheme, and Mux\'s.\n\n### Implementation\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n```\n\nWired into Express, where getting the raw body needs care:\n\n```js\nimport express from \'express\';\n\nconst app = express();\n\napp.post(\'/hostr-webhook\',\n express.raw({ type: \'application/json\' }), // NOT express.json()\n (req, res) => {\n const raw = req.body.toString(\'utf8\');\n if (!verifyHostrWebhook(process.env.HOSTR_WEBHOOK_SECRET, req.get(\'hostr-signature\'), raw)) {\n return res.status(400).end();\n }\n const event = JSON.parse(raw); // parse only after verifying\n res.status(200).end(); // ack fast\n queue.push(event); // then do the work\n });\n```\n\n### The three ways this goes wrong\n\n**Using the parsed body instead of the raw bytes.** `JSON.parse` followed by\n`JSON.stringify` does not reproduce the original bytes \u2014 key order, whitespace,\nand number formatting can all shift. The HMAC is over what was actually sent.\nRegister the raw body parser on this route specifically.\n\n**A plain `===` on the hex digest.** String comparison short-circuits on the\nfirst differing character, which leaks timing information. Use\n`timingSafeEqual`, and compare lengths first \u2014 it throws on mismatched lengths.\n\n**Ignoring `t`.** Without a bounded timestamp window a captured delivery can be\nreplayed at any time in the future. Five minutes is a reasonable tolerance.\n\n### Do not confuse the units\n\n`t` is unix **seconds**. Elsewhere in the API, `tokenExpiresAt` and `expiresAt`\nare epoch **milliseconds**. That is deliberate \u2014 each convention matches its home\n(Stripe\'s for signatures, Mux\'s for token bundles) rather than being made falsely\nuniform. Mixing them up produces a timestamp roughly 55,000 years out, which at\nleast fails loudly.\n\n### Getting the secret\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nWritten straight to the file at mode 0600; only the variable name is printed. The\nsecret is returned **once**, in the `PUT /v1/webhooks` response, and never again\n\u2014 `GET /v1/webhooks` deliberately does not include it. Lost it? Set a new one.\n\n### Proving it end to end\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\nRedelivers the newest stored event to your endpoint, so you can confirm the whole\npath without uploading anything.\n\nIf your endpoint was down when an event fired, nothing was lost \u2014 see\n[Webhooks and the event log](#webhooks-and-the-event-log).\n\n## Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `playback`-scoped\nkey.\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"vp_5b0e2a7c9d1f4e83"} (body optional)\n -> { playback, thumbnail, storyboard, expiresAt }\n```\n\n```bash\nnpx @hostr/cli assets token <assetId> --json\n```\n\nField names mirror Mux\'s bundle, so a consumer migrating swaps its base URL and\nkeeps its client code.\n\n### Build the URL\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\nHand it to any HLS player. Safari and iOS play HLS natively; elsewhere use\nhls.js.\n\n```html\n<video id="v" controls></video>\n<script type="module">\n import Hls from \'https://cdn.jsdelivr.net/npm/hls.js@1/+esm\';\n const src = `https://stream.hostr.sh/${playbackId}.m3u8?token=${token}`;\n const v = document.getElementById(\'v\');\n if (v.canPlayType(\'application/vnd.apple.mpegurl\')) v.src = src;\n else if (Hls.isSupported()) { const h = new Hls(); h.loadSource(src); h.attachMedia(v); }\n</script>\n```\n\n### Mint timing, and what to gate on\n\nMinting works any time after the asset exists \u2014 it 404s only for unknown or\ndeleted assets. **The stream itself 404s until the asset is `ready`.**\n\nSo gate your UI on `video.ready` or the asset\'s status, never on whether minting\nsucceeded. A successful mint for a still-encoding asset is expected behaviour,\nnot a green light.\n\n### The TTL is not a viewing limit\n\nThe token is checked **once**, at the master playlist. After that the session\ncarries its own internally-managed credentials, so a viewer who starts playback\nat minute 59 of a one-hour token watches the whole video.\n\n`ttlSec` accepts 60 to 86,400 and defaults to 3,600. It only needs to be valid\nwhen playback *starts*.\n\n### viewerId\n\nOptional, opaque, and carried inside the token. It enables per-viewer concurrency\nobservation and attributes watch data to a viewer. Concurrency is currently\nobserved rather than enforced \u2014 see [Tokens and signed playback](https://docs.hostr.sh/concepts/playback/).\n\n**Send a pseudonym, never a user id or email.** The token sits in playback\nURLs, and anyone holding a URL can read its claims. Use a random id you store\nagainst the user (for example a UUID column) and send that. It stays stable\nfor the user, so watch history stays joined, and deleting it erases the link\nback to the person. Do not derive it from the user id with a hash: anyone who\ncan guess user ids can recompute it, and it cannot be erased per user. Choose\nbefore you start sending one, because changing it later splits that viewer\'s\nhistory in two. Omit it for signed-out viewers; they are counted, not\nattributed.\n\n### Things that will confuse you once\n\n**Non-browser user agents are rejected** with `403 blocked_user_agent` \u2014\ncurl, wget, yt-dlp, vlc, and ffmpeg UAs are blocked as a casual-ripping speed\nbump. A server-side smoke test must send a browser-like `User-Agent` or hit\n`/health`, which is exempt. Expect it; do not debug it.\n\n**Signed URLs are not DRM.** They stop link-sharing and hotlinking, not an\nentitled viewer recording their screen.\n\n**Deleted assets stop minting immediately**, but tokens already issued can replay\nedge-cached segments for a few hours until they expire.\n\n### Poster and scrub previews\n\nThe same mint response covers both, each URL taking its matching token:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSee [Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Audio only, for transcription\n\nThe `playback` token also opens an audio-only file, available seconds into the\nencode \u2014 before the asset is playable:\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nSee [Audio for speech-to-text](#audio-for-speech-to-text).\n\n## Thumbnails and storyboards\n\n`POST /v1/assets/:id/tokens` returns three tokens, not one. The `thumbnail` and\n`storyboard` values drive the poster image and the scrub preview.\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSame host, same query-token shape as the playlist. Each URL takes its **own**\ntoken \u2014 they are minted with different audiences and are not interchangeable.\n\n### Poster\n\nA single JPEG extracted during encoding. Use it as the `poster` attribute:\n\n```html\n<video poster="https://stream.hostr.sh/abc123/thumbnail.jpg?token=..." controls></video>\n```\n\n### Choosing the poster frame\n\nThe encode-time poster is a frame 10% in \u2014 a fine default and, often enough,\nthe wrong picture. `POST /v1/assets/:id/poster` re-takes it from any second:\n\n```json\nPOST /v1/assets/{id}/poster\n{ "time": 12.5 }\n\n202 { "assetId": "...", "rev": "mfc9z0aa", "time": 12.5, "queued": true }\n```\n\nThe frame is cut from the best rung the ladder has published \u2014 a `playable`\nasset works, at the quality it has so far \u2014 and lands under its own immutable\nkey. `thumbnail.jpg` keeps its URL and changes content: wait for\n`video.poster_ready` (it carries the same `rev`), then **mint a fresh token\nbundle** \u2014 a `thumbnail` token minted before the job landed keeps serving the\nold picture until it expires. Store a copy if you serve posters from your own\nCDN; that is what the event is for.\n\nPoster jobs run on their own lane: a running worker takes them beside\nwhatever it is encoding, so a frame never waits behind a ladder. What it can\nwait behind is a cold start \u2014 the fleet scales to zero and a machine is about\nseven seconds from taking a job. `POST /v1/assets/:id/poster/warm` starts one\nahead of time; call it when a user opens your thumbnail editor and the frame\nthey pick a moment later is seconds away. It is a no-op while any machine is\nup, and costs one machine\'s idle window when nothing follows.\n\nThe record says which frame is live (`posterRev`, `posterTime`) and whether a\njob is running (`poster`, `null` once it lands). One job at a time per asset;\na failed or ten-minute-stale request no longer blocks the next one. There is\nno way to render an arbitrary frame on request \u2014 the media host has no\ndecoder \u2014 so build a scrubber on the storyboard below and confirm with one\ncall, rather than previewing through this endpoint.\n\n### Storyboard\n\nA standard "thumbnails" WebVTT track. Cues point into a tiled sprite using\n`#xywh=` fragments, and **the sprite URLs inside the VTT are pre-signed for you**\n\u2014 you do not mint anything extra.\n\nThat is the convention mux-player\'s `storyboard-src`, media-chrome\'s preview\ntrack, and most other players already consume:\n\n```html\n<mux-player\n storyboard-src="https://stream.hostr.sh/abc123/storyboard.vtt?token=..."\n></mux-player>\n```\n\n### Treat both as optional\n\nAssets encoded before storyboards existed 404 on that URL, and scrubbing simply\nshows no preview. Players handle this gracefully by design \u2014 do not add error\nhandling that turns a missing preview into a broken player.\n\n## Audio for speech-to-text\n\nIf you want hostr to do the transcription, see\n[Auto-captions](#auto-captions-and-translation). This page is for bringing your own\ntranscriber.\n\nTranscription services want one audio file URL, not an HLS playlist. hostr\nmakes one per asset \u2014 `audio.m4a`, a single AAC track in an MP4 container \u2014\nand makes it **first**, ahead of every video rendition. A twenty-minute\nsource has its audio file within seconds of the job starting, minutes before\n`video.playable`.\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nIt takes the **`playback`** token from `POST /v1/assets/:id/tokens` \u2014 the same\none that opens the master playlist. There is no fourth token to mint.\n\n### When it exists\n\nListen for **`video.audio_ready`**. It fires once, the moment the file is in\nthe media bucket, and carries `assetId`, `playbackId`, `durationSec` and an\n`audio` block (`bytes`, `codec`, `channels`, `sampleRate`, `copied`).\n\n```json\n{\n "id": "evt_01j\u2026",\n "type": "video.audio_ready",\n "createdAt": "2026-09-08T10:14:07.412Z",\n "data": {\n "assetId": "9f8c1e2a\u2026",\n "playbackId": "9f8c1e2a\u2026",\n "status": "processing",\n "durationSec": 1842.4,\n "audio": { "bytes": 29478912, "codec": "aac", "channels": 2, "sampleRate": 48000, "copied": true }\n }\n}\n```\n\nThe event also carries `loudness` \u2014 the probe\'s EBU R128 measurement of the\nsource, see [the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\nThe same block is on the asset record as `audio`. It is **absent** until the\ntranscoder has looked at the source and **`null`** when the source has no\naudio stream \u2014 in which case `video.audio_ready` never fires, and a poller can\ntell "no audio" from "not yet" without waiting.\n\nBefore the file exists the URL is a plain `404`.\n\n### Assets you uploaded before this existed\n\nEvery upload since 2026-09-09 gets its audio file automatically. Assets\nencoded before that have none, and nothing will produce one on its own \u2014 so a\ncatalogue uploaded before you added transcription cannot be captioned until\nyou ask for it. Ask once per asset:\n\n```\nPOST /v1/assets/{id}/audio\n```\n\nIt requires the `upload` scope (this is content generation, the same rung as\nattaching a subtitle track) and answers `202 {"assetId": "\u2026", "queued": true}`.\nThe job publishes `audio.m4a` and nothing else \u2014 no re-encode of the ladder,\nno new playlists, no change to the asset\'s status \u2014 and then fires\n`video.audio_ready` exactly as a fresh upload would, so the consumer you\nalready wrote needs no second code path.\n\nTwo refusals, both `409`: `already_has_audio` when the record already carries\nan `audio` block, and `not_encoded` when the asset has no finished source\nobject yet (an upload still in flight, or aborted). Calling it twice is safe\nregardless \u2014 a job that finds the file already in the bucket reports it\ninstead of re-encoding.\n\nTo sweep a whole account, the CLI does the paging and the per-asset check:\n\n```bash\nnpx @hostr/cli assets audio --missing # every playable/ready asset with no audio\nnpx @hostr/cli assets audio <assetId> # just this one\n```\n\n`--missing` skips assets whose `audio` is `null` \u2014 the transcoder already\nlooked and the source is silent, so there is nothing to extract.\n\n### Handing it to a transcriber\n\nMint a token whose lifetime covers the fetch, then pass the URL straight\nthrough. AssemblyAI, for example:\n\n```bash\nTOKEN=$(npx @hostr/cli assets token "$ASSET" --ttl 3600 --json | jq -r .playback)\n\ncurl -X POST https://api.assemblyai.com/v2/transcript \\\n -H "authorization: $ASSEMBLYAI_API_KEY" \\\n -H \'content-type: application/json\' \\\n -d "{\\"audio_url\\":\\"https://stream.hostr.sh/$ASSET/audio.m4a?token=$TOKEN\\"}"\n```\n\nThe token only has to be valid when the fetch **starts**, but give it a few\nminutes of headroom: services queue downloads. `Range` requests are honoured\n(`206`, `Accept-Ranges: bytes`), so a fetcher that streams or resumes works.\n\n**The user-agent blocklist does not apply to this URL.** Playlists reject\ncurl-like user agents as a casual-ripping speed bump; a transcription\nservice\'s fetcher is exactly the kind of client that rule would catch, so the\naudio file is exempt. The token is still required.\n\n### What is in the file\n\n- **AAC source, no normalisation** \u2014 the stream is copied untouched: the\n source\'s channel count, sample rate and bitrate, no decode. `copied: true`.\n- **Anything else** \u2014 encoded once with the ladder\'s own audio settings (AAC,\n 48 kHz, the top group\'s bitrate, the same filter chain), so what the\n transcriber hears is what the viewer hears. `copied: false`. An asset with\n [normalisation](https://docs.hostr.sh/concepts/ladder/#audio-options) on is always this case:\n the file must carry the same gain the viewer gets.\n\nEither way the `moov` atom is at the front, and the object is versioned with\nthe asset and immutable.\n\n### Then attach the result\n\nWhen the transcript comes back, attach it with\n`PUT /v1/assets/:id/subtitles/:lang` (raw WebVTT). It is accepted as soon as the\nasset has a known duration, which `video.audio_ready` guarantees \u2014 so a\ntranscript that comes back before the asset is `playable` attaches without\nwaiting or retrying. See [Subtitles](#subtitles).\n\n## Subtitles\n\nHuman-authored WebVTT tracks, attachable any time after the asset is `ready`.\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { assetId, tracks: [{ lang, name, default, rev, source? }] }\nDELETE /v1/assets/:id/subtitles/:lang\n```\n\n```bash\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\nnpx @hostr/cli subtitles list <assetId>\nnpx @hostr/cli subtitles rm <assetId> en\n```\n\nThe PUT body is **raw WebVTT, not JSON**. It must start with `WEBVTT`, and is\ncapped at 2,000,000 bytes.\n\n```bash\ncurl -X PUT "https://api.hostr.sh/v1/assets/$ASSET/subtitles/en?name=English&default=true" \\\n -H "authorization: Bearer $HOSTR_API_KEY" \\\n -H \'content-type: text/vtt\' \\\n --data-binary @subs.vtt\n```\n\n### Rules\n\n**`:lang` is a BCP 47 tag** \u2014 `en`, `pt-BR`. One track per tag; PUT replaces.\nAnything that is not a valid tag is `400 bad_language`.\n\n**The asset must have a known duration.** Before that it is `409 not_ready` \u2014\nthe track playlist needs it. It is known from `video.audio_ready` (seconds\ninto the job, once the transcoder has probed the source) and from `playable`\nonwards; a track attached while the ladder is still encoding appears the\nmoment the master playlist is served, because tracks are injected at serve\ntime, not baked into the encode.\n\n**`source` says who wrote it.** Absent on your tracks; `auto` / `auto-translated` on\nmachine-made ones (see [Auto-captions](#auto-captions-and-translation)). A PUT always makes the\ntrack yours.\n\n**`default=true` demotes any previous default.** At most one default per asset.\n\n**Write scope is `upload`, not `playback`.** Subtitles are content, not\nentitlement.\n\n### How they reach the player\n\nTracks are injected into the master playlist at serve time as an `EXT-X-MEDIA`\nsubtitles group. The next master fetch after a PUT includes them.\n\n**No re-encode. No new playback URL. No token change.** Players pick them up in\nthe captions menu automatically.\n\nTracks are stored under a content-hash revision, so replacing one is cache-safe\neven though segments are served with a one-year immutable cache. A viewer already\nmid-session keeps the track they started with; the replacement applies to the next\nplayback session.\n\n### Deleting\n\nRemoval delists the track first \u2014 that is what makes it invisible to players. The\nunderlying versioned objects become unreachable and are purged with the asset, so\nleftovers cost nothing.\n\n`404 subtitle_not_found` means no track for that tag; `404 asset_not_found` means\nno such asset. They are distinct codes.\n\n## Auto-captions and translation\n\nhostr transcribes an asset\'s audio and attaches the result as an ordinary\n[subtitle track](#subtitles), then translates it into the languages\nyou name. Nothing re-encodes; players pick the tracks up from the master\nplaylist like any other.\n\n```\nPOST /v1/uploads { ..., "captions": { "language": "auto", "translate": ["en","es"], "context": "Creator: Ana" } }\nPOST /v1/renders same field\nPOST /v1/assets/:id/render same field \u2014 applied when the new cut lands\nPOST /v1/assets/:id/captions transcribe now, or redo (body optional)\nPOST /v1/assets/:id/captions/translate { "languages": ["fr"] } \u2014 from the stored transcript\nGET /v1/assets/:id the `captions` block: transcript + per-language state\nGET /v1/assets/:id/subtitles tracks carry `source`: absent (yours), `auto`, `auto-translated`\n```\n\nWriting captions \u2014 the `captions` field and both `POST` routes \u2014 needs `upload`\nscope: captions are content, not entitlement. Reading them back\n(`GET /v1/assets/:id`, `GET \u2026/subtitles`) needs only `read`.\n\n### When it runs\n\nWith `captions` on the upload, transcription starts the moment `audio.m4a`\nexists \u2014 seconds into the encode, minutes before `video.playable` \u2014 so on a\nnormal source the tracks are there before the video is. Translations follow\none by one as `captions.ready` events; the source language lands first.\n\n`POST /v1/assets/:id/captions` runs any time the asset has audio. Unlike Mux\'s\nseven-day window on generated subtitles, there is no time limit. With no body it\nreuses what was asked for last time. It answers:\n\n```\n202 { "assetId": "\u2026", "rev": "mfc9z0aa", "queued": true }\n```\n\n`rev` is the new run\'s id. Keep it: every `captions.*` event carries the `rev`\nof the run it is about, and an event whose `rev` is not the one you hold is\nfrom a run this one replaced.\n\n`context` is free text handed to the transcriber to help with names and\nvocabulary \u2014 creator, title, domain terms. It is trimmed and cut at **1000\ncharacters**; a longer value is truncated, not refused.\n\nOnly one run exists per asset at a time. While a run is at the transcription\nprovider, a second `POST` answers `409 captions_in_progress`. So does one that\narrives within two minutes of a run starting, before it has reached the\nprovider, and so does the loser of two simultaneous requests. A run that never\nproduces a transcript is failed with `asr_failed` after two hours, which\nreleases the asset for a new one.\n\nA transient answer from the provider (a rate limit, a 5xx) is not a failure:\nthe completion is retried by the provider\'s callback or by hostr\'s five-minute\npoll, and nothing is reported until the provider says the job failed or the two\nhours are up.\n\n### It is metered\n\nEvery run counts the source duration as **transcription minutes** for the UTC\ncalendar month. Translation is not metered. `GET /v1/account` shows\n`captions: { minutesUsed, minutesCap, resetsAt }`, and so does the dashboard\'s\nUsage page. `minutesCap` has three meanings:\n\n| `minutesCap` | Meaning |\n|---|---|\n| `0` | Captions are off for the account. Ask hostr to enable them. |\n| `null` | Captions are on with no monthly limit. Every minute is still counted. |\n| a number | Captions are on, up to that many minutes this month. |\n\nThe cap is only a ceiling; `minutesUsed` (and the daily `captionSeconds` on\n`GET /v1/usage`) is the count either way.\n\nThe explicit routes refuse **before** spending: `403 captions_cap_exceeded`\n(or `captions_not_enabled`) with `minutesUsed`, `minutesCap` and `resetsAt` in\nthe body. `POST \u2026/captions/translate` refuses `captions_not_enabled` the same\nway. The upload path cannot know the duration yet, so there the cap refusal is\nasynchronous: `captions.errored` with `reason: cap_exceeded`, and the `captions`\nblock on the asset says the same. A key whose account has captions off (cap 0)\nis refused at create time on `POST /v1/uploads`, `POST /v1/renders` and\n`POST /v1/assets/:id/render` with `403 captions_not_enabled`, and the same\nthree fields. A `captions` value that is not an object (`null` included) is\n`400 bad_captions`.\n\n### Your tracks win\n\nOne track per language tag. A track you upload with\n`PUT /v1/assets/:id/subtitles/:lang` has no `source` and is yours: a redo never\nreplaces it, and a requested language it owns is reported in `skippedLangs`\nrather than written. That is the correction loop: download the auto VTT, fix\nit, PUT it back, and it stays fixed through every redo. PUT over an auto track\nmakes it yours.\n\nThis is enforced when the track is written, not only when the run is planned: a\ntrack you upload while a translation is running is never replaced, and the run\nreports that language in `skippedLangs` instead.\n\nAfter a [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) the stored request is\napplied again under a new run, because the old captions are timed to the old\ncut. A track you corrected is still skipped \u2014 the `captions.ready` event\'s\n`skippedLangs` tells you it is now mistimed and needs your attention. A plain\nre-encode changes no timing and does nothing to captions.\n\n### Languages\n\n`language` is `auto` (detected) or any BCP 47 tag you know the speech is in.\n`translate` takes any tags, at most 10 per request; a target in the source\nlanguage is dropped \u2014 any variant of it, so from Spanish speech `es-MX` is\ndropped too (that is localisation, not translation). The detected source track\nis written under the primary tag (`en`, not `en-US`): the transcriber detects\nlanguages, not regional variants. A declared one is written under exactly what\nyou said (`pt-BR`).\n\nA translated track\'s region comes only from the tag you ask for: `es-MX`,\n`es-419` (Latin America) and `es-ES` are three separate tracks, and the target\n\u2014 region included \u2014 is named to the translation model. One Spanish target is\nusually enough; `es-419` is the common choice for a Latin American audience.\n\nTrack names are the language\'s own name plus the suffix, with a region or\nscript named alongside it so variants are told apart in the player menu:\n`Espa\xF1ol (auto)`, `English (auto-translated)`, `Espa\xF1ol (M\xE9xico, auto-translated)`,\n`Portugu\xEAs (Brasil, auto)`, `\u4E2D\u6587 (\u7E41\u9AD4, auto-translated)`.\n\n### Reading progress: the `captions` block\n\n`GET /v1/assets/:id` carries the asset\'s latest run as `captions` (absent if\ncaptions were never requested). A new run, a redo or a re-edit replaces it.\n\n```json\n"captions": {\n "rev": "mg3k1z9q",\n "requestedAt": "2026-10-09T15:00:00.000Z",\n "request": { "language": "auto", "translate": ["en", "es"], "context": "Creator: Ana" },\n "transcript": { "status": "ready", "lang": "pt", "durationSec": 1842.4, "attempts": 1,\n "submittedAt": "2026-10-09T15:00:04.000Z", "finishedAt": "2026-10-09T15:02:11.000Z" },\n "tracks": {\n "pt": { "status": "ready", "source": "auto", "name": "Portugu\xEAs (auto)", "rev": "9c1e2a4b6d47", "attempts": 0, "updatedAt": "\u2026" },\n "en": { "status": "ready", "source": "auto-translated", "name": "English (auto-translated)", "rev": "b6d47f0a1b2c", "attempts": 1, "updatedAt": "\u2026" },\n "es": { "status": "pending", "source": "auto-translated", "attempts": 1, "updatedAt": "\u2026" }\n },\n "skippedLangs": [],\n "inFlight": true\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `rev` | The run\'s id. Every `captions.*` event carries it; ignore events whose `rev` is not the asset\'s current one. |\n| `request` | What was asked for. A redo with no body, and every re-edit, reuses it. |\n| `transcript.status` | `pending` (waiting for the audio file), `submitted` (at the transcription provider), `ready`, or `failed`. |\n| `transcript.lang` | The spoken language, as the source track\'s tag. Detected languages use the primary tag (`en`, not `en-US`). |\n| `transcript.reason`, `transcript.error` | On `failed`: why. `reason` is one of the codes under [Events](#events). |\n| `tracks.<lang>.status` | `pending` (being made), `ready` (serving), or `failed` (`error` says why). |\n| `tracks.<lang>.source` | `auto` for the spoken language, `auto-translated` for the rest. |\n| `tracks.<lang>.rev`, `name` | The subtitle track\'s content revision and menu label, once written. |\n| `tracks.<lang>.attempts` | Translation attempts so far. |\n| `skippedLangs` | Requested languages left alone because a track you uploaded owns them. |\n| `inFlight` | `true` while the transcript or any track is still owed. Stop polling when it is `false`. |\n\nOn `GET /v1/assets/:id/subtitles`, a track\'s `source` is the same marker:\n`auto`, `auto-translated`, or absent. **Absent means hostr treats the track as\nyours**, including any machine tracks you attached with your own pipeline\nbefore you used hostr captions. A redo will not replace those. To have hostr\ntake a language over, delete that track first\n(`DELETE /v1/assets/:id/subtitles/:lang`), then redo.\n\n### From the terminal\n\n```bash\nnpx @hostr/cli upload video.mp4 --captions --translate en,es --context "Creator: Ana"\nnpx @hostr/cli captions status <assetId> # the captions block, summarised\nnpx @hostr/cli captions request <assetId> # redo with the stored request\nnpx @hostr/cli captions request <assetId> --lang pt --translate en,es\nnpx @hostr/cli captions translate <assetId> --lang fr,de\n```\n\nEvery command takes `--json`. `captions request` and `captions translate`\nneed `upload` scope.\n\n### Errors\n\n| Status | `error` | When |\n|---|---|---|\n| 400 | `bad_captions` | The body or `captions` field is malformed (`hint` says which), `null`, or a `languages` list is empty or invalid |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`). Body carries `minutesUsed`, `minutesCap`, `resetsAt` |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly cap. Same three fields |\n| 409 | `not_ready` | No audio yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | The source has no audio stream |\n| 409 | `captions_in_progress` | A run is at the provider, started under two minutes ago, or a concurrent request won |\n| 409 | `no_transcript` | `\u2026/translate` before a transcript exists |\n| 409 | `asset_unowned` | The asset has no owning account |\n| 502 | `asr_submit_failed` | The provider refused the submission (or could not be reached); `hint` carries its error. Nothing was metered for it. A submission the provider *accepted* is metered even if another request replaced the run meanwhile \u2014 that answers `409 captions_in_progress`, not 502 |\n| 503 | `captions_unavailable` | No transcription provider on this deployment |\n| 503 | `translate_unavailable` | No translation queue or provider on this deployment |\n\n### Events\n\n| Type | When | `data` |\n|---|---|---|\n| `captions.ready` | the transcript step finished, then once per translated track | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written` (false when a manual track owned the slot), `trackRev?`, `skippedLangs`, `pending` (languages still to come) |\n| `captions.errored` | a run or one language failed | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n\n`reason` is one of `not_enabled`, `cap_exceeded`, `no_audio` (silent source, or\nthe transcode failed first), `provider_unavailable`, `asr_failed`,\n`translate_unavailable`, `translate_failed`. A failed language never blocks the\nothers.\n\n`captions.errored` fires **at most once per run-level failure** (the transcript\nstage; it has no `lang`) and **once per failed language**. A second report of\nthe same failure, such as the provider\'s callback and hostr\'s poll both seeing\nit, does not repeat it.\n\n### What is stored\n\nThe transcript and the source VTT are kept with the asset, so\n`POST /v1/assets/:id/captions/translate` never transcribes again. Both are\npurged with the asset.\n\nTwo third parties see your content. The audio goes to the transcription\nprovider (AssemblyAI). Translation sends the transcript\'s text, cue by cue, to\nan LLM provider (Anthropic). The `context` string goes only to the\ntranscription provider, never to the translation model.\n\n## Event reference\n\nDelivered body: `{ id, type, createdAt, data }`. `id` is the durable event row\'s\nid \u2014 **dedupe on it**, and note that a redelivery reuses it deliberately.\n\n| Type | Fired when | `data` highlights |\n|---|---|---|\n| `upload.created` | Multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `upload.aborted` | Session ended without completing | `assetId`, `reason` (`client_abort` \\| `abandoned`), `error?` |\n| `video.uploaded` | Bytes verified in storage, transcode queued \u2014 or a render\'s join is ready | `assetId`, `key`, `bytes`, `etag?`, `sha256?`, `rendered?` |\n| `video.audio_ready` | `audio.m4a` exists \u2014 seconds in, before anything is playable | `assetId`, `playbackId`, `durationSec`, `audio`, `loudness` |\n| `video.playable` | Watchable at one quality, ladder still encoding | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.ready` | Full HLS ladder live | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.errored` | Transcode failed | `assetId`, `error` (always present) |\n| `video.poster_ready` | A chosen poster frame is live behind `thumbnail.jpg` (`POST /v1/assets/:id/poster`) | `assetId`, `playbackId`, `rev`, `time` |\n| `captions.ready` | One language finished (one event per language, the source language first): its track landed, or \u2014 `written: false` \u2014 a track you uploaded owned the slot and was kept | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written`, `trackRev?`, `skippedLangs`, `pending` |\n| `captions.errored` | A captions run, or one language of it, failed. At most once per run-level failure, once per failed language | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n| `video.deleted` | Asset erased, playback over | `assetId` |\n| `video.input.non_standard` | The source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`upload.aborted`, `video.playable`, `video.ready` and `video.errored` fire on the\nstatus **transition** only, so a repeated abort, or a transcoder re-posting a\nstate, cannot duplicate them.\n\n`upload.aborted` has one meaning \u2014 this upload ended without completing \u2014 and\n`reason` says how it got there: `client_abort` for `DELETE /v1/uploads/:id`, or\n`abandoned` for an upload the platform collected after 14 days idle (see\n[Assets and lifecycle](#assets-and-lifecycle)). Treat both as terminal; the reason\nis for your own reporting, not for deciding whether the upload is over.\n\n`video.audio_ready` fires **once per asset**, on the first report that carries\nthe audio file, and never for a source with no audio stream \u2014 the record\'s\n`audio` field is `null` there. It is the cue to start speech-to-text: see\n[Audio for speech-to-text](#audio-for-speech-to-text).\n\n`loudness` is the probe\'s EBU R128 measurement of the source and the gain the\nencode applied \u2014 `integratedLufs`, `loudnessRangeLu`, `truePeakDbtp`, `mono`,\n`normalization` \u2014 for creator warnings and playback-side gain. `null` on a\nsource with no audio stream. See\n[the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\n`video.playable` carries the same fields as `video.ready`, so publishing as early\nas possible is a matter of switching which one you listen for. Its `renditions`\nlists the single quality available so far; `video.ready` then lists the full\nladder. `video.errored` fires only when nothing is watchable \u2014 a ladder that\nfails after the asset is already playable does not fire it.\n\n`error` on `video.errored` is always present. An optional error field makes every\nconsumer invent its own generic message, which is worse than a bad message.\n\n`video.ready` fires **once more** when an asset is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding), carrying `reencoded: true` and the\nnew `rev`. Same shape, same meaning \u2014 a full ladder is live \u2014 so a consumer that\nonly stores "ready" needs no change; one that caches a poster should refresh it.\nA [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) landing also carries\n`rendered: true`: the content changed, so its duration may have too, and\ncaptions made for the old version no longer line up.\n\n### captions.*\n\nMachine captions: `captions.ready` fires once for the transcribed language and once\nper translated track; `captions.errored` for a failed run or a failed language. `rev`\nidentifies the run. Fields, `reason` values and the metering rules are in\n[Auto-captions and translation](https://docs.hostr.sh/guides/captions/#events).\n\n### video.input.non_standard\n\nAdvisory. The transcode proceeds normally and a `video.ready` still follows. It\nfires **at most once per asset**, and only when there is something to say \u2014 a\nfile with nothing unusual about it generates no traffic.\n\n| Finding code | Meaning |\n|---|---|\n| `hdr_tags_on_8bit` | Source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | Genuine 10-bit HLG or PQ HDR. Converted to 8-bit BT.709 SDR before the ladder split; HDR is not delivered (assets encoded before 2026-09-10 were relabelled instead \u2014 [re-encode](https://docs.hostr.sh/concepts/assets/#re-encoding) them) |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer. Converted to BT.709 at 10-bit; at 8-bit the tag is not trusted and the pixels are left alone |\n| `variable_frame_rate` | Container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | Source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe \u2014 `container`, `videoCodec`, `pixFmt`,\n`bitDepth`, `width`, `height`, `rotation`, `declaredFps`, `averageFps`,\n`targetFps`, `isVariableFrameRate`, `colorSpace`, `colorTransfer`,\n`colorPrimaries`. `width` and `height` are the *display* size \u2014 a portrait\nphone recording stores landscape pixels with a 90\xB0 rotation, and the ladder\nis built on what the viewer sees, not on how the frames are stored.\n\nThe same block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\n### Delivery\n\nThree attempts with short backoff, each recorded and inspectable. Every event is\nwritten to the durable log **before** the first attempt.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim\nGET /v1/events?order=desc&limit=1 newest first\nPOST /v1/events/:id/redeliver replay to the current endpoint\n```\n\nSignature verification: [Verifying webhooks](#verifying-webhook-signatures).\n\n## Error reference\n\nEvery non-2xx body is `{ "error": "<code>", ... }`. **Switch on `error`, never on\nthe message** \u2014 codes are stable, prose is not.\n\n| Status | Error | Meaning and action |\n|---|---|---|\n| 400 | `size_required` | `size` missing or not positive on `POST /v1/uploads` |\n| 400 | `partNumbers_required` | Empty or non-array `partNumbers` |\n| 400 | `too_many_parts_per_request` | More than 100 part numbers; `max` names the cap |\n| 400 | `part_out_of_range` | A part number below 1 or above `expectedParts` |\n| 400 | `bad_language` | Not a BCP 47 tag |\n| 400 | `bad_captions` | `captions` (or the body of `\u2026/captions`, `\u2026/captions/translate`) is malformed, `null`, or names no valid languages; `hint` says which |\n| 400 | `not_webvtt` | Subtitle body does not start with `WEBVTT` |\n| 400 | `bad_status` | Status outside the allowed set (internal route) |\n| 400 | `bad_scopes` | A scope outside `read`/`upload`/`playback`/`full` |\n| 400 | `bad_date` | A date parameter was not `YYYY-MM-DD` |\n| 400 | `authorization_pending` | Device login not yet approved \u2014 keep polling |\n| 400 | `slow_down` | Polling too fast \u2014 back off, keep polling |\n| 400 | `expired_token` | Device code expired. Start over |\n| 400 | `access_denied` | Device login was declined |\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `quota_uploads_daily` | Daily upload cap; `limit` is the ceiling |\n| 403 | `quota_assets` | Asset count cap; `limit` is the ceiling |\n| 403 | `quota_storage` | Storage cap; `limit` is the ceiling |\n| 403 | `account_suspended` | Contact the operator |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`); ask hostr to enable them. `minutesUsed`, `minutesCap`, `resetsAt` are in the body |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly transcription minutes; same three fields. Retry after `resetsAt` or raise the cap |\n| 403 | `download_not_enabled` | Original downloads are not enabled for this account; the operator grants them |\n| 403 | `accountId_service_only` | Only the service key may name an owning account |\n| 403 | `blocked_user_agent` | Media host only \u2014 send a browser-like `User-Agent` |\n| 404 | `asset_not_found` | No such asset, or it has been deleted |\n| 404 | `subtitle_not_found` | No track for that language tag |\n| 404 | `event_not_found` | No such event for this account |\n| 404 | `key_not_found` | No such live key for this account |\n| 404 | `not_configured` | No webhook endpoint configured |\n| 404 | `account_not_found` | Key authenticated but its account row is gone |\n| 409 | `incomplete_upload` | Parts missing; `missing` lists them. Upload and re-complete |\n| 409 | `size_mismatch` | Declared size does not match bytes landed. Abort and restart |\n| 409 | `not_uploading` | Upload already finished or aborted |\n| 409 | `not_ready` | Asset is not `ready`, so its duration is unknown. On `POST \u2026/captions`: no audio file yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | `POST \u2026/captions` on a source with no audio stream |\n| 409 | `captions_in_progress` | A captions run is at the provider, started under two minutes ago, or a concurrent request won. Wait for `captions.ready` / `captions.errored` |\n| 409 | `no_transcript` | `POST \u2026/captions/translate` before a transcript exists; request captions first |\n| 409 | `asset_unowned` | The asset has no owning account, so captions cannot be metered; claim it for an account first |\n| 409 | `no_webhook_endpoint` | Nothing configured or enabled to redeliver to |\n| 413 | `file_too_large` | Too many parts at this part size; raise `partSize` |\n| 413 | `subtitle_too_large` | Over 2,000,000 bytes |\n| 429 | `rate_limited` | `POST /v1/device/code` only. It is unauthenticated, so it is capped per IP. A real login makes one call \u2014 if you see this, something is looping |\n| 500 | `signing_not_configured` | Operator-side: the playback signing key is not loaded. Not your fault, not retryable. Report it |\n| 500 | `internal` | Unexpected. `message` carries truncated detail |\n| 502 | `asr_submit_failed` | The transcription provider refused a captions submission (or could not be reached); `hint` carries its error. Nothing was metered for it. Retry later. (A submission the provider accepted is metered; if another request replaced the run meanwhile, that is `409 captions_in_progress`.) |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n| 503 | `captions_unavailable` | No transcription provider is configured on this deployment. Operator-side; not retryable by you |\n| 503 | `translate_unavailable` | No translation queue or provider is configured on this deployment. Operator-side |\n\n### Retry guidance\n\n**Retry with backoff:** `503 control_plane_unavailable`, `500 internal`,\n`400 authorization_pending`, `400 slow_down`.\n\n**Fix and retry:** the `409` upload conflicts \u2014 they tell you exactly what is\nwrong (`missing` part numbers, or both byte counts).\n\n**Do not retry unchanged:** every `400`, `401`, `403`, and `404`. A quota `403`\nin particular will fail identically forever until the account limit is raised.\n\n### The 401 conflation is deliberate\n\n`unauthorized` covers both "bad credential" and "not your asset". Distinguishing\nthem would let anyone holding a valid key enumerate other accounts\' asset ids by\nwatching which error came back.\n\n## CLI reference\n\n```bash\nnpx @hostr/cli <command>\n```\n\nA single-file, zero-dependency bundle published as `hostr`. **Every command takes\n`--json`** \u2014 one JSON document on stdout, NDJSON for `tail --follow`, errors as\nJSON on stderr with exit code 1. Nothing needs prose parsing.\n\nRun it with **no arguments** for setup status and the single next step. That is\nthe intended entry point, for humans and agents alike.\n\n### Credentials\n\nPrecedence, highest first: `--key <k>`, then `HOSTR_API_KEY`, then the saved\nprofile.\n\nAPI base, highest first: `--api <url>`, then `HOSTR_API_BASE`, then the profile,\nthen production.\n\nProfiles live in `~/.config/hostr/config.json`, cleartext at mode 0600 \u2014 the\n`gh` and `wrangler` precedent.\n\n### Authentication\n\n```bash\nhostr login # browser-authorized; nothing is pasted\nhostr login --profile staging # name the profile\nhostr login --key hostr_sk_live_... # escape hatch for CI; skips the browser\nhostr whoami # account, plan, limits, live usage\n```\n\n`login` with no key opens your browser and uses the device-authorization flow.\nThe code and URL are printed to **stderr**, so `--json` stdout stays a single\nparseable document.\n\n### Uploading\n\n```bash\nhostr upload video.mp4 # returns when bytes are verified in storage\nhostr upload video.mp4 --wait # blocks until transcode is ready or failed\nhostr upload video.mp4 --content-type video/quicktime\nhostr upload # no file: opens a local drag-drop page\nhostr upload video.mp4 --captions --translate en,es --context "Creator: Ana" # captions on upload\n```\n\nWraps the SDK\'s Node adapter, so it carries the same resume and integrity\nguarantees. Kill it mid-transfer and re-run to continue.\n\n### Assets\n\n```bash\nhostr assets list --status ready --limit 20 --cursor <cursor>\nhostr assets get <id>\nhostr assets token <id> --ttl 3600 --viewer-id vp_5b0e2a7c # playback scope\nhostr assets audio <id> # audio-only rendition for an older asset (upload scope)\nhostr assets audio --missing # ...for every asset in the account that lacks one\nhostr assets reencode <id> # fresh ladder from the stored source (upload scope)\nhostr assets delete <id> # full scope\n```\n\n### Subtitles\n\n```bash\nhostr subtitles list <assetId>\nhostr subtitles set <assetId> subs.vtt --lang en --name English --default\nhostr subtitles rm <assetId> en\n```\n\n`--lang` is required and must be a BCP 47 tag. The asset must be `ready`.\n\n### Captions\n\n```bash\nhostr captions status <assetId> # the latest run: transcript + each language\nhostr captions request <assetId> # redo with the stored request (upload scope)\nhostr captions request <assetId> --lang pt --translate en,es --context "Creator: Ana"\nhostr captions translate <assetId> --lang fr,de # from the stored transcript; not metered\n```\n\n`--captions`, `--captions-lang`, `--translate` and `--context` on `upload` ask\nfor captions on the new asset; any one of them is enough. Captions must be\nenabled on the account \u2014 the refusal says how many of the month\'s minutes are\nused and when they reset. Watch `hostr tail --follow --type captions.ready`.\nSee [Auto-captions and translation](#auto-captions-and-translation).\n\n### Events\n\n```bash\nhostr tail # one page of the durable log\nhostr tail --follow # poll every 2s, NDJSON under --json\nhostr tail --type video.ready --limit 50 --after <cursor>\n```\n\n### Keys\n\n```bash\nhostr keys list\nhostr keys create --name ci --scopes upload --write-env .env\nhostr keys revoke <id>\n```\n\n`--scopes` takes a comma-separated list and **defaults to `playback`** \u2014 the\nright rung for an application key. `revoke` takes the `id` from `keys list`, not\nthe `keyId`.\n\n### Webhooks\n\n```bash\nhostr webhooks get\nhostr webhooks set https://app.example/hook --write-env .env\nhostr webhooks set https://app.example/hook --secret whsec_... # bring your own\nhostr webhooks set https://app.example/hook --rotate --write-env .env # replace the secret\nhostr webhooks delete\nhostr webhooks test # redeliver the newest event \u2014 proves your endpoint\n```\n\nRe-running `set` on a live endpoint keeps its secret (nothing is written to a\n`--write-env` file, and the output says so); pass `--rotate` to replace it.\n\n### Repo setup\n\n```bash\nhostr init # docs/hostr.md + AGENTS.md section + .env.example\nhostr init --dir ../other-repo\n```\n\nIdempotent. Re-running replaces the content between the `AGENTS.md` markers\nrather than appending.\n\n### Usage\n\n```bash\nhostr usage --from 2026-08-01 --to 2026-08-25\n```\n\nDefaults to the last 30 days.\n\n### --write-env\n\n```bash\nhostr keys create --scopes upload --write-env .env\nhostr webhooks set https://app.example/hook --write-env .env\n```\n\nWrites the secret into that file at mode 0600 and prints **only the variable\nname** \u2014 in `--json` mode too. Nothing else in the CLI ever prints a credential.\n\nThe point is that an agent driving your terminal can orchestrate a credential\nwithout the value ever entering its context or its transcript.\n\n## SDK reference\n\n```bash\nnpm install @hostr/upload\n```\n\nFour entry points, each bundled independently:\n\n| Entry | Platform | What the adapter supplies |\n|---|---|---|\n| `@hostr/upload` | **Trusted backend only** | `createUploadSession`, `mintUploadToken` \u2014 needs your API key |\n| `@hostr/upload/browser` | Web | XHR transport (for progress events), `localStorage` session store |\n| `@hostr/upload/node` | Node, scripts | `fetch` transport, filesystem session store |\n| `@hostr/upload/react-native` | Expo / RN | Native staged-parts transport; survives backgrounding |\n\nThe engine, retry policy, and integrity checks are one shared core. Adapters only\nsupply platform I/O: read bytes, PUT bytes, persist a few KB of JSON.\n\n### Browser\n\n```js\nimport { createUpload } from \'@hostr/upload/browser\';\n\n// createUploadSession runs on YOUR SERVER \u2014 it holds the API key.\n// The browser receives only { endpoint, assetId, uploadToken }.\nconst upload = await createUpload({ endpoint, uploadToken, assetId, file });\n\nupload.on(\'progress\', p => {\n const pct = (p.bytesConfirmed / p.totalBytes * 100).toFixed(1);\n console.log(`${p.partsDone}/${p.partsTotal} parts, ${pct}%`);\n});\nupload.on(\'statechange\', s => console.log(s));\n// idle -> running -> (stalled <-> running)* -> completing -> complete\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\nSession state persists in `localStorage`, keyed by `name:size:lastModified`.\nReload the page, pick the same file, and `createUpload` resumes automatically \u2014\nthere is no explicit resume call.\n\n### Node\n\n```js\nimport { createUploadSession, createUpload } from \'@hostr/upload/node\';\n\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n});\n\nconst upload = await createUpload({\n endpoint, uploadToken: session.uploadToken, assetId: session.assetId,\n file: \'/path/to/video.mp4\', // a path, not an open handle\n stateDir: \'./upload-state\', // survives a process restart\n});\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\n#### Captions on upload\n\n`createUploadSession` takes the same `captions` object as `POST /v1/uploads`\n(SDK 0.3.0 or later). hostr transcribes the audio as soon as it exists and\ntranslates it into each `translate` language; every track lands as an ordinary\nsubtitle track. The account must have captions enabled.\n\n```js\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n captions: { language: \'auto\', translate: [\'en\', \'es\'], context: \'Creator: Ana. Title: Rainy night.\' },\n});\n```\n\nSee [Auto-captions and translation](#auto-captions-and-translation) for the events, the\nmetering and the rules.\n\n### Take the endpoint from your backend\'s response\n\n**One rule for clients of a proxying backend:** take the hostr `endpoint` from\nyour backend\'s create-upload response, never from separate client config.\n`createUploadSession` returns it as `endpoint` alongside the session, so\nforwarding `{ endpoint, assetId, uploadToken }` is all the browser needs.\n\nAn upload token is only valid on the hostr instance that minted it. Endpoint\ndrift fails as a bare mid-flow `401` with nothing pointing at the cause;\nreturning the endpoint alongside the session makes the mistake structurally\nimpossible. Learned from the first consumer integration.\n\n### Events\n\n| Event | Payload |\n|---|---|\n| `progress` | `{ partsDone, partsTotal, bytesConfirmed, totalBytes, partStates }` |\n| `statechange` | `\'idle\' \\| \'running\' \\| \'stalled\' \\| \'completing\' \\| \'complete\'` |\n| `diagnostic` | `{ code, consecutiveNetworkFailures, bytesEverConfirmed, hint }` |\n\n`partStates` maps part number to `\'inflight\'` or `\'done\'` \u2014 enough for a per-part\nprogress grid.\n\nThe `diagnostic` event fires once per failure streak, on every adapter. Today its\nonly code is `systemic-transport-failure`, which almost always means bucket CORS\n\u2014 see [Upload from a browser](#upload-from-a-browser). It is **advisory, never\nterminal**: the upload keeps retrying regardless.\n\n### Token refresh\n\n```js\nconst upload = await createUpload({\n endpoint, assetId, uploadToken, file,\n onTokenExpired: async () => (await refreshFromYourServer(assetId)).uploadToken,\n});\n```\n\nYour server calls `POST /v1/uploads/:id/token`. The SDK retries in place; nothing\nis lost. Even without the hook a killed client resumes from what storage reports\non next launch.\n\n### Integrity\n\nThree layers, each proving something different \u2014 per-part ETag, part count and\ntotal size at completion, and an optional whole-file SHA-256. See\n[Resume and integrity](#resume-and-integrity) for what each one does and\ndoes not prove.\n\n### Import error classes from the entry you actually use\n\nEach entry is bundled independently, so `IntegrityError` from\n`@hostr/upload/node` is not the same class object as the one from\n`@hostr/upload/browser`. An `instanceof` check across entries fails. Import from\nthe entry your code uses.\n\n## The encoding ladder\n\nhostr encodes each source into several renditions at different bitrates \u2014\nthe *ladder* \u2014 and packages them as fMP4 (CMAF) HLS with H.264 video and AAC\naudio. A player picks a rung based on available bandwidth and changes rung\nmid-stream as conditions move.\n\n### Rungs are chosen by short edge\n\nA ladder defined by height assumes landscape. Defining rungs by the **short\nedge** means portrait video, square video, and unusual aspect ratios all get a\nsensible ladder instead of a broken one.\n\n**Nothing is ever upscaled.** Rungs above the source\'s short edge are dropped, so\na 480p source produces a 480p-and-below ladder rather than a blurry 1080p\nrendition that costs bandwidth and delivers nothing.\n\n### Why switching is clean\n\nTwo invariants do the work, and both are load-bearing:\n\n**Every rung shares one constant frame rate and identical keyframe intervals**,\nwith scene-cut detection disabled. This is the only reason segment boundaries\nline up across rungs. Without it a player switching quality lands mid-segment and\nstutters or re-buffers.\n\n**Variable frame rate is normalised to constant before the ladder splits.**\nSources are frequently VFR \u2014 phone recordings especially \u2014 and segment durations\ndrift apart otherwise.\n\nThere is a subtlety inside that second one worth stating, because it shipped as a\nbug once. The frame rate comes from **the container\'s declared rate, not the\nmeasured average**. On a VFR source the average is depressed *because* frames were\ndropped; using it as a ceiling compounds the loss. A 30fps source got encoded at\n25fps that way.\n\n### Audio is demuxed\n\nAudio lives in its own rendition group rather than being muxed into every video\nrung. One copy instead of four, and it is the prerequisite for multi-language\naudio later.\n\nBy default that group is AAC-LC at 128 kbps, stereo (a mono source stays mono;\n5.1 is downmixed \u2014 never the other way), 48 kHz, resampled with soxr. Nothing\nelse is done to it: no gain change, no noise reduction, no gating, no silence\ntrimming. Room tone is content, and many creators master deliberately.\n\n### Audio options\n\n`POST /v1/uploads` takes an `audio` field that changes that for one asset:\n\n```json\n{ "size": 104857600, "filename": "tapping.mov", "audio": "asmr" }\n```\n\n| Option | Default | What it does |\n|---|---|---|\n| `kbps` | `128` | Bitrate of the top audio group. At 192 or more a second **96 kbps group** is added and the smallest rung (240p) references it, so a viewer on mobile data is not made to fetch a 256 kbps track behind 400 kbps of video. |\n| `normalize` | `false` | Fixed programme gain with peak limiting \u2014 see below. |\n| `targetLufs` | `-24` | Integrated loudness target. Mux normalises to the same level at ingest. |\n| `truePeakDbtp` | `-1.5` | True-peak ceiling, verified on encoded audio. |\n\n`"asmr"` is shorthand for `{ "kbps": 256, "normalize": true }` with the default\ntarget and ceiling. The resolved options land on the record as `audioOptions`.\nAn asset that already exists takes the same field on\n[`POST /v1/assets/:id/reencode`](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n#### Normalisation raises the programme and limits peaks\n\nNormalisation is **off by default**. No upload, re-encode or account default\nchanges when you preview it. Explicitly set `audio.normalize: true` or choose\n`audio: "asmr"` to apply it.\n\nThe source\'s EBU R128 measurement sets a fixed programme gain. An oversampled\nlookahead limiter catches peaks that would cross the ceiling, with automatic\nmakeup gain disabled and latency compensated. There is no programme compressor,\ndenoise, gate or automatic levelling. Quiet passages and room tone remain.\n\nThe transcoder measures encoded AAC and corrects the fixed gain for loudness\nlost to limiting, aiming within 0.5 LU of the target. It lowers the internal\nlimiter ceiling if AAC overshoots. If five passes cannot reach the target\nunder the ceiling, a new upload ships with its original, untouched audio\nrather than failing: `normalization` is `null` and `loudness.normalizationSkipped`\nsays why. A re-encode that cannot converge fails instead, and the existing\nladder remains live. Encoded segments that miss the target still fail the job. All normalised HLS groups and `audio.m4a`\nuse the same final chain. The low bitrate group allows 1 LU of codec variation.\n\nThe source measurement stays in `loudness`; `normalization.output` holds the\nencoded result. `normalization.limiting.maxReductionDb` measures maximum\nattenuation before AAC, and `limitedSamplesPct` is the percentage of sample\nframes attenuated by more than 0.1 dB, measured at 192 kHz. `limitingDb` is\nseparately the estimated peak reduction from source peak, gain and ceiling.\nThe limiter can change loudness range; the record shows both source and output.\n\nOlder records without `normalization.mode: "peak-limiter"` describe the former\ngain clamp: `peakLimited: true` there means it landed below target. Existing\nmedia is unchanged until explicitly re-encoded.\n\n#### Listen before applying\n\nOn the dashboard asset page, choose a start second and three targets (initial\nsuggestions: **-30, -24, -18 LUFS**, each editable from -40 to -10). Make the\ncomparison to hear Original plus three normalised 30-second clips, shortened\nat the end of the video. The players show measured LUFS, true peak, loudness\nrange and limiting. Only **Re-encode at \u2026 LUFS** changes the asset.\n\nEach tier is measured over the selected sample. A full re-encode measures the\nwhole source and may need a different gain at the same target. Preview input\nis the published gain-free Original AAC, not the lossless upload. Preview jobs\nread only the needed audio fragments and run on the concurrent poster/preview\nlane, never behind a ladder encode. Worker processing is bounded to 45 seconds;\nqueue or cold-start delays are additional. Missing fast-lane configuration\nreturns 503 rather than falling back to the encode queue.\n\n```bash\nhostr assets audio-preview ASSET --time 120 --targets=-30,-24,-18\nhostr assets audio-preview ASSET --json\n# After listening and choosing a target:\nhostr assets reencode ASSET --target-lufs -24\n```\n\nThe API takes `POST /v1/assets/:id/audio-preview` with\n`{ "time": 120, "targets": [-30, -24, -18] }`. Optional `truePeakDbtp` and\n`kbps` keep their existing defaults (-1.5 and 128). Poll `GET` on the same\nroute for state and measurements, then fetch\n`GET /v1/assets/:id/audio-preview/:previewRev/:index` with a tenant key\n(index 0 is Original, 1\u20133 follow the targets). Audio remains private. A\ncomparison never changes `audioOptions`, playback, posters or the live revision.\nOnly the latest comparison is addressable. Clips remain under the asset prefix\nuntil asset deletion. A queued request older than two minutes can be retried.\n\n#### The original stays selectable\n\nWhen a gain is applied, the top audio group carries a second rendition,\n**Original** \u2014 the same encode with no gain \u2014 beside the **Normalised**\ndefault. A player\'s audio-track menu offers both, so a creator can A/B their\nown upload and nothing is ever available only normalised. It costs one more\naudio track\'s worth of storage and appears only when normalisation actually\nchanged something. The 96 kbps group does not carry it. (The original upload is\nkept in storage regardless; this is about what a viewer can pick.)\n\n### Loudness is measured for every asset\n\nWhether or not normalisation is on, the probe measures the first audio stream\n(EBU R128, true-peak mode) and the record carries the result as `loudness`,\nwhich also rides `video.audio_ready`, `video.playable` and `video.ready`:\n\n```json\n{\n "integratedLufs": -32.1,\n "loudnessRangeLu": 15.2,\n "truePeakDbtp": -0.3,\n "thresholdLufs": -42.5,\n "mono": false,\n "normalization": null\n}\n```\n\nTwo uses. **Creator warnings before publish** \u2014 `mono` for a one-channel source\n(the ladder keeps it mono; it never fakes stereo), `truePeakDbtp` near 0 for\nclipping, a very low `integratedLufs` for a recording that is quiet even by\nASMR standards. For output loudness use `normalization.output.integratedLufs`\nwhen present. `integratedLufs` itself always describes the source; adding\n`gainDb` is no longer an exact output prediction when peaks are limited.\n\nThe block is written once on first encode and replaced atomically when a\nre-encode becomes ready. It is `null` for a source with no audio stream.\n\n### What the probe found\n\nThe transcoder probes each source and records what it saw on the asset as\n`input` \u2014 codec, pixel format, bit depth, dimensions, declared and average frame\nrates, and colour metadata.\n\nWhen the source required a judgement call, that block also carries `findings`,\nand a [`video.input.non_standard`](#event-reference) event fires once. It is\nadvisory: the transcode proceeds normally and `video.ready` still follows. See\nthe [events reference](#event-reference) for the finding codes.\n\n### HDR sources\n\nThe ladder is 8-bit BT.709 SDR. A genuine HDR source \u2014 10-bit with an HLG or\nPQ transfer, which is what an iPhone records in HDR mode \u2014 is converted to SDR\nonce, before the ladder splits, so every rung, the poster and the storyboard\ncome from the same converted frames. HLG is read the way the format intends\nfor SDR screens (as an ordinary gamma curve, with the colour converted to\nBT.709 and a small contrast and saturation lift standing in for the display\nheadroom SDR lacks); PQ is tone-mapped perceptually. A BT.2020 source without\nan HDR curve has only its colour converted. The finding on the record says\nwhich was done.\n\nThe 10-bit part is the rule. HDR is a 10-bit format, so HDR tags on 8-bit\npixels are a label copied forward by an earlier transcode, and converting as\nif they were true blows the highlights; those sources are treated as SDR and\nleft alone.\n\nHDR is not delivered to viewers. Tone mapping shipped on 2026-09-10; an HDR\nasset encoded before that plays back washed out until it is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n### Container and codec choices\n\nfMP4 rather than MPEG-TS: universal playback today, and the container that keeps\nHEVC addable later without re-packaging everything.\n\nSegments are served `immutable, max-age=1yr`, and content keys are versioned\n(`.../hls/v2/...`) rather than overwritten. Overwriting a key that is cached for\na year is how you end up needing a cache purge in production, which is a smell\nrather than a tool.\n\n## Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| GET | `/health` | \u2014 | Liveness check |\n| GET | `/llms.txt` | \u2014 | Agent documentation index |\n| GET | `/llms-full.txt` | \u2014 | Agent integration guide |\n| POST | `/v1/device/code` | \u2014 | Begin browser authorization |\n| POST | `/v1/device/token` | \u2014 | Exchange an approved device code for a key |\n| POST | `/v1/uploads` | upload | Create an asset and begin a multipart upload |\n| GET | `/v1/uploads/:id` | upload | Upload status and parts already stored |\n| DELETE | `/v1/uploads/:id` | upload | Abort an upload |\n| POST | `/v1/uploads/:id/parts` | upload | Get presigned PUT URLs for parts |\n| POST | `/v1/uploads/:id/complete` | upload | Finalise the upload and queue transcoding |\n| POST | `/v1/uploads/:id/token` | upload | Re-mint an upload token |\n| GET | `/v1/assets` | read | List your assets |\n| GET | `/v1/assets/:id` | read | Get one asset |\n| DELETE | `/v1/assets/:id` | full | Erase an asset |\n| POST | `/v1/assets/:id/tokens` | playback | Mint playback credentials |\n| POST | `/v1/assets/:id/audio` | upload | Generate the audio-only rendition |\n| POST | `/v1/assets/:id/download` | playback | Link to the original upload |\n| GET | `/v1/assets/:id/audio-preview` | read | Read the current audio comparison |\n| POST | `/v1/assets/:id/audio-preview` | upload | Compare Original audio and three loudness targets |\n| GET | `/v1/assets/:id/audio-preview/:previewRev/:index` | read | Read one private AAC comparison clip |\n| POST | `/v1/assets/:id/reencode` | upload | Re-encode an asset from its stored source |\n| POST | `/v1/renders` | upload | Create a video from an edit |\n| POST | `/v1/assets/:id/render` | upload | Re-edit an asset |\n| POST | `/v1/assets/:id/poster` | upload | Re-take the poster from a chosen second |\n| POST | `/v1/assets/:id/poster/warm` | upload | Start a worker ahead of a poster request |\n| POST | `/v1/assets/:id/status` | \u2014 | Report transcode status |\n| GET | `/v1/assets/:id/subtitles` | read | List subtitle tracks |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | Attach or replace a subtitle track |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | Remove a subtitle track |\n| POST | `/v1/assets/:id/captions` | upload | Transcribe an asset (or redo it) |\n| POST | `/v1/assets/:id/captions/translate` | upload | Translate the transcript into more languages |\n| POST | `/v1/captions/callback/:id/:rev` | \u2014 | Transcription provider callback (not for clients) |\n| POST | `/v1/billing/webhook` | \u2014 | Stripe webhook (not for clients) |\n| GET | `/v1/account` | read | Whoami |\n| GET | `/v1/usage` | read | Daily usage |\n| GET | `/v1/billing` | read | Billing status and this month so far |\n| GET | `/v1/events` | read | Tail the durable event log |\n| POST | `/v1/events/:id/redeliver` | full | Replay a stored event |\n| GET | `/v1/webhooks` | full | Get the webhook endpoint |\n| PUT | `/v1/webhooks` | full | Configure the webhook endpoint |\n| DELETE | `/v1/webhooks` | full | Disable the webhook endpoint |\n| GET | `/v1/keys` | full | List key metadata |\n| POST | `/v1/keys` | full | Mint an API key |\n| DELETE | `/v1/keys/:id` | full | Revoke a key |\n\nGenerated from the same OpenAPI description the API is checked against.\nFull reference with schemas and examples: https://docs.hostr.sh/reference/api/\n';
|
|
5829
|
+
var integrate_default = '# hostr \u2014 integration guide\n\n<!-- GENERATED FILE \u2014 DO NOT EDIT.\n Source: apps/docs/src/content/docs/** (pages with `agentGuide: true`)\n packages/openapi/src/spec.ts (the route table)\n Regenerate: pnpm build:agent-guide \xB7 CI checks this with --check -->\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs https://docs.hostr.sh \xB7 GET {API base}/llms-full.txt (this file)\n```\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\n## Quickstart\n\nBases you will need:\n\n```\nAPI base https://api.hostr.sh (override with HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\n```\n\n### 1. Authorize\n\n```bash\nnpx @hostr/cli login\n```\n\nThis opens your browser and mints the CLI\'s own key once you approve it. Nothing\nis pasted, which matters when a coding agent is driving your terminal \u2014 a key\ntyped into an agent\'s session lands in its context and its transcript.\n\nThe key appears on the dashboard Keys page as `CLI \u2014 <hostname>` and is revocable\nlike any other. For CI, `--key <k>` or `HOSTR_API_KEY` skips the browser.\n\nConfirm it worked:\n\n```bash\nnpx @hostr/cli whoami --json\n```\n\n### 2. Upload\n\n```bash\nnpx @hostr/cli upload video.mp4 --wait --json\n```\n\n`--wait` blocks until the transcode reaches `ready` or `failed`. Without it the\ncommand returns as soon as the bytes are verified in storage.\n\n```json\n{ "assetId": "9f8c1e2a4b6d47f0a1b2c3d4e5f60718", "status": "ready", "bytes": 104857600 }\n```\n\nThe upload is resumable. Kill it mid-transfer and run it again \u2014 it asks storage\nwhich parts landed and continues from there.\n\n### 3. Play it\n\nMint a playback token, then build the URL:\n\n```bash\nnpx @hostr/cli assets token 9f8c1e2a4b6d47f0a1b2c3d4e5f60718 --json\n```\n\n```json\n{ "playback": "eyJhbGci...", "thumbnail": "...", "storyboard": "...", "expiresAt": 1756134000000 }\n```\n\n```\nhttps://stream.hostr.sh/{assetId}.m3u8?token={playback}\n```\n\nHand that to any HLS player. Safari and iOS play it natively; elsewhere use\nhls.js. To check a video without writing any of that, open it in the dashboard\nand press Play \u2014 the asset page mints a token and plays it in place.\n\nThe token is checked once, when playback starts. It does not need to stay valid\nfor the whole viewing session, and the default one-hour lifetime is not a\none-hour viewing limit.\n\n### 4. Wire up events, not polling\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nThe signing secret is generated, written to `.env`, and never printed \u2014 only the\nvariable name is. Then verify signatures before trusting a delivery: see\n[Verifying webhooks](#verifying-webhook-signatures).\n\nProve your endpoint end to end without uploading anything else:\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\n### No Node available?\n\nEvery flow here has a raw-HTTP equivalent. Start at\n[Upload from a server](#upload-from-a-server).\n\n## Authentication\n\nEvery call sends `Authorization: Bearer <credential>` over HTTPS. There are three\ncredential classes and **no shared master key**.\n\n### API keys\n\n`hostr_sk_live_\u2026`, minted per account. SHA-256-hashed at rest; the plaintext is\nshown exactly once, at mint. Verified in constant time and cached about 60 seconds\nin the edge Worker \u2014 so **revocation takes up to a minute** to take effect.\n\nScopes are a ladder, each rung including the ones below:\n\n| Scope | Grants |\n|---|---|\n| `read` | GETs only \u2014 asset status, listing, usage, events |\n| `upload` | read, plus the whole upload lifecycle |\n| `playback` | upload, plus minting playback tokens |\n| `full` | everything: deletion, key management, webhook config |\n\n**`playback` is what an application key should be.** It can ingest video and it\ncan let viewers watch, but it cannot delete assets or mint more keys. Reach for\n`full` only where you actually manage the account.\n\n**Keep keys server-side.** Never ship one in a browser or a mobile app.\n\nRotate by creating the new key and revoking the old one:\n\n```bash\nnpx @hostr/cli keys create --name ci --scopes upload --write-env .env\nnpx @hostr/cli keys revoke <old-id>\n```\n\nRevoking the key that makes the request is allowed \u2014 it is rotation\'s last step.\n\n### Upload tokens\n\nA stateless HMAC scoped to a single `assetId`, returned as `uploadToken` from\n`POST /v1/uploads`. **This is the only credential an untrusted client should ever\nhold.** It can drive that one upload and nothing else \u2014 it grants nothing on\nplayback, and nothing on any other asset.\n\nTokens expire. For uploads that outlive one, re-mint in place with\n`POST /v1/uploads/:id/token` (an API key with `upload` scope, not the expiring\ntoken itself) \u2014 no progress is lost. The SDK does this for you through its\n`onTokenExpired` hook.\n\n### Service key\n\nThe internal transcoder credential. Never issued to tenants; documented only so\nits one route is not mistaken for a public one.\n\n### Login without pasting a key\n\n```bash\nnpx @hostr/cli login\n```\n\nBrowser-authorized, using the RFC 8628 device flow. The CLI never accepts a\npasted key from a human or an agent, for the reason given in the\n[quickstart](#quickstart). `--key` and `HOSTR_API_KEY` remain the escape\nhatch for CI and other headless callers.\n\nCredential precedence everywhere: `--key` flag, then `HOSTR_API_KEY`, then the\nsaved profile.\n\n### Auth errors\n\n| Status | Error | Meaning |\n|---|---|---|\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `account_suspended` | Contact the operator |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\nThat 401 conflation is deliberate: distinguishing "wrong key" from "not your\nasset" would let anyone with a valid key enumerate other accounts\' asset ids.\n\n**`control_plane_unavailable` fails closed for API keys only.** If the control\nplane is unreachable, key-authenticated calls 503 \u2014 but in-flight uploads using\nupload tokens keep working, and playback is untouched, because the media Worker\nnever queries it.\n\n## Assets and lifecycle\n\nAn asset is created when you open an upload and lives until you erase it.\n\n```\nuploading -> uploaded -> queued -> processing -> playable -> ready\n \\-> failed\n \\-> aborted (deleted, from anywhere)\n```\n\n| Status | Means |\n|---|---|\n| `uploading` | Multipart session open, parts landing |\n| `uploaded` | Bytes verified against storage; part count and total size both agree |\n| `queued` | Handed to the transcode queue |\n| `processing` | Encoding. Carries live `progress` |\n| `playable` | Watchable now, at one quality. The rest of the ladder is still encoding |\n| `ready` | HLS renditions are live and playable |\n| `failed` | Transcode failed. `error` is always present |\n| `aborted` | Upload abandoned before completion \u2014 you aborted it, or it was collected after 14 days idle |\n| `deleted` | Erased. Terminal and irreversible |\n\n### Status only moves forward\n\nStatus only ever moves forward. Both encode passes report independently and\nqueue delivery is at-least-once, so a late or redelivered report that would move\na `playable` or `ready` asset backwards is ignored \u2014 including a failure. An asset\nthat is watchable is never marked `failed`; the error is recorded on the record\ninstead. Once `playable`, an asset stays playable.\n\n### Reading progress\n\nWhile `processing` or `playable`, the record carries\n`progress: { encodedSec, totalSec, speed }`, where `speed` is the encode rate as\na multiple of realtime. While `playable` it tracks the remaining qualities.\nOutside those two it is null or absent \u2014 a terminal status never shows a stale\nbar.\n\nThat is enough for a real percentage instead of a spinner, which matters more\nthan it sounds: most "the transcode is slow" reports turn out to be missing\nprogress feedback rather than slow encoding.\n\n### Stalled encodes are re-queued\n\nAn encode that stops reporting \u2014 a transcoder machine restarted mid-encode,\nsay \u2014 is noticed within about ten minutes of its last progress post and the\npass that was running is re-queued. Nothing about the asset changes: status\nstays where it was, progress resumes, and `video.playable` / `video.ready`\nstill fire exactly once. An encode that stays silent through three re-queues\n(roughly an hour) is given up on and the asset records an `error`, exactly as\na transcode failure would: it becomes `failed` if nothing was playable yet\n(`video.errored` fires), or stays `playable` with the error if it was.\n\n### Abandoned uploads are collected after 14 days\n\nAn upload that shows no activity for **14 days** is moved to `aborted`, its\nmultipart session is released, and an `upload.aborted` event fires carrying\n`reason: "abandoned"`. The asset\'s `error` says the same thing in words. An\n`aborted` asset stops counting against your asset quota \u2014 it holds no bytes.\n\n"Activity" means a call about that upload: asking for the next batch of\npresigned part URLs, or re-minting its upload token. Sending bytes to the\npresigned URLs is not visible to the API, so a client that goes quiet for a\nfortnight without asking for anything is what gets collected. In practice an\nuploader hits the API at least twice a day, because presigned part URLs expire\nin at most 12 hours.\n\nThe window is deliberately double the 7-day upload token lifetime. An upload\nwhose token expired is not lost \u2014 re-mint at `POST /v1/uploads/:id/token` and\nresume \u2014 so a shorter window would destroy uploads that were still resumable.\nIf you know an upload is finished with, `DELETE /v1/uploads/:id` releases the\nstorage immediately rather than waiting.\n\n### Prefer events to polling\n\n`GET /v1/assets/:id` returns the authoritative record, and\n`GET /v1/assets?limit=&status=&cursor=` lists your assets newest-first with\nkeyset pagination. But polling is the fallback, not the design \u2014\n[webhooks](#webhooks-and-the-event-log) push the same transitions.\n\n### playbackId is its own field\n\n`GET /v1/assets/:id` always carries `playbackId`, and carries `durationSec`\n(seconds, the same value as `renditions.durationSec`) from `playable` on. The\n`video.playable` and `video.ready` events carry both.\n\n`playbackId` equals `assetId` today. **Store it as its own field anyway.** It is\nnamed separately so the two can diverge later without forcing a migration on\nevery consumer.\n\n### Re-encoding\n\n`POST /v1/assets/:id/reencode` (`hostr assets reencode <id>`) builds a fresh\nladder from the original upload, which stays in storage. The asset keeps its id,\nits playback URLs and its subtitles; the current ladder keeps serving until the\nnew one is live, then playback tokens switch over. Nothing is overwritten and\nnothing needs purging \u2014 the new ladder lands under a new encode revision, and\nthe record\'s `rev` says which one is live.\n\nUse it when an encode was wrong without the source being wrong. The first case\nwas HDR: assets uploaded before 2026-09-10 from an iPhone in HDR were relabelled\nrather than tone-mapped and play back washed out. Re-encoding them fixes it.\n\nThe video ladder is always the current default, but the body may carry `audio`\n\u2014 the same field `POST /v1/uploads` takes, see [audio\noptions](https://docs.hostr.sh/concepts/ladder/#audio-options) \u2014 to set the asset\'s audio options for\nthis and every later encode:\n\n```\nPOST /v1/assets/:id/reencode { "audio": "asmr" } # hostr assets reencode <id> --audio asmr\n```\n\nThat is how an asset uploaded before the option existed gets normalised: the\nsource is still in storage. The new ladder\'s measurement and gain replace the\nrecord\'s `loudness` block when it lands.\n\nOnly a `ready` asset can be re-encoded, one re-encode at a time. The request\nreturns `202` and the record carries a `reencode` block while the job runs;\n`video.ready` fires again with `reencoded: true` and the new `rev` when the\nladder is live, and the block clears. Poster and storyboard images keep their\nURLs but change content \u2014 refresh anything you cached from them.\n\n### Editing: renders\n\nAn edit is an ordered list of clips \u2014 each a window of one of your own assets,\nwith its own gain \u2014 that hostr cuts and joins into one video. Nothing is\ntranscoded while the edit is being made (build and preview it however you like);\na render happens once, when you ask.\n\n```\nPOST /v1/renders { "clips": [ \u2026 ], "audio": "asmr" } # a new asset\nPOST /v1/assets/:id/render { "clips": [ \u2026 ] } # re-edit an existing one\n```\n\nEach clip is `{ assetId, inSec, outSec, gainDb? }`. A source must be `playable`\nor `ready`, and is cut from what it plays **now** \u2014 an asset that was already\nre-edited contributes its edited version, and an asset may be a source of its\nown re-edit (the join never overwrites anything). Cuts are frame-accurate. The\nfirst clip sets the picture\'s orientation, size and frame rate; later clips are\nfitted inside it, letterboxed rather than stretched or cropped. Audio is joined\nat 48 kHz stereo, each clip at its `gainDb`; a clip with no audio contributes\nsilence. `audio` is applied once, over the whole joined programme \u2014 so\nnormalisation sees the finished video, not each clip.\n\n**A new asset** (`POST /v1/renders`) behaves like an upload from the moment\nits join exists: `video.uploaded` (with `rendered: true`), then\n`video.playable` and `video.ready`. It sits `queued` while the join runs; if the\njoin fails the asset goes `failed` and `video.errored` fires. Store the\n`assetId` from the `202` straight away.\n\n**A re-edit** (`POST /v1/assets/:id/render`) is a\n[re-encode](#re-encoding) with a new edit as its source: same one-at-a-time\nrule, same `reencode` block, and the current version keeps serving until the\nnew one lands \u2014 then `video.ready` fires with `reencoded: true`,\n`rendered: true` and the new `rev`. A failure at either step leaves the current\nversion live. Because the content changed, a poster you chose is dropped (the\nnew ladder\'s own poster serves) and **subtitles stay attached but no longer\nline up** \u2014 replace them after the new version lands. The record\'s `render`\nblock describes the edit that is live, and later re-encodes build from it.\n\n### Choosing the poster\n\n`POST /v1/assets/:id/poster` with `{ "time": <seconds> }` (`hostr assets poster\n<id> --time <seconds>`) re-takes the poster\nfrom that second of the published ladder \u2014 no re-encode, no source download,\nseconds of work. The picture lands under its own immutable key and the\nrecord\'s `posterRev`/`posterTime` move to it; `thumbnail.jpg` keeps its URL,\nand thumbnail tokens minted from then on serve the new frame.\n`video.poster_ready` fires when it is live. See\n[Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Downloading the original\n\n`POST /v1/assets/:id/download` returns a presigned URL for the file exactly as\nit was uploaded \u2014 not a rendition \u2014 valid for 60 to 3600 seconds (default 900).\nThe response saves as an attachment under `filename`; pass your own to choose\nit, and the source\'s extension is added if yours has none.\n\n**This is off for every account until a hostr operator enables it.** It needs\na `playback` key that owns the asset *and* that grant; without the grant the\nowner gets `403 download_not_enabled`. Anyone else gets the usual `401`, so\nthe route never reveals whether an asset exists or whether its owner has\ndownloads on. Mint the URL server-side, only for callers you have authorised,\nand hand it straight to the browser \u2014 it is a bearer credential for the\noriginal bytes until `expiresAt`.\n\n### Deletion\n\n`DELETE /v1/assets/:id` purges the source and every rendition from storage, frees\nthe storage quota, and emits `video.deleted`. Idempotent \u2014 repeat calls report\n`deleted` again with `objectsDeleted: 0`.\n\nNew playback tokens stop minting immediately. Tokens already issued can replay\nedge-cached segments for at most a few hours until they expire. **There is no\nundelete.**\n\n## Quotas and limits\n\nAccounts carry three ceilings, visible any time:\n\n```bash\nnpx @hostr/cli whoami --json # or: GET /v1/account\n```\n\n```json\n{\n "limits": { "maxAssets": 10000, "maxSourceBytes": 5497558138880, "maxUploadsPerDay": 500 },\n "usage": { "assetCount": 1711, "storageBytes": 742381129728, "uploadsToday": 12 }\n}\n```\n\n| Limit | Counts |\n|---|---|\n| `maxAssets` | Assets held at once. Deletion frees this immediately |\n| `maxSourceBytes` | Total source bytes stored |\n| `maxUploadsPerDay` | Upload sessions created per UTC day |\n\n### Hitting one\n\nQuotas are checked when the upload is created, not when bytes arrive. A tripped\nquota is a `403` naming which one and its ceiling:\n\n```json\n{ "error": "quota_storage", "limit": 5497558138880 }\n```\n\nThe three codes are `quota_uploads_daily`, `quota_assets`, and `quota_storage`.\n\n**This means the account needs raising, not that the request was malformed.**\nDistinguish it from a `400` in your error handling \u2014 retrying a quota error\nwithout changing anything will fail identically forever.\n\n### Request-shaped limits\n\nThese are properties of the API rather than of your plan:\n\n| Limit | Value |\n|---|---|\n| Parts per presign request | 100 |\n| Parts per upload | 10,000 (a storage limit; raise `partSize` for very large files) |\n| Subtitle file size | 2,000,000 bytes |\n| Playback token lifetime | 60 s to 86,400 s, default 3,600 |\n| Event page size | 1 to 200, default 50 |\n\nExceeding the part ceiling for a given part size is `413 file_too_large`, and the\nresponse carries the maximum byte count that part size can address.\n\n### Usage over time\n\n```\nGET /v1/usage?from=2026-08-01&to=2026-08-25 (read)\n -> { from, to, days: [{ day, uploads, bytesUploaded, encodedSeconds,\n captionSeconds, deliveredMinutes, deliveredBytes }] }\n```\n\nDefaults to the last 30 days. Dates are UTC and must be `YYYY-MM-DD`; anything\nelse is `400 bad_date`.\n\n**Delivery** is what viewers watched. `deliveredMinutes` counts whole video\nsegments served, on any rung, so one viewer watching one minute is one minute\nwhatever quality they got; audio, posters, storyboards and subtitles add to\n`deliveredBytes` but not to minutes. Edge-cache hits count: a byte served from\nthe cache was still delivered. Delivery is rolled up hourly, so today\'s figure,\nand up to the three hours before the current one, can still grow.\n\n### Not your fault\n\nTwo failures look like integration bugs and are not:\n\n`503 control_plane_unavailable` is transient \u2014 retry with backoff. In-flight\nuploads holding upload tokens are unaffected, and playback is untouched.\n\n`500 signing_not_configured` means the playback signing key is not loaded on the\nAPI deployment. It is operator-side and not retryable from a client. Report it\nrather than working around it.\n\n## Billing\n\nhostr is pay-as-you-go: no plan and no monthly fee. You pay for what you\nencode, store, deliver and caption, and **the first $5 is free**.\n\n| Line | Price (USD) |\n|---|---|\n| Encoding | $0.015 per source minute |\n| Video storage | $0.003 per minute stored, per month |\n| Original files | $0.03 per GB stored, per month |\n| Delivery | $0.0005 per minute watched |\n| Captions: transcription | $0.006 per minute |\n| Captions: translation | $0.001 per minute, per language |\n\nStorage is the daily average over the month. Delivery counts whole video\nsegments served, on any quality rung, so one viewer watching one minute is one\nminute. Original files are the uploads hostr keeps so it can re-encode or hand\nthem back; they are billed by size because a 4K original can be many times\nlarger than its encoded video.\n\n### The free allowance, then a card\n\nEvery account\'s first **$5** of usage is free, once: about 330 minutes encoded,\nor 10,000 minutes watched. That is enough to build and test a whole integration,\nupload to playback to webhook, without a card. It counts every line above.\n\nWithout a card you can always sign in, run `npx @hostr/cli login`, create keys,\nconfigure webhooks and read the docs. Once the allowance is used up, an upload,\nrender, re-encode or captions request answers:\n\n```\n402 { "error": "payment_method_required", "billingUrl": "https://dashboard.hostr.sh/billing", "hint": "\u2026" }\n```\n\nA human adds the card at `billingUrl`. Uploads work within a minute of it being\nadded. An agent setting up an integration should finish its testing, then hand\nthe human that link before the integration goes live.\n\n`GET /v1/billing` reports where you stand: `allowance.leftUsd`, and\n`cardRequired`, which says whether the card requirement is on at all. Read\nthose rather than `status`: `needs_card` means only that no card is on file.\n\n#### During the beta\n\n**The card requirement is off, and usage is not billed.** Uploads work without a\ncard whatever you use, `cardRequired` is `false`, and a month that closes during\nthe beta is recorded as not chargeable. It does not draw on the allowance\neither: the $5 starts counting when the beta ends. Adding a card early is fine;\nnothing is charged for beta usage.\n\n### How a month becomes an invoice\n\nEach UTC month closes on the 1st. Its charges are invoiced and charged to the\ncard once the unbilled balance, after the free allowance, reaches **$5**; a\nsmaller month carries over and is added to the next. The first invoice shows the\nallowance as its own credit line. Sales tax is added where it applies.\n\nA failed charge does not stop anything: the account shows as `past_due`, the\ncharge is retried, and the dashboard says so.\n\n### Checking what you owe\n\n```\nGET /v1/billing (read)\n -> { status, cardRequired, allowance: { totalUsd, leftUsd }, billingUrl, thresholdUsd, carriedUsd,\n currentMonth: { month, lines: [{ key, quantity, unitUsd, amountUsd }], accruedUsd },\n nextInvoiceUsd, prices }\n```\n\n`currentMonth` is what has accrued so far, from the same meters as\n`GET /v1/usage`. `status` is `needs_card`, `active`, `past_due` or `exempt`;\n`cardRequired` says whether a missing card is refused right now.\n\nThe `plan` field on `GET /v1/account` (and in `whoami`) names the account\'s\nquota tier, the set of limits on [Quotas and limits](#quotas-and-limits). It is\nnot a billing plan: there is one price list, above.\n\n## Upload from a server\n\nFour calls. Bytes go straight to storage via presigned URLs and never transit\nhostr\'s compute.\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,3]} (at most 100 per call)\n -> { urls: [{ partNumber, url }], expiresIn }\n\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nPOST /v1/uploads/:id/complete {"sha256":"<hex of the whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\n```\n\nPlus `GET /v1/uploads/:id` for resume and `DELETE /v1/uploads/:id` to abort.\n\n### Completion recovery for mobile uploads\n\nSet `autoComplete: true` on **creation** to let Hostr finalize the upload when\nstorage contains every numbered part at exactly the declared sizes. It defaults\nto false, preserving the existing client-driven completion and SHA-256 flow.\nThe phone may still call `/complete`; it is serialized with server recovery.\nDo not opt in if completion must wait for a later client SHA-256 assertion:\nautomatic completion may win that race and that later assertion is not retained.\n\nAn alarm starts checking after one minute. Checks back off to fifteen minutes\nwithout progress and stop after seven days without observed progress; a new\npresign or token request rearms them. These checks never discard incomplete\nparts. The existing abandonment policy remains separate. GET upload status\nincludes `activity` with the last check, last observed progress, stored bytes,\npart numbers, and a waiting flag. That flag diagnoses inactivity, not its cause.\n\nRecovery persists the verified part manifest before completing storage, confirms\nthe object\'s size and composite ETag after a lost completion response, and\nresumes a partially published transcode job. The standard `video.uploaded` event\nand usage metering share a stable completion event ID; queue messages remain\nat-least-once. Cancel and complete are serialized for opted-in sessions.\n\nOn iOS, enqueue all available file-backed part tasks while execution is\navailable, with bounded staging concurrency and a disk-space reserve. Do not\ngate preparation of the next part on the previous network completion. iOS still\ncontrols when transfers run; force-quitting requires relaunch to resume.\n\n### The rules that bite\n\n**Every part is exactly `partSize` bytes except the last.** Use the `partSize`\nthe server returned, not one you picked.\n\n**`size` must be exact.** Completion verifies both the part count and the total\nbyte count against what actually landed. Mismatches are `409`:\n\n- `incomplete_upload` \u2014 `missing` lists the absent part numbers, first 50. Upload\n them and complete again.\n- `size_mismatch` \u2014 `declaredSize` and `receivedBytes` are both reported. Abort\n and restart; the file you declared is not the file you sent.\n\nPart count alone would accept N parts of the wrong sizes, which is why both are\nchecked.\n\n**Request presigned URLs only for the parts you are about to send.** They expire.\nMinting all of them up front for a large file wastes most of them.\n\n**Completion is idempotent.** A client that crashed after bytes landed but before\nit saw the response can safely retry and will get the stored result back rather\nthan a conflict.\n\n### Worked example\n\n```bash\nAPI=https://api.hostr.sh\nKEY=$HOSTR_API_KEY\nFILE=video.mp4\nSIZE=$(wc -c < "$FILE")\n\n# 1. Create the session.\nCREATE=$(curl -sS -X POST "$API/v1/uploads" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"filename\\":\\"$FILE\\",\\"contentType\\":\\"video/mp4\\",\\"size\\":$SIZE}")\n\nASSET=$(echo "$CREATE" | jq -r .assetId)\nPART_SIZE=$(echo "$CREATE" | jq -r .partSize)\nPARTS=$(echo "$CREATE" | jq -r .expectedParts)\n\n# 2. Split, presign, and PUT each part.\nsplit -b "$PART_SIZE" "$FILE" part-\ni=1\nfor p in part-*; do\n URL=$(curl -sS -X POST "$API/v1/uploads/$ASSET/parts" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"partNumbers\\":[$i]}" | jq -r \'.urls[0].url\')\n curl -sS -X PUT --data-binary "@$p" "$URL" -o /dev/null\n i=$((i + 1))\ndone\n\n# 3. Finalise.\ncurl -sS -X POST "$API/v1/uploads/$ASSET/complete" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"sha256\\":\\"$(shasum -a 256 "$FILE" | cut -d\' \' -f1)\\"}"\n```\n\nBatch the presign calls in real code \u2014 up to 100 part numbers per request.\n\n### Send the sha256\n\nThe `etag` completion returns is a *composite* multipart ETag: a hash of hashes,\nand dependent on the part size you used. It is not a content identity and cannot\nbe compared against a hash you computed locally.\n\nIf you want real integrity, send `sha256` \u2014 the lowercase hex SHA-256 of the\nwhole file. It is stored as object metadata and is what an independent\nverification would check against. A malformed value is ignored rather than\nrejected, since retrying the call cannot fix it.\n\n### Credentials\n\n`POST /v1/uploads` needs an API key with `upload` scope. The remaining upload\nroutes accept **either** that key or the `uploadToken` returned at creation.\n\nRe-minting a token (`POST /v1/uploads/:id/token`) requires the API key \u2014 not the\nexpiring token, which would otherwise be able to renew itself forever.\n\nUploading from a browser or mobile client? Do not ship the API key. See\n[Upload from a browser](#upload-from-a-browser).\n\n## Upload from a browser\n\nCreate the upload on your server, hand the client a narrow credential, and let it\nPUT parts directly. Upload bytes never touch your servers.\n\n### Forward only the client-safe fields\n\n`POST /v1/uploads` returns more than a client should see:\n\n```json\n{\n "assetId": "...", // safe\n "uploadToken": "...", // safe\n "tokenExpiresAt": 1756130400000, // safe\n "partSize": 8388608, // safe\n "expectedParts": 13, // safe\n\n "key": "acct_7Kd2/.../source.mp4", // storage internal \u2014 do NOT forward\n "uploadId": "2~aBcDeF..." // storage internal \u2014 do NOT forward\n}\n```\n\nForward exactly `assetId`, `uploadToken`, `tokenExpiresAt`, `partSize`, and\n`expectedParts`. Passing the whole body through leaks your storage layout to\nevery client.\n\nThe `uploadToken` is scoped to that one asset and grants nothing else \u2014 no other\nasset, and nothing on playback. That is why it is safe to hand out and an API key\nis not.\n\n### Origins: none to register\n\nPart PUTs go **directly to object storage**, not through the API. On hosted\nhostr the source bucket accepts them from **any origin** \u2014 production,\n`localhost` on any port, `*.vercel.app` previews \u2014 with nothing to ask for or\nconfigure. The presigned part URL is the credential, so there is no origin list\nto keep in sync.\n\n**Your own Content-Security-Policy can still block it.** If your app sets\n`connect-src`, add the storage host the part URLs point at\n(`https://*.r2.cloudflarestorage.com`) alongside the API host. A blocked\n`connect-src` fails exactly like being offline.\n\n**Self-hosting**, you own the bucket and set this rule on it yourself:\n\n```json\n{\n "AllowedOrigins": ["*"],\n "AllowedMethods": ["PUT"],\n "AllowedHeaders": ["*"],\n "ExposeHeaders": ["ETag"]\n}\n```\n\n`ExposeHeaders: ["ETag"]` is not optional \u2014 the client verifies each part\'s ETag\nand cannot read it otherwise. `AllowedHeaders` is not optional either: a part\nwhose `Blob` has a type sends `Content-Type`, and a preflight that refuses it\nblocks every part.\n\n### The failure mode, stated plainly\n\n**A browser that cannot reach storage is silent from the API\'s perspective.**\nThe upload session exists, zero parts ever land, and the client cycles\nrunning/stalled as though the network were flaky. To `XMLHttpRequest` a blocked\nPUT \u2014 by your CSP, an extension, a proxy, or (self-hosting) bucket CORS \u2014 is\nindistinguishable from a network error; the browser withholds the detail.\n\n**First move when a browser upload stalls at 0%: open the network tab and look\nat the part PUT** (and its `OPTIONS` preflight), not the Worker.\n\nThis cost real time on the first customer integration, which is why the SDK\nsurfaces it itself. After three consecutive network-class part failures with\n**zero bytes ever confirmed** for the session, it emits:\n\n```js\nupload.on(\'diagnostic\', d => {\n // { code: \'systemic-transport-failure\', consecutiveNetworkFailures, bytesEverConfirmed, hint }\n console.warn(d.hint);\n});\n```\n\nZero-ever-confirmed is what separates this from ordinary mid-upload network loss.\nIt is **advisory, never terminal**: a client that is genuinely offline from byte\nzero looks identical, so the upload keeps retrying regardless. The event is the\nhook for telling the user to check their connection \u2014 or for telling *you* to\ncheck your CSP.\n\n### Token expiry mid-upload\n\nLong uploads outlive their token. Handle it on your server:\n\n```js\nconst upload = createUpload({\n file,\n assetId,\n uploadToken,\n onTokenExpired: async () => {\n const r = await fetch(\'/api/hostr/refresh-token\', {\n method: \'POST\',\n body: JSON.stringify({ assetId }),\n });\n return (await r.json()).uploadToken; // your server calls POST /v1/uploads/:id/token\n },\n});\n```\n\nThe SDK re-mints and retries in place. Nothing is lost even without this \u2014 a\nkilled client resumes from what storage reports on the next launch \u2014 but the hook\navoids the interruption.\n\nSee the [SDK reference](#sdk-reference) for the full option surface.\n\n## Resume and integrity\n\n### Resume asks storage, not the client\n\n```\nGET /v1/uploads/:id\n -> { status, partSize, expectedParts, uploadedParts, parts, bytesUploaded }\n```\n\n`uploadedParts` is part numbers only; `parts` is the richer form, each entry\ncarrying `partNumber`, `size`, and `etag`.\n\nThe API derives this by listing what is actually in storage, not by reading\nbookkeeping the client sent. That is the whole design: a crash, a refresh, a\ndropped connection, or a client that lied all converge on the same truth. Resume\nis simply "send the parts that are not in that list".\n\nParts are listed only while `uploading`. Once completed or aborted the list is\nempty, because there is nothing left to resume.\n\nResume has an outer limit: an upload with no activity for 14 days is collected\nand moved to `aborted`. That window is twice the upload token\'s lifetime, so an\nexpired token never costs you the upload \u2014 re-mint at\n`POST /v1/uploads/:id/token` and carry on. See\n[Assets and lifecycle](#assets-and-lifecycle).\n\n### Three layers, three different claims\n\nEach layer proves something the others do not.\n\n**Per-part ETag.** Storage returns an ETag on every part PUT. The client compares\nit against the part it just sent, so a corrupted or truncated part is caught\nimmediately rather than at the end of a multi-gigabyte upload.\n\n**Part count and total size at completion.** Completion refuses to finalise\nunless the number of parts and the total byte count both match what was declared\nat creation. Count alone would accept N parts of the wrong sizes.\n\n**Whole-file SHA-256, if you send it.** This is the only one that proves content\nidentity.\n\n### Why the ETag is not enough\n\nThe ETag on a completed multipart object is a **hash of the part hashes**, then\nthe part count \u2014 `b8f1c0d9e2a34567-13`. Two consequences:\n\n- It cannot be compared against a hash you compute locally.\n- The same file uploaded with a different `partSize` produces a different ETag.\n\nSo it is a useful transfer check and a useless content identity. Send `sha256`\n(lowercase hex of the whole file) at completion if identity matters; it is stored\nas object metadata.\n\nIndependent verification, deriving both from the bytes actually in storage rather\nthan from anything the client claimed:\n\n```bash\npnpm verify:upload <assetId>\n```\n\n### Completion is idempotent\n\nA client that crashed after the bytes landed but before it saw the response\nretries and gets the stored `etag` and `bytes` back, not a `409`. Any genuinely\nunfinished status still conflicts with `not_uploading`.\n\nThis matters more than it sounds: the window between "storage has everything" and\n"the client knows" is exactly where flaky mobile connections die.\n\n### Aborting\n\n```\nDELETE /v1/uploads/:id\n```\n\nAn in-flight multipart session holds part storage that is invisible in normal\nlistings. Abandoning an upload without aborting leaks it. The SDK and CLI do this\nfor you; a hand-rolled client should too.\n\n## Webhooks and the event log\n\nhostr POSTs lifecycle events to one endpoint per account so you do not have to\npoll.\n\n```\nPUT /v1/webhooks {"url": "https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables; delivery history is retained\n```\n\nSupply `secret` in the PUT to bring your own. Omit it and a **live endpoint\nkeeps the secret it already has**, so changing the URL or re-running setup never\nbreaks signature checks; a new endpoint (or one switched off with DELETE) gets a\ngenerated one. `rotate: true` (CLI: `--rotate`) replaces a live secret \u2014 deploy\nthe new value promptly, since deliveries are signed with it at once. Configuration is cached about 60 seconds in the Worker, the same\npropagation window as key revocation.\n\nA URL configured without a secret logs and skips: **unsigned events are never\nsent.**\n\n### The log is written first\n\nEvery event becomes a durable row **before** delivery is attempted. A consumer\noutage cannot lose one.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n**The webhook is the wake-up signal; the log is the ledger.** Delivery is\nat-least-once \u2014 three attempts with short backoff, each recorded and inspectable.\nA consumer that must not miss anything reconciles against `GET /v1/events` (or\n`GET /v1/assets/:id`) rather than trusting that every POST arrived.\n\n`npx @hostr/cli tail --follow` polls the log for you.\n\n### Building a consumer that behaves\n\n**Verify the signature before parsing.** See\n[Verifying webhooks](#verifying-webhook-signatures) \u2014 use the raw body bytes.\n\n**Dedupe on `id`.** It is the durable event row\'s id, and a redelivery reuses it\ndeliberately so your dedupe still works.\n\n**Respond `2xx` fast.** Do the real work asynchronously. Anything else is\nretried.\n\n**Expect at-least-once, and design for it.** `video.ready` and `video.errored`\nfire on the status *transition* only, so a transcoder re-posting a terminal state\ncannot duplicate them \u2014 but the network can still deliver the same event twice.\n\n### Testing locally\n\nBoth `https` and `http` endpoints are accepted, including private addresses.\nhostr does not block internal ranges, so a local receiver works in development\nexactly as production does.\n\n```bash\nnpx @hostr/cli webhooks test # redelivers the newest event to your endpoint\n```\n\nSee the [events reference](#event-reference) for every type and payload.\n\n## Verifying webhook signatures\n\nEvery delivery carries a `hostr-signature` header:\n\n```\nhostr-signature: t=1756130400,v1=3b1f2c9d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c\n```\n\n`v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`, hex-encoded. `t` is a unix\ntimestamp in **seconds**. This is the Stripe scheme, and Mux\'s.\n\n### Implementation\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n```\n\nWired into Express, where getting the raw body needs care:\n\n```js\nimport express from \'express\';\n\nconst app = express();\n\napp.post(\'/hostr-webhook\',\n express.raw({ type: \'application/json\' }), // NOT express.json()\n (req, res) => {\n const raw = req.body.toString(\'utf8\');\n if (!verifyHostrWebhook(process.env.HOSTR_WEBHOOK_SECRET, req.get(\'hostr-signature\'), raw)) {\n return res.status(400).end();\n }\n const event = JSON.parse(raw); // parse only after verifying\n res.status(200).end(); // ack fast\n queue.push(event); // then do the work\n });\n```\n\n### The three ways this goes wrong\n\n**Using the parsed body instead of the raw bytes.** `JSON.parse` followed by\n`JSON.stringify` does not reproduce the original bytes \u2014 key order, whitespace,\nand number formatting can all shift. The HMAC is over what was actually sent.\nRegister the raw body parser on this route specifically.\n\n**A plain `===` on the hex digest.** String comparison short-circuits on the\nfirst differing character, which leaks timing information. Use\n`timingSafeEqual`, and compare lengths first \u2014 it throws on mismatched lengths.\n\n**Ignoring `t`.** Without a bounded timestamp window a captured delivery can be\nreplayed at any time in the future. Five minutes is a reasonable tolerance.\n\n### Do not confuse the units\n\n`t` is unix **seconds**. Elsewhere in the API, `tokenExpiresAt` and `expiresAt`\nare epoch **milliseconds**. That is deliberate \u2014 each convention matches its home\n(Stripe\'s for signatures, Mux\'s for token bundles) rather than being made falsely\nuniform. Mixing them up produces a timestamp roughly 55,000 years out, which at\nleast fails loudly.\n\n### Getting the secret\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nWritten straight to the file at mode 0600; only the variable name is printed. The\nsecret is returned **once**, in the `PUT /v1/webhooks` response, and never again\n\u2014 `GET /v1/webhooks` deliberately does not include it. Lost it? Set a new one.\n\n### Proving it end to end\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\nRedelivers the newest stored event to your endpoint, so you can confirm the whole\npath without uploading anything.\n\nIf your endpoint was down when an event fired, nothing was lost \u2014 see\n[Webhooks and the event log](#webhooks-and-the-event-log).\n\n## Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `playback`-scoped\nkey.\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"vp_5b0e2a7c9d1f4e83"} (body optional)\n -> { playback, thumbnail, storyboard, expiresAt }\n```\n\n```bash\nnpx @hostr/cli assets token <assetId> --json\n```\n\nField names mirror Mux\'s bundle, so a consumer migrating swaps its base URL and\nkeeps its client code.\n\n### Build the URL\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\nHand it to any HLS player. Safari and iOS play HLS natively; elsewhere use\nhls.js.\n\n```html\n<video id="v" controls></video>\n<script type="module">\n import Hls from \'https://cdn.jsdelivr.net/npm/hls.js@1/+esm\';\n const src = `https://stream.hostr.sh/${playbackId}.m3u8?token=${token}`;\n const v = document.getElementById(\'v\');\n if (v.canPlayType(\'application/vnd.apple.mpegurl\')) v.src = src;\n else if (Hls.isSupported()) { const h = new Hls(); h.loadSource(src); h.attachMedia(v); }\n</script>\n```\n\n### Mint timing, and what to gate on\n\nMinting works any time after the asset exists \u2014 it 404s only for unknown or\ndeleted assets. **The stream itself 404s until the asset is `ready`.**\n\nSo gate your UI on `video.ready` or the asset\'s status, never on whether minting\nsucceeded. A successful mint for a still-encoding asset is expected behaviour,\nnot a green light.\n\n### The TTL is not a viewing limit\n\nThe token is checked **once**, at the master playlist. After that the session\ncarries its own internally-managed credentials, so a viewer who starts playback\nat minute 59 of a one-hour token watches the whole video.\n\n`ttlSec` accepts 60 to 86,400 and defaults to 3,600. It only needs to be valid\nwhen playback *starts*.\n\n### viewerId\n\nOptional, opaque, and carried inside the token. It enables per-viewer concurrency\nobservation and attributes watch data to a viewer. Concurrency is currently\nobserved rather than enforced \u2014 see [Tokens and signed playback](https://docs.hostr.sh/concepts/playback/).\n\n**Send a pseudonym, never a user id or email.** The token sits in playback\nURLs, and anyone holding a URL can read its claims. Use a random id you store\nagainst the user (for example a UUID column) and send that. It stays stable\nfor the user, so watch history stays joined, and deleting it erases the link\nback to the person. Do not derive it from the user id with a hash: anyone who\ncan guess user ids can recompute it, and it cannot be erased per user. Choose\nbefore you start sending one, because changing it later splits that viewer\'s\nhistory in two. Omit it for signed-out viewers; they are counted, not\nattributed.\n\n### Things that will confuse you once\n\n**Non-browser user agents are rejected** with `403 blocked_user_agent` \u2014\ncurl, wget, yt-dlp, vlc, and ffmpeg UAs are blocked as a casual-ripping speed\nbump. A server-side smoke test must send a browser-like `User-Agent` or hit\n`/health`, which is exempt. Expect it; do not debug it.\n\n**Signed URLs are not DRM.** They stop link-sharing and hotlinking, not an\nentitled viewer recording their screen.\n\n**Deleted assets stop minting immediately**, but tokens already issued can replay\nedge-cached segments for a few hours until they expire.\n\n### Poster and scrub previews\n\nThe same mint response covers both, each URL taking its matching token:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSee [Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Audio only, for transcription\n\nThe `playback` token also opens an audio-only file, available seconds into the\nencode \u2014 before the asset is playable:\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nSee [Audio for speech-to-text](#audio-for-speech-to-text).\n\n## Thumbnails and storyboards\n\n`POST /v1/assets/:id/tokens` returns three tokens, not one. The `thumbnail` and\n`storyboard` values drive the poster image and the scrub preview.\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSame host, same query-token shape as the playlist. Each URL takes its **own**\ntoken \u2014 they are minted with different audiences and are not interchangeable.\n\n### Poster\n\nA single JPEG extracted during encoding. Use it as the `poster` attribute:\n\n```html\n<video poster="https://stream.hostr.sh/abc123/thumbnail.jpg?token=..." controls></video>\n```\n\n### Choosing the poster frame\n\nThe encode-time poster is a frame 10% in \u2014 a fine default and, often enough,\nthe wrong picture. `POST /v1/assets/:id/poster` re-takes it from any second:\n\n```json\nPOST /v1/assets/{id}/poster\n{ "time": 12.5 }\n\n202 { "assetId": "...", "rev": "mfc9z0aa", "time": 12.5, "queued": true }\n```\n\nThe frame is cut from the best rung the ladder has published \u2014 a `playable`\nasset works, at the quality it has so far \u2014 and lands under its own immutable\nkey. `thumbnail.jpg` keeps its URL and changes content: wait for\n`video.poster_ready` (it carries the same `rev`), then **mint a fresh token\nbundle** \u2014 a `thumbnail` token minted before the job landed keeps serving the\nold picture until it expires. Store a copy if you serve posters from your own\nCDN; that is what the event is for.\n\nPoster jobs run on their own lane: a running worker takes them beside\nwhatever it is encoding, so a frame never waits behind a ladder. What it can\nwait behind is a cold start \u2014 the fleet scales to zero and a machine is about\nseven seconds from taking a job. `POST /v1/assets/:id/poster/warm` starts one\nahead of time; call it when a user opens your thumbnail editor and the frame\nthey pick a moment later is seconds away. It is a no-op while any machine is\nup, and costs one machine\'s idle window when nothing follows.\n\nThe record says which frame is live (`posterRev`, `posterTime`) and whether a\njob is running (`poster`, `null` once it lands). One job at a time per asset;\na failed or ten-minute-stale request no longer blocks the next one. There is\nno way to render an arbitrary frame on request \u2014 the media host has no\ndecoder \u2014 so build a scrubber on the storyboard below and confirm with one\ncall, rather than previewing through this endpoint.\n\n### Storyboard\n\nA standard "thumbnails" WebVTT track. Cues point into a tiled sprite using\n`#xywh=` fragments, and **the sprite URLs inside the VTT are pre-signed for you**\n\u2014 you do not mint anything extra.\n\nThat is the convention mux-player\'s `storyboard-src`, media-chrome\'s preview\ntrack, and most other players already consume:\n\n```html\n<mux-player\n storyboard-src="https://stream.hostr.sh/abc123/storyboard.vtt?token=..."\n></mux-player>\n```\n\n### Treat both as optional\n\nAssets encoded before storyboards existed 404 on that URL, and scrubbing simply\nshows no preview. Players handle this gracefully by design \u2014 do not add error\nhandling that turns a missing preview into a broken player.\n\n## Audio for speech-to-text\n\nIf you want hostr to do the transcription, see\n[Auto-captions](#auto-captions-and-translation). This page is for bringing your own\ntranscriber.\n\nTranscription services want one audio file URL, not an HLS playlist. hostr\nmakes one per asset \u2014 `audio.m4a`, a single AAC track in an MP4 container \u2014\nand makes it **first**, ahead of every video rendition. A twenty-minute\nsource has its audio file within seconds of the job starting, minutes before\n`video.playable`.\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nIt takes the **`playback`** token from `POST /v1/assets/:id/tokens` \u2014 the same\none that opens the master playlist. There is no fourth token to mint.\n\n### When it exists\n\nListen for **`video.audio_ready`**. It fires once, the moment the file is in\nthe media bucket, and carries `assetId`, `playbackId`, `durationSec` and an\n`audio` block (`bytes`, `codec`, `channels`, `sampleRate`, `copied`).\n\n```json\n{\n "id": "evt_01j\u2026",\n "type": "video.audio_ready",\n "createdAt": "2026-09-08T10:14:07.412Z",\n "data": {\n "assetId": "9f8c1e2a\u2026",\n "playbackId": "9f8c1e2a\u2026",\n "status": "processing",\n "durationSec": 1842.4,\n "audio": { "bytes": 29478912, "codec": "aac", "channels": 2, "sampleRate": 48000, "copied": true }\n }\n}\n```\n\nThe event also carries `loudness` \u2014 the probe\'s EBU R128 measurement of the\nsource, see [the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\nThe same block is on the asset record as `audio`. It is **absent** until the\ntranscoder has looked at the source and **`null`** when the source has no\naudio stream \u2014 in which case `video.audio_ready` never fires, and a poller can\ntell "no audio" from "not yet" without waiting.\n\nBefore the file exists the URL is a plain `404`.\n\n### Assets you uploaded before this existed\n\nEvery upload since 2026-09-09 gets its audio file automatically. Assets\nencoded before that have none, and nothing will produce one on its own \u2014 so a\ncatalogue uploaded before you added transcription cannot be captioned until\nyou ask for it. Ask once per asset:\n\n```\nPOST /v1/assets/{id}/audio\n```\n\nIt requires the `upload` scope (this is content generation, the same rung as\nattaching a subtitle track) and answers `202 {"assetId": "\u2026", "queued": true}`.\nThe job publishes `audio.m4a` and nothing else \u2014 no re-encode of the ladder,\nno new playlists, no change to the asset\'s status \u2014 and then fires\n`video.audio_ready` exactly as a fresh upload would, so the consumer you\nalready wrote needs no second code path.\n\nTwo refusals, both `409`: `already_has_audio` when the record already carries\nan `audio` block, and `not_encoded` when the asset has no finished source\nobject yet (an upload still in flight, or aborted). Calling it twice is safe\nregardless \u2014 a job that finds the file already in the bucket reports it\ninstead of re-encoding.\n\nTo sweep a whole account, the CLI does the paging and the per-asset check:\n\n```bash\nnpx @hostr/cli assets audio --missing # every playable/ready asset with no audio\nnpx @hostr/cli assets audio <assetId> # just this one\n```\n\n`--missing` skips assets whose `audio` is `null` \u2014 the transcoder already\nlooked and the source is silent, so there is nothing to extract.\n\n### Handing it to a transcriber\n\nMint a token whose lifetime covers the fetch, then pass the URL straight\nthrough. AssemblyAI, for example:\n\n```bash\nTOKEN=$(npx @hostr/cli assets token "$ASSET" --ttl 3600 --json | jq -r .playback)\n\ncurl -X POST https://api.assemblyai.com/v2/transcript \\\n -H "authorization: $ASSEMBLYAI_API_KEY" \\\n -H \'content-type: application/json\' \\\n -d "{\\"audio_url\\":\\"https://stream.hostr.sh/$ASSET/audio.m4a?token=$TOKEN\\"}"\n```\n\nThe token only has to be valid when the fetch **starts**, but give it a few\nminutes of headroom: services queue downloads. `Range` requests are honoured\n(`206`, `Accept-Ranges: bytes`), so a fetcher that streams or resumes works.\n\n**The user-agent blocklist does not apply to this URL.** Playlists reject\ncurl-like user agents as a casual-ripping speed bump; a transcription\nservice\'s fetcher is exactly the kind of client that rule would catch, so the\naudio file is exempt. The token is still required.\n\n### What is in the file\n\n- **AAC source, no normalisation** \u2014 the stream is copied untouched: the\n source\'s channel count, sample rate and bitrate, no decode. `copied: true`.\n- **Anything else** \u2014 encoded once with the ladder\'s own audio settings (AAC,\n 48 kHz, the top group\'s bitrate, the same filter chain), so what the\n transcriber hears is what the viewer hears. `copied: false`. An asset with\n [normalisation](https://docs.hostr.sh/concepts/ladder/#audio-options) on is always this case:\n the file must carry the same gain the viewer gets.\n\nEither way the `moov` atom is at the front, and the object is versioned with\nthe asset and immutable.\n\n### Then attach the result\n\nWhen the transcript comes back, attach it with\n`PUT /v1/assets/:id/subtitles/:lang` (raw WebVTT). It is accepted as soon as the\nasset has a known duration, which `video.audio_ready` guarantees \u2014 so a\ntranscript that comes back before the asset is `playable` attaches without\nwaiting or retrying. See [Subtitles](#subtitles).\n\n## Subtitles\n\nHuman-authored WebVTT tracks, attachable any time after the asset is `ready`.\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { assetId, tracks: [{ lang, name, default, rev, source? }] }\nDELETE /v1/assets/:id/subtitles/:lang\n```\n\n```bash\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\nnpx @hostr/cli subtitles list <assetId>\nnpx @hostr/cli subtitles rm <assetId> en\n```\n\nThe PUT body is **raw WebVTT, not JSON**. It must start with `WEBVTT`, and is\ncapped at 2,000,000 bytes.\n\n```bash\ncurl -X PUT "https://api.hostr.sh/v1/assets/$ASSET/subtitles/en?name=English&default=true" \\\n -H "authorization: Bearer $HOSTR_API_KEY" \\\n -H \'content-type: text/vtt\' \\\n --data-binary @subs.vtt\n```\n\n### Rules\n\n**`:lang` is a BCP 47 tag** \u2014 `en`, `pt-BR`. One track per tag; PUT replaces.\nAnything that is not a valid tag is `400 bad_language`.\n\n**The asset must have a known duration.** Before that it is `409 not_ready` \u2014\nthe track playlist needs it. It is known from `video.audio_ready` (seconds\ninto the job, once the transcoder has probed the source) and from `playable`\nonwards; a track attached while the ladder is still encoding appears the\nmoment the master playlist is served, because tracks are injected at serve\ntime, not baked into the encode.\n\n**`source` says who wrote it.** Absent on your tracks; `auto` / `auto-translated` on\nmachine-made ones (see [Auto-captions](#auto-captions-and-translation)). A PUT always makes the\ntrack yours.\n\n**`default=true` demotes any previous default.** At most one default per asset.\n\n**Write scope is `upload`, not `playback`.** Subtitles are content, not\nentitlement.\n\n### How they reach the player\n\nTracks are injected into the master playlist at serve time as an `EXT-X-MEDIA`\nsubtitles group. The next master fetch after a PUT includes them.\n\n**No re-encode. No new playback URL. No token change.** Players pick them up in\nthe captions menu automatically.\n\nTracks are stored under a content-hash revision, so replacing one is cache-safe\neven though segments are served with a one-year immutable cache. A viewer already\nmid-session keeps the track they started with; the replacement applies to the next\nplayback session.\n\n### Deleting\n\nRemoval delists the track first \u2014 that is what makes it invisible to players. The\nunderlying versioned objects become unreachable and are purged with the asset, so\nleftovers cost nothing.\n\n`404 subtitle_not_found` means no track for that tag; `404 asset_not_found` means\nno such asset. They are distinct codes.\n\n## Auto-captions and translation\n\nhostr transcribes an asset\'s audio and attaches the result as an ordinary\n[subtitle track](#subtitles), then translates it into the languages\nyou name. Nothing re-encodes; players pick the tracks up from the master\nplaylist like any other.\n\n```\nPUT /v1/account/captions { "enabled": true } \u2014 turn captions on (full scope); off by default\nPOST /v1/uploads { ..., "captions": { "language": "auto", "translate": ["en","es"], "context": "Creator: Ana" } }\nPOST /v1/renders same field\nPOST /v1/assets/:id/render same field \u2014 applied when the new cut lands\nPOST /v1/assets/:id/captions transcribe now, or redo (body optional)\nPOST /v1/assets/:id/captions/translate { "languages": ["fr"] } \u2014 from the stored transcript\nGET /v1/assets/:id the `captions` block: transcript + per-language state\nGET /v1/assets/:id/subtitles tracks carry `source`: absent (yours), `auto`, `auto-translated`\n```\n\nWriting captions \u2014 the `captions` field and both `POST` routes \u2014 needs `upload`\nscope: captions are content, not entitlement. Reading them back\n(`GET /v1/assets/:id`, `GET \u2026/subtitles`) needs only `read`.\n\n### Turn them on\n\nCaptions are **off** for a new account, because they cost money. Turn them on\nonce, for the whole account:\n\n```\nPUT /v1/account/captions { "enabled": true } on, no monthly limit\nPUT /v1/account/captions { "enabled": true, "monthlyMinutes": 600 } on, at most 600 transcription minutes a month\nPUT /v1/account/captions { "enabled": false } off (forgets the limit)\n```\n\nThe same switch is on the dashboard\'s Usage page and in the CLI\n(`hostr captions enable [--limit <minutes>]`, `hostr captions disable`,\n`hostr captions info`). It needs a `full` key: turning on spending is an\naccount decision, the same rung as keys and webhooks. An agent integrating\nhostr should ask its human before turning captions on, not do it unasked.\nThe answer is the new allowance (below). The change reaches every API edge\nwithin a minute.\n\nTranscription is billed per minute of audio, and each translation per minute\nper language ([Billing](#billing) has the prices; `GET /v1/billing`\nlists them). Runs sit behind the same card gate as uploads. Turning captions\noff refuses new runs; runs already submitted finish and are billed.\n\n### When it runs\n\nWith `captions` on the upload, transcription starts the moment `audio.m4a`\nexists \u2014 seconds into the encode, minutes before `video.playable` \u2014 so on a\nnormal source the tracks are there before the video is. Translations follow\none by one as `captions.ready` events; the source language lands first.\n\n`POST /v1/assets/:id/captions` runs any time the asset has audio. Unlike Mux\'s\nseven-day window on generated subtitles, there is no time limit. With no body it\nreuses what was asked for last time. It answers:\n\n```\n202 { "assetId": "\u2026", "rev": "mfc9z0aa", "queued": true }\n```\n\n`rev` is the new run\'s id. Keep it: every `captions.*` event carries the `rev`\nof the run it is about, and an event whose `rev` is not the one you hold is\nfrom a run this one replaced.\n\n`context` is free text handed to the transcriber to help with names and\nvocabulary \u2014 creator, title, domain terms. It is trimmed and cut at **1000\ncharacters**; a longer value is truncated, not refused.\n\nOnly one run exists per asset at a time. While a run is at the transcription\nprovider, a second `POST` answers `409 captions_in_progress`. So does one that\narrives within two minutes of a run starting, before it has reached the\nprovider, and so does the loser of two simultaneous requests. A run that never\nproduces a transcript is failed with `asr_failed` after two hours, which\nreleases the asset for a new one.\n\nA transient answer from the provider (a rate limit, a 5xx) is not a failure:\nthe completion is retried by the provider\'s callback or by hostr\'s five-minute\npoll, and nothing is reported until the provider says the job failed or the two\nhours are up.\n\n### It is metered\n\nEvery run counts the source duration as **transcription minutes** for the UTC\ncalendar month. Translation is billed separately, per language-minute\n(`translationSeconds` on `GET /v1/usage`), and the monthly limit does not count\nit. `GET /v1/account` shows\n`captions: { minutesUsed, minutesCap, resetsAt }`, and so does the dashboard\'s\nUsage page. `minutesCap` has three meanings:\n\n| `minutesCap` | Meaning |\n|---|---|\n| `0` | Captions are off for the account. Turn them on with `PUT /v1/account/captions`. |\n| `null` | Captions are on with no monthly limit. Every minute is still counted. |\n| a number | Captions are on, up to that many minutes this month. |\n\nThe cap is only a ceiling; `minutesUsed` (and the daily `captionSeconds` on\n`GET /v1/usage`) is the count either way.\n\nThe explicit routes refuse **before** spending: `403 captions_cap_exceeded`\n(or `captions_not_enabled`) with `minutesUsed`, `minutesCap` and `resetsAt` in\nthe body. `POST \u2026/captions/translate` refuses `captions_not_enabled` the same\nway. The upload path cannot know the duration yet, so there the cap refusal is\nasynchronous: `captions.errored` with `reason: cap_exceeded`, and the `captions`\nblock on the asset says the same. A key whose account has captions off (cap 0)\nis refused at create time on `POST /v1/uploads`, `POST /v1/renders` and\n`POST /v1/assets/:id/render` with `403 captions_not_enabled`, and the same\nthree fields. A `captions` value that is not an object (`null` included) is\n`400 bad_captions`.\n\n### Your tracks win\n\nOne track per language tag. A track you upload with\n`PUT /v1/assets/:id/subtitles/:lang` has no `source` and is yours: a redo never\nreplaces it, and a requested language it owns is reported in `skippedLangs`\nrather than written. That is the correction loop: download the auto VTT, fix\nit, PUT it back, and it stays fixed through every redo. PUT over an auto track\nmakes it yours.\n\nThis is enforced when the track is written, not only when the run is planned: a\ntrack you upload while a translation is running is never replaced, and the run\nreports that language in `skippedLangs` instead.\n\nAfter a [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) the stored request is\napplied again under a new run, because the old captions are timed to the old\ncut. A track you corrected is still skipped \u2014 the `captions.ready` event\'s\n`skippedLangs` tells you it is now mistimed and needs your attention. A plain\nre-encode changes no timing and does nothing to captions.\n\n### Languages\n\n`language` is `auto` (detected) or any BCP 47 tag you know the speech is in.\n`translate` takes any tags, at most 10 per request; a target in the source\nlanguage is dropped \u2014 any variant of it, so from Spanish speech `es-MX` is\ndropped too (that is localisation, not translation). The detected source track\nis written under the primary tag (`en`, not `en-US`): the transcriber detects\nlanguages, not regional variants. A declared one is written under exactly what\nyou said (`pt-BR`).\n\nA translated track\'s region comes only from the tag you ask for: `es-MX`,\n`es-419` (Latin America) and `es-ES` are three separate tracks, and the target\n\u2014 region included \u2014 is named to the translation model. One Spanish target is\nusually enough; `es-419` is the common choice for a Latin American audience.\n\nTrack names are the language\'s own name plus the suffix, with a region or\nscript named alongside it so variants are told apart in the player menu:\n`Espa\xF1ol (auto)`, `English (auto-translated)`, `Espa\xF1ol (M\xE9xico, auto-translated)`,\n`Portugu\xEAs (Brasil, auto)`, `\u4E2D\u6587 (\u7E41\u9AD4, auto-translated)`.\n\n### Reading progress: the `captions` block\n\n`GET /v1/assets/:id` carries the asset\'s latest run as `captions` (absent if\ncaptions were never requested). A new run, a redo or a re-edit replaces it.\n\n```json\n"captions": {\n "rev": "mg3k1z9q",\n "requestedAt": "2026-10-09T15:00:00.000Z",\n "request": { "language": "auto", "translate": ["en", "es"], "context": "Creator: Ana" },\n "transcript": { "status": "ready", "lang": "pt", "durationSec": 1842.4, "attempts": 1,\n "submittedAt": "2026-10-09T15:00:04.000Z", "finishedAt": "2026-10-09T15:02:11.000Z" },\n "tracks": {\n "pt": { "status": "ready", "source": "auto", "name": "Portugu\xEAs (auto)", "rev": "9c1e2a4b6d47", "attempts": 0, "updatedAt": "\u2026" },\n "en": { "status": "ready", "source": "auto-translated", "name": "English (auto-translated)", "rev": "b6d47f0a1b2c", "attempts": 1, "updatedAt": "\u2026" },\n "es": { "status": "pending", "source": "auto-translated", "attempts": 1, "updatedAt": "\u2026" }\n },\n "skippedLangs": [],\n "inFlight": true\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `rev` | The run\'s id. Every `captions.*` event carries it; ignore events whose `rev` is not the asset\'s current one. |\n| `request` | What was asked for. A redo with no body, and every re-edit, reuses it. |\n| `transcript.status` | `pending` (waiting for the audio file), `submitted` (at the transcription provider), `ready`, or `failed`. |\n| `transcript.lang` | The spoken language, as the source track\'s tag. Detected languages use the primary tag (`en`, not `en-US`). |\n| `transcript.reason`, `transcript.error` | On `failed`: why. `reason` is one of the codes under [Events](#events). |\n| `tracks.<lang>.status` | `pending` (being made), `ready` (serving), or `failed` (`error` says why). |\n| `tracks.<lang>.source` | `auto` for the spoken language, `auto-translated` for the rest. |\n| `tracks.<lang>.rev`, `name` | The subtitle track\'s content revision and menu label, once written. |\n| `tracks.<lang>.attempts` | Translation attempts so far. |\n| `skippedLangs` | Requested languages left alone because a track you uploaded owns them. |\n| `inFlight` | `true` while the transcript or any track is still owed. Stop polling when it is `false`. |\n\nOn `GET /v1/assets/:id/subtitles`, a track\'s `source` is the same marker:\n`auto`, `auto-translated`, or absent. **Absent means hostr treats the track as\nyours**, including any machine tracks you attached with your own pipeline\nbefore you used hostr captions. A redo will not replace those. To have hostr\ntake a language over, delete that track first\n(`DELETE /v1/assets/:id/subtitles/:lang`), then redo.\n\n### From the terminal\n\n```bash\nnpx @hostr/cli upload video.mp4 --captions --translate en,es --context "Creator: Ana"\nnpx @hostr/cli captions status <assetId> # the captions block, summarised\nnpx @hostr/cli captions request <assetId> # redo with the stored request\nnpx @hostr/cli captions request <assetId> --lang pt --translate en,es\nnpx @hostr/cli captions translate <assetId> --lang fr,de\n```\n\nEvery command takes `--json`. `captions request` and `captions translate`\nneed `upload` scope.\n\n### Errors\n\n| Status | `error` | When |\n|---|---|---|\n| 400 | `bad_captions` | The body or `captions` field is malformed (`hint` says which), `null`, or a `languages` list is empty or invalid |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`). Body carries `minutesUsed`, `minutesCap`, `resetsAt` |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly cap. Same three fields |\n| 409 | `not_ready` | No audio yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | The source has no audio stream |\n| 409 | `captions_in_progress` | A run is at the provider, started under two minutes ago, or a concurrent request won |\n| 409 | `no_transcript` | `\u2026/translate` before a transcript exists |\n| 409 | `asset_unowned` | The asset has no owning account |\n| 502 | `asr_submit_failed` | The provider refused the submission (or could not be reached); `hint` carries its error. Nothing was metered for it. A submission the provider *accepted* is metered even if another request replaced the run meanwhile \u2014 that answers `409 captions_in_progress`, not 502 |\n| 503 | `captions_unavailable` | No transcription provider on this deployment |\n| 503 | `translate_unavailable` | No translation queue or provider on this deployment |\n\n### Events\n\n| Type | When | `data` |\n|---|---|---|\n| `captions.ready` | the transcript step finished, then once per translated track | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written` (false when a manual track owned the slot), `trackRev?`, `skippedLangs`, `pending` (languages still to come) |\n| `captions.errored` | a run or one language failed | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n\n`reason` is one of `not_enabled`, `cap_exceeded`, `no_audio` (silent source, or\nthe transcode failed first), `provider_unavailable`, `asr_failed`,\n`translate_unavailable`, `translate_failed`. A failed language never blocks the\nothers.\n\n`captions.errored` fires **at most once per run-level failure** (the transcript\nstage; it has no `lang`) and **once per failed language**. A second report of\nthe same failure, such as the provider\'s callback and hostr\'s poll both seeing\nit, does not repeat it.\n\n### What is stored\n\nThe transcript and the source VTT are kept with the asset, so\n`POST /v1/assets/:id/captions/translate` never transcribes again. Both are\npurged with the asset.\n\nTwo third parties see your content. The audio goes to the transcription\nprovider (AssemblyAI). Translation sends the transcript\'s text, cue by cue, to\nan LLM provider (Anthropic). The `context` string goes only to the\ntranscription provider, never to the translation model.\n\n## Event reference\n\nDelivered body: `{ id, type, createdAt, data }`. `id` is the durable event row\'s\nid \u2014 **dedupe on it**, and note that a redelivery reuses it deliberately.\n\n| Type | Fired when | `data` highlights |\n|---|---|---|\n| `upload.created` | Multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `upload.aborted` | Session ended without completing | `assetId`, `reason` (`client_abort` \\| `abandoned`), `error?` |\n| `video.uploaded` | Bytes verified in storage, transcode queued \u2014 or a render\'s join is ready | `assetId`, `key`, `bytes`, `etag?`, `sha256?`, `rendered?` |\n| `video.audio_ready` | `audio.m4a` exists \u2014 seconds in, before anything is playable | `assetId`, `playbackId`, `durationSec`, `audio`, `loudness` |\n| `video.playable` | Watchable at one quality, ladder still encoding | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.ready` | Full HLS ladder live | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.errored` | Transcode failed | `assetId`, `error` (always present) |\n| `video.poster_ready` | A chosen poster frame is live behind `thumbnail.jpg` (`POST /v1/assets/:id/poster`) | `assetId`, `playbackId`, `rev`, `time` |\n| `captions.ready` | One language finished (one event per language, the source language first): its track landed, or \u2014 `written: false` \u2014 a track you uploaded owned the slot and was kept | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written`, `trackRev?`, `skippedLangs`, `pending` |\n| `captions.errored` | A captions run, or one language of it, failed. At most once per run-level failure, once per failed language | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n| `video.deleted` | Asset erased, playback over | `assetId` |\n| `video.input.non_standard` | The source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`upload.aborted`, `video.playable`, `video.ready` and `video.errored` fire on the\nstatus **transition** only, so a repeated abort, or a transcoder re-posting a\nstate, cannot duplicate them.\n\n`upload.aborted` has one meaning \u2014 this upload ended without completing \u2014 and\n`reason` says how it got there: `client_abort` for `DELETE /v1/uploads/:id`, or\n`abandoned` for an upload the platform collected after 14 days idle (see\n[Assets and lifecycle](#assets-and-lifecycle)). Treat both as terminal; the reason\nis for your own reporting, not for deciding whether the upload is over.\n\n`video.audio_ready` fires **once per asset**, on the first report that carries\nthe audio file, and never for a source with no audio stream \u2014 the record\'s\n`audio` field is `null` there. It is the cue to start speech-to-text: see\n[Audio for speech-to-text](#audio-for-speech-to-text).\n\n`loudness` is the probe\'s EBU R128 measurement of the source and the gain the\nencode applied \u2014 `integratedLufs`, `loudnessRangeLu`, `truePeakDbtp`, `mono`,\n`normalization` \u2014 for creator warnings and playback-side gain. `null` on a\nsource with no audio stream. See\n[the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\n`video.playable` carries the same fields as `video.ready`, so publishing as early\nas possible is a matter of switching which one you listen for. Its `renditions`\nlists the single quality available so far; `video.ready` then lists the full\nladder. `video.errored` fires only when nothing is watchable \u2014 a ladder that\nfails after the asset is already playable does not fire it.\n\n`error` on `video.errored` is always present. An optional error field makes every\nconsumer invent its own generic message, which is worse than a bad message.\n\n`video.ready` fires **once more** when an asset is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding), carrying `reencoded: true` and the\nnew `rev`. Same shape, same meaning \u2014 a full ladder is live \u2014 so a consumer that\nonly stores "ready" needs no change; one that caches a poster should refresh it.\nA [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) landing also carries\n`rendered: true`: the content changed, so its duration may have too, and\ncaptions made for the old version no longer line up.\n\n### captions.*\n\nMachine captions: `captions.ready` fires once for the transcribed language and once\nper translated track; `captions.errored` for a failed run or a failed language. `rev`\nidentifies the run. Fields, `reason` values and the metering rules are in\n[Auto-captions and translation](https://docs.hostr.sh/guides/captions/#events).\n\n### video.input.non_standard\n\nAdvisory. The transcode proceeds normally and a `video.ready` still follows. It\nfires **at most once per asset**, and only when there is something to say \u2014 a\nfile with nothing unusual about it generates no traffic.\n\n| Finding code | Meaning |\n|---|---|\n| `hdr_tags_on_8bit` | Source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | Genuine 10-bit HLG or PQ HDR. Converted to 8-bit BT.709 SDR before the ladder split; HDR is not delivered (assets encoded before 2026-09-10 were relabelled instead \u2014 [re-encode](https://docs.hostr.sh/concepts/assets/#re-encoding) them) |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer. Converted to BT.709 at 10-bit; at 8-bit the tag is not trusted and the pixels are left alone |\n| `variable_frame_rate` | Container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | Source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe \u2014 `container`, `videoCodec`, `pixFmt`,\n`bitDepth`, `width`, `height`, `rotation`, `declaredFps`, `averageFps`,\n`targetFps`, `isVariableFrameRate`, `colorSpace`, `colorTransfer`,\n`colorPrimaries`. `width` and `height` are the *display* size \u2014 a portrait\nphone recording stores landscape pixels with a 90\xB0 rotation, and the ladder\nis built on what the viewer sees, not on how the frames are stored.\n\nThe same block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\n### Delivery\n\nThree attempts with short backoff, each recorded and inspectable. Every event is\nwritten to the durable log **before** the first attempt.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim\nGET /v1/events?order=desc&limit=1 newest first\nPOST /v1/events/:id/redeliver replay to the current endpoint\n```\n\nSignature verification: [Verifying webhooks](#verifying-webhook-signatures).\n\n## Error reference\n\nEvery non-2xx body is `{ "error": "<code>", ... }`. **Switch on `error`, never on\nthe message** \u2014 codes are stable, prose is not.\n\n| Status | Error | Meaning and action |\n|---|---|---|\n| 400 | `size_required` | `size` missing or not positive on `POST /v1/uploads` |\n| 400 | `partNumbers_required` | Empty or non-array `partNumbers` |\n| 400 | `too_many_parts_per_request` | More than 100 part numbers; `max` names the cap |\n| 400 | `part_out_of_range` | A part number below 1 or above `expectedParts` |\n| 400 | `bad_language` | Not a BCP 47 tag |\n| 400 | `bad_captions` | `captions` (or the body of `\u2026/captions`, `\u2026/captions/translate`) is malformed, `null`, or names no valid languages; `hint` says which |\n| 400 | `bad_captions_setting` | `PUT /v1/account/captions`: `enabled` is not a boolean, or `monthlyMinutes` is not a whole number of at least 1; `hint` says which |\n| 400 | `not_webvtt` | Subtitle body does not start with `WEBVTT` |\n| 400 | `bad_status` | Status outside the allowed set (internal route) |\n| 400 | `bad_scopes` | A scope outside `read`/`upload`/`playback`/`full` |\n| 400 | `bad_date` | A date parameter was not `YYYY-MM-DD` |\n| 400 | `authorization_pending` | Device login not yet approved \u2014 keep polling |\n| 400 | `slow_down` | Polling too fast \u2014 back off, keep polling |\n| 400 | `expired_token` | Device code expired. Start over |\n| 400 | `access_denied` | Device login was declined |\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `quota_uploads_daily` | Daily upload cap; `limit` is the ceiling |\n| 403 | `quota_assets` | Asset count cap; `limit` is the ceiling |\n| 403 | `quota_storage` | Storage cap; `limit` is the ceiling |\n| 403 | `account_suspended` | Contact the operator |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`); turn them on with `PUT /v1/account/captions` (full key) or `hostr captions enable`. `minutesUsed`, `minutesCap`, `resetsAt` are in the body |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly transcription minutes; same three fields. Retry after `resetsAt` or raise the cap |\n| 403 | `download_not_enabled` | Original downloads are not enabled for this account; the operator grants them |\n| 403 | `accountId_service_only` | Only the service key may name an owning account |\n| 403 | `blocked_user_agent` | Media host only \u2014 send a browser-like `User-Agent` |\n| 404 | `asset_not_found` | No such asset, or it has been deleted |\n| 404 | `subtitle_not_found` | No track for that language tag |\n| 404 | `event_not_found` | No such event for this account |\n| 404 | `key_not_found` | No such live key for this account |\n| 404 | `not_configured` | No webhook endpoint configured |\n| 404 | `account_not_found` | Key authenticated but its account row is gone |\n| 409 | `incomplete_upload` | Parts missing; `missing` lists them. Upload and re-complete |\n| 409 | `size_mismatch` | Declared size does not match bytes landed. Abort and restart |\n| 409 | `not_uploading` | Upload already finished or aborted |\n| 409 | `not_ready` | Asset is not `ready`, so its duration is unknown. On `POST \u2026/captions`: no audio file yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | `POST \u2026/captions` on a source with no audio stream |\n| 409 | `captions_in_progress` | A captions run is at the provider, started under two minutes ago, or a concurrent request won. Wait for `captions.ready` / `captions.errored` |\n| 409 | `no_transcript` | `POST \u2026/captions/translate` before a transcript exists; request captions first |\n| 409 | `asset_unowned` | The asset has no owning account, so captions cannot be metered; claim it for an account first |\n| 409 | `no_webhook_endpoint` | Nothing configured or enabled to redeliver to |\n| 413 | `file_too_large` | Too many parts at this part size; raise `partSize` |\n| 413 | `subtitle_too_large` | Over 2,000,000 bytes |\n| 429 | `rate_limited` | `POST /v1/device/code` only. It is unauthenticated, so it is capped per IP. A real login makes one call \u2014 if you see this, something is looping |\n| 500 | `signing_not_configured` | Operator-side: the playback signing key is not loaded. Not your fault, not retryable. Report it |\n| 500 | `internal` | Unexpected. `message` carries truncated detail |\n| 502 | `asr_submit_failed` | The transcription provider refused a captions submission (or could not be reached); `hint` carries its error. Nothing was metered for it. Retry later. (A submission the provider accepted is metered; if another request replaced the run meanwhile, that is `409 captions_in_progress`.) |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n| 503 | `captions_unavailable` | No transcription provider is configured on this deployment. Operator-side; not retryable by you |\n| 503 | `translate_unavailable` | No translation queue or provider is configured on this deployment. Operator-side |\n\n### Retry guidance\n\n**Retry with backoff:** `503 control_plane_unavailable`, `500 internal`,\n`400 authorization_pending`, `400 slow_down`.\n\n**Fix and retry:** the `409` upload conflicts \u2014 they tell you exactly what is\nwrong (`missing` part numbers, or both byte counts).\n\n**Do not retry unchanged:** every `400`, `401`, `403`, and `404`. A quota `403`\nin particular will fail identically forever until the account limit is raised.\n\n### The 401 conflation is deliberate\n\n`unauthorized` covers both "bad credential" and "not your asset". Distinguishing\nthem would let anyone holding a valid key enumerate other accounts\' asset ids by\nwatching which error came back.\n\n## CLI reference\n\n```bash\nnpx @hostr/cli <command>\n```\n\nA single-file, zero-dependency bundle published as `hostr`. **Every command takes\n`--json`** \u2014 one JSON document on stdout, NDJSON for `tail --follow`, errors as\nJSON on stderr with exit code 1. Nothing needs prose parsing.\n\nRun it with **no arguments** for setup status and the single next step. That is\nthe intended entry point, for humans and agents alike.\n\n### Credentials\n\nPrecedence, highest first: `--key <k>`, then `HOSTR_API_KEY`, then the saved\nprofile.\n\nAPI base, highest first: `--api <url>`, then `HOSTR_API_BASE`, then the profile,\nthen production.\n\nProfiles live in `~/.config/hostr/config.json`, cleartext at mode 0600 \u2014 the\n`gh` and `wrangler` precedent.\n\n### Authentication\n\n```bash\nhostr login # browser-authorized; nothing is pasted\nhostr login --profile staging # name the profile\nhostr login --key hostr_sk_live_... # escape hatch for CI; skips the browser\nhostr whoami # account, plan, limits, live usage\n```\n\n`login` with no key opens your browser and uses the device-authorization flow.\nThe code and URL are printed to **stderr**, so `--json` stdout stays a single\nparseable document.\n\n### Uploading\n\n```bash\nhostr upload video.mp4 # returns when bytes are verified in storage\nhostr upload video.mp4 --wait # blocks until transcode is ready or failed\nhostr upload video.mp4 --content-type video/quicktime\nhostr upload # no file: opens a local drag-drop page\nhostr upload video.mp4 --captions --translate en,es --context "Creator: Ana" # captions on upload\n```\n\nWraps the SDK\'s Node adapter, so it carries the same resume and integrity\nguarantees. Kill it mid-transfer and re-run to continue.\n\n### Assets\n\n```bash\nhostr assets list --status ready --limit 20 --cursor <cursor>\nhostr assets get <id>\nhostr assets token <id> --ttl 3600 --viewer-id vp_5b0e2a7c # playback scope\nhostr assets audio <id> # audio-only rendition for an older asset (upload scope)\nhostr assets audio --missing # ...for every asset in the account that lacks one\nhostr assets reencode <id> # fresh ladder from the stored source (upload scope)\nhostr assets delete <id> # full scope\n```\n\n### Subtitles\n\n```bash\nhostr subtitles list <assetId>\nhostr subtitles set <assetId> subs.vtt --lang en --name English --default\nhostr subtitles rm <assetId> en\n```\n\n`--lang` is required and must be a BCP 47 tag. The asset must be `ready`.\n\n### Captions\n\n```bash\nhostr captions info # on/off, this month\'s minutes, prices\nhostr captions enable # turn on for the account \u2014 billed per use (full scope)\nhostr captions enable --limit 600 # on, at most 600 transcription minutes a month\nhostr captions disable # turn off; forgets the limit\nhostr captions status <assetId> # the latest run: transcript + each language\nhostr captions request <assetId> # redo with the stored request (upload scope)\nhostr captions request <assetId> --lang pt --translate en,es --context "Creator: Ana"\nhostr captions translate <assetId> --lang fr,de # from the stored transcript; billed per language-minute\n```\n\n`--captions`, `--captions-lang`, `--translate` and `--context` on `upload` ask\nfor captions on the new asset; any one of them is enough. Captions must be\non for the account (`hostr captions enable`); they start off because they are\nbilled. A refusal says how many of the month\'s minutes are used and when they\nreset. No other command turns captions on: not `init`, not `upload`. Watch `hostr tail --follow --type captions.ready`.\nSee [Auto-captions and translation](#auto-captions-and-translation).\n\n### Events\n\n```bash\nhostr tail # one page of the durable log\nhostr tail --follow # poll every 2s, NDJSON under --json\nhostr tail --type video.ready --limit 50 --after <cursor>\n```\n\n### Keys\n\n```bash\nhostr keys list\nhostr keys create --name ci --scopes upload --write-env .env\nhostr keys revoke <id>\n```\n\n`--scopes` takes a comma-separated list and **defaults to `playback`** \u2014 the\nright rung for an application key. `revoke` takes the `id` from `keys list`, not\nthe `keyId`.\n\n### Webhooks\n\n```bash\nhostr webhooks get\nhostr webhooks set https://app.example/hook --write-env .env\nhostr webhooks set https://app.example/hook --secret whsec_... # bring your own\nhostr webhooks set https://app.example/hook --rotate --write-env .env # replace the secret\nhostr webhooks delete\nhostr webhooks test # redeliver the newest event \u2014 proves your endpoint\n```\n\nRe-running `set` on a live endpoint keeps its secret (nothing is written to a\n`--write-env` file, and the output says so); pass `--rotate` to replace it.\n\n### Repo setup\n\n```bash\nhostr init # docs/hostr.md + AGENTS.md section + .env.example\nhostr init --dir ../other-repo\n```\n\nIdempotent. Re-running replaces the content between the `AGENTS.md` markers\nrather than appending.\n\n### Usage\n\n```bash\nhostr usage --from 2026-08-01 --to 2026-08-25\n```\n\nDefaults to the last 30 days.\n\n### --write-env\n\n```bash\nhostr keys create --scopes upload --write-env .env\nhostr webhooks set https://app.example/hook --write-env .env\n```\n\nWrites the secret into that file at mode 0600 and prints **only the variable\nname** \u2014 in `--json` mode too. Nothing else in the CLI ever prints a credential.\n\nThe point is that an agent driving your terminal can orchestrate a credential\nwithout the value ever entering its context or its transcript.\n\n## SDK reference\n\n```bash\nnpm install @hostr/upload\n```\n\nFour entry points, each bundled independently:\n\n| Entry | Platform | What the adapter supplies |\n|---|---|---|\n| `@hostr/upload` | **Trusted backend only** | `createUploadSession`, `mintUploadToken` \u2014 needs your API key |\n| `@hostr/upload/browser` | Web | XHR transport (for progress events), `localStorage` session store |\n| `@hostr/upload/node` | Node, scripts | `fetch` transport, filesystem session store |\n| `@hostr/upload/react-native` | Expo / RN | Native staged-parts transport; survives backgrounding |\n\nThe engine, retry policy, and integrity checks are one shared core. Adapters only\nsupply platform I/O: read bytes, PUT bytes, persist a few KB of JSON.\n\n### Browser\n\n```js\nimport { createUpload } from \'@hostr/upload/browser\';\n\n// createUploadSession runs on YOUR SERVER \u2014 it holds the API key.\n// The browser receives only { endpoint, assetId, uploadToken }.\nconst upload = await createUpload({ endpoint, uploadToken, assetId, file });\n\nupload.on(\'progress\', p => {\n const pct = (p.bytesConfirmed / p.totalBytes * 100).toFixed(1);\n console.log(`${p.partsDone}/${p.partsTotal} parts, ${pct}%`);\n});\nupload.on(\'statechange\', s => console.log(s));\n// idle -> running -> (stalled <-> running)* -> completing -> complete\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\nSession state persists in `localStorage`, keyed by `name:size:lastModified`.\nReload the page, pick the same file, and `createUpload` resumes automatically \u2014\nthere is no explicit resume call.\n\n### Node\n\n```js\nimport { createUploadSession, createUpload } from \'@hostr/upload/node\';\n\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n});\n\nconst upload = await createUpload({\n endpoint, uploadToken: session.uploadToken, assetId: session.assetId,\n file: \'/path/to/video.mp4\', // a path, not an open handle\n stateDir: \'./upload-state\', // survives a process restart\n});\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\n#### Captions on upload\n\n`createUploadSession` takes the same `captions` object as `POST /v1/uploads`\n(SDK 0.3.0 or later). hostr transcribes the audio as soon as it exists and\ntranslates it into each `translate` language; every track lands as an ordinary\nsubtitle track. The account must have captions enabled.\n\n```js\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n captions: { language: \'auto\', translate: [\'en\', \'es\'], context: \'Creator: Ana. Title: Rainy night.\' },\n});\n```\n\nSee [Auto-captions and translation](#auto-captions-and-translation) for the events, the\nmetering and the rules.\n\n### Take the endpoint from your backend\'s response\n\n**One rule for clients of a proxying backend:** take the hostr `endpoint` from\nyour backend\'s create-upload response, never from separate client config.\n`createUploadSession` returns it as `endpoint` alongside the session, so\nforwarding `{ endpoint, assetId, uploadToken }` is all the browser needs.\n\nAn upload token is only valid on the hostr instance that minted it. Endpoint\ndrift fails as a bare mid-flow `401` with nothing pointing at the cause;\nreturning the endpoint alongside the session makes the mistake structurally\nimpossible. Learned from the first consumer integration.\n\n### Events\n\n| Event | Payload |\n|---|---|\n| `progress` | `{ partsDone, partsTotal, bytesConfirmed, totalBytes, partStates }` |\n| `statechange` | `\'idle\' \\| \'running\' \\| \'stalled\' \\| \'completing\' \\| \'complete\'` |\n| `diagnostic` | `{ code, consecutiveNetworkFailures, bytesEverConfirmed, hint }` |\n\n`partStates` maps part number to `\'inflight\'` or `\'done\'` \u2014 enough for a per-part\nprogress grid.\n\nThe `diagnostic` event fires once per failure streak, on every adapter. Today its\nonly code is `systemic-transport-failure`: the browser cannot reach storage at all\n(offline, or your CSP\'s `connect-src` omits the storage host) \u2014 see [Upload from a browser](#upload-from-a-browser). It is **advisory, never\nterminal**: the upload keeps retrying regardless.\n\n### Token refresh\n\n```js\nconst upload = await createUpload({\n endpoint, assetId, uploadToken, file,\n onTokenExpired: async () => (await refreshFromYourServer(assetId)).uploadToken,\n});\n```\n\nYour server calls `POST /v1/uploads/:id/token`. The SDK retries in place; nothing\nis lost. Even without the hook a killed client resumes from what storage reports\non next launch.\n\n### Integrity\n\nThree layers, each proving something different \u2014 per-part ETag, part count and\ntotal size at completion, and an optional whole-file SHA-256. See\n[Resume and integrity](#resume-and-integrity) for what each one does and\ndoes not prove.\n\n### Import error classes from the entry you actually use\n\nEach entry is bundled independently, so `IntegrityError` from\n`@hostr/upload/node` is not the same class object as the one from\n`@hostr/upload/browser`. An `instanceof` check across entries fails. Import from\nthe entry your code uses.\n\n## The encoding ladder\n\nhostr encodes each source into several renditions at different bitrates \u2014\nthe *ladder* \u2014 and packages them as fMP4 (CMAF) HLS with H.264 video and AAC\naudio. A player picks a rung based on available bandwidth and changes rung\nmid-stream as conditions move.\n\n### Rungs are chosen by short edge\n\nA ladder defined by height assumes landscape. Defining rungs by the **short\nedge** means portrait video, square video, and unusual aspect ratios all get a\nsensible ladder instead of a broken one.\n\n**Nothing is ever upscaled.** Rungs above the source\'s short edge are dropped, so\na 480p source produces a 480p-and-below ladder rather than a blurry 1080p\nrendition that costs bandwidth and delivers nothing.\n\n### Why switching is clean\n\nTwo invariants do the work, and both are load-bearing:\n\n**Every rung shares one constant frame rate and identical keyframe intervals**,\nwith scene-cut detection disabled. This is the only reason segment boundaries\nline up across rungs. Without it a player switching quality lands mid-segment and\nstutters or re-buffers.\n\n**Variable frame rate is normalised to constant before the ladder splits.**\nSources are frequently VFR \u2014 phone recordings especially \u2014 and segment durations\ndrift apart otherwise.\n\nThere is a subtlety inside that second one worth stating, because it shipped as a\nbug once. The frame rate comes from **the container\'s declared rate, not the\nmeasured average**. On a VFR source the average is depressed *because* frames were\ndropped; using it as a ceiling compounds the loss. A 30fps source got encoded at\n25fps that way.\n\n### Audio is demuxed\n\nAudio lives in its own rendition group rather than being muxed into every video\nrung. One copy instead of four, and it is the prerequisite for multi-language\naudio later.\n\nBy default that group is AAC-LC at 128 kbps, stereo (a mono source stays mono;\n5.1 is downmixed \u2014 never the other way), 48 kHz, resampled with soxr. Nothing\nelse is done to it: no gain change, no noise reduction, no gating, no silence\ntrimming. Room tone is content, and many creators master deliberately.\n\n### Audio options\n\n`POST /v1/uploads` takes an `audio` field that changes that for one asset:\n\n```json\n{ "size": 104857600, "filename": "tapping.mov", "audio": "asmr" }\n```\n\n| Option | Default | What it does |\n|---|---|---|\n| `kbps` | `128` | Bitrate of the top audio group. At 192 or more a second **96 kbps group** is added and the smallest rung (240p) references it, so a viewer on mobile data is not made to fetch a 256 kbps track behind 400 kbps of video. |\n| `normalize` | `false` | Fixed programme gain with peak limiting \u2014 see below. |\n| `targetLufs` | `-24` | Integrated loudness target. Mux normalises to the same level at ingest. |\n| `truePeakDbtp` | `-1.5` | True-peak ceiling, verified on encoded audio. |\n\n`"asmr"` is shorthand for `{ "kbps": 256, "normalize": true }` with the default\ntarget and ceiling. The resolved options land on the record as `audioOptions`.\nAn asset that already exists takes the same field on\n[`POST /v1/assets/:id/reencode`](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n#### Normalisation raises the programme and limits peaks\n\nNormalisation is **off by default**. No upload, re-encode or account default\nchanges when you preview it. Explicitly set `audio.normalize: true` or choose\n`audio: "asmr"` to apply it.\n\nThe source\'s EBU R128 measurement sets a fixed programme gain. An oversampled\nlookahead limiter catches peaks that would cross the ceiling, with automatic\nmakeup gain disabled and latency compensated. There is no programme compressor,\ndenoise, gate or automatic levelling. Quiet passages and room tone remain.\n\nThe transcoder measures encoded AAC and corrects the fixed gain for loudness\nlost to limiting, aiming within 0.5 LU of the target. It lowers the internal\nlimiter ceiling if AAC overshoots. If five passes cannot reach the target\nunder the ceiling, a new upload ships with its original, untouched audio\nrather than failing: `normalization` is `null` and `loudness.normalizationSkipped`\nsays why. A re-encode that cannot converge fails instead, and the existing\nladder remains live. Encoded segments that miss the target still fail the job. All normalised HLS groups and `audio.m4a`\nuse the same final chain. The low bitrate group allows 1 LU of codec variation.\n\nThe source measurement stays in `loudness`; `normalization.output` holds the\nencoded result. `normalization.limiting.maxReductionDb` measures maximum\nattenuation before AAC, and `limitedSamplesPct` is the percentage of sample\nframes attenuated by more than 0.1 dB, measured at 192 kHz. `limitingDb` is\nseparately the estimated peak reduction from source peak, gain and ceiling.\nThe limiter can change loudness range; the record shows both source and output.\n\nOlder records without `normalization.mode: "peak-limiter"` describe the former\ngain clamp: `peakLimited: true` there means it landed below target. Existing\nmedia is unchanged until explicitly re-encoded.\n\n#### Listen before applying\n\nOn the dashboard asset page, choose a start second and three targets (initial\nsuggestions: **-30, -24, -18 LUFS**, each editable from -40 to -10). Make the\ncomparison to hear Original plus three normalised 30-second clips, shortened\nat the end of the video. The players show measured LUFS, true peak, loudness\nrange and limiting. Only **Re-encode at \u2026 LUFS** changes the asset.\n\nEach tier is measured over the selected sample. A full re-encode measures the\nwhole source and may need a different gain at the same target. Preview input\nis the published gain-free Original AAC, not the lossless upload. Preview jobs\nread only the needed audio fragments and run on the concurrent poster/preview\nlane, never behind a ladder encode. Worker processing is bounded to 45 seconds;\nqueue or cold-start delays are additional. Missing fast-lane configuration\nreturns 503 rather than falling back to the encode queue.\n\n```bash\nhostr assets audio-preview ASSET --time 120 --targets=-30,-24,-18\nhostr assets audio-preview ASSET --json\n# After listening and choosing a target:\nhostr assets reencode ASSET --target-lufs -24\n```\n\nThe API takes `POST /v1/assets/:id/audio-preview` with\n`{ "time": 120, "targets": [-30, -24, -18] }`. Optional `truePeakDbtp` and\n`kbps` keep their existing defaults (-1.5 and 128). Poll `GET` on the same\nroute for state and measurements, then fetch\n`GET /v1/assets/:id/audio-preview/:previewRev/:index` with a tenant key\n(index 0 is Original, 1\u20133 follow the targets). Audio remains private. A\ncomparison never changes `audioOptions`, playback, posters or the live revision.\nOnly the latest comparison is addressable. Clips remain under the asset prefix\nuntil asset deletion. A queued request older than two minutes can be retried.\n\n#### The original stays selectable\n\nWhen a gain is applied, the top audio group carries a second rendition,\n**Original** \u2014 the same encode with no gain \u2014 beside the **Normalised**\ndefault. A player\'s audio-track menu offers both, so a creator can A/B their\nown upload and nothing is ever available only normalised. It costs one more\naudio track\'s worth of storage and appears only when normalisation actually\nchanged something. The 96 kbps group does not carry it. (The original upload is\nkept in storage regardless; this is about what a viewer can pick.)\n\n### Loudness is measured for every asset\n\nWhether or not normalisation is on, the probe measures the first audio stream\n(EBU R128, true-peak mode) and the record carries the result as `loudness`,\nwhich also rides `video.audio_ready`, `video.playable` and `video.ready`:\n\n```json\n{\n "integratedLufs": -32.1,\n "loudnessRangeLu": 15.2,\n "truePeakDbtp": -0.3,\n "thresholdLufs": -42.5,\n "mono": false,\n "normalization": null\n}\n```\n\nTwo uses. **Creator warnings before publish** \u2014 `mono` for a one-channel source\n(the ladder keeps it mono; it never fakes stereo), `truePeakDbtp` near 0 for\nclipping, a very low `integratedLufs` for a recording that is quiet even by\nASMR standards. For output loudness use `normalization.output.integratedLufs`\nwhen present. `integratedLufs` itself always describes the source; adding\n`gainDb` is no longer an exact output prediction when peaks are limited.\n\nThe block is written once on first encode and replaced atomically when a\nre-encode becomes ready. It is `null` for a source with no audio stream.\n\n### What the probe found\n\nThe transcoder probes each source and records what it saw on the asset as\n`input` \u2014 codec, pixel format, bit depth, dimensions, declared and average frame\nrates, and colour metadata.\n\nWhen the source required a judgement call, that block also carries `findings`,\nand a [`video.input.non_standard`](#event-reference) event fires once. It is\nadvisory: the transcode proceeds normally and `video.ready` still follows. See\nthe [events reference](#event-reference) for the finding codes.\n\n### HDR sources\n\nThe ladder is 8-bit BT.709 SDR. A genuine HDR source \u2014 10-bit with an HLG or\nPQ transfer, which is what an iPhone records in HDR mode \u2014 is converted to SDR\nonce, before the ladder splits, so every rung, the poster and the storyboard\ncome from the same converted frames. HLG is read the way the format intends\nfor SDR screens (as an ordinary gamma curve, with the colour converted to\nBT.709 and a small contrast and saturation lift standing in for the display\nheadroom SDR lacks); PQ is tone-mapped perceptually. A BT.2020 source without\nan HDR curve has only its colour converted. The finding on the record says\nwhich was done.\n\nThe 10-bit part is the rule. HDR is a 10-bit format, so HDR tags on 8-bit\npixels are a label copied forward by an earlier transcode, and converting as\nif they were true blows the highlights; those sources are treated as SDR and\nleft alone.\n\nHDR is not delivered to viewers. Tone mapping shipped on 2026-09-10; an HDR\nasset encoded before that plays back washed out until it is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n### Container and codec choices\n\nfMP4 rather than MPEG-TS: universal playback today, and the container that keeps\nHEVC addable later without re-packaging everything.\n\nSegments are served `immutable, max-age=1yr`, and content keys are versioned\n(`.../hls/v2/...`) rather than overwritten. Overwriting a key that is cached for\na year is how you end up needing a cache purge in production, which is a smell\nrather than a tool.\n\n## Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| GET | `/health` | \u2014 | Liveness check |\n| GET | `/llms.txt` | \u2014 | Agent documentation index |\n| GET | `/llms-full.txt` | \u2014 | Agent integration guide |\n| POST | `/v1/device/code` | \u2014 | Begin browser authorization |\n| POST | `/v1/device/token` | \u2014 | Exchange an approved device code for a key |\n| POST | `/v1/uploads` | upload | Create an asset and begin a multipart upload |\n| GET | `/v1/uploads/:id` | upload | Upload status and parts already stored |\n| DELETE | `/v1/uploads/:id` | upload | Abort an upload |\n| POST | `/v1/uploads/:id/parts` | upload | Get presigned PUT URLs for parts |\n| POST | `/v1/uploads/:id/complete` | upload | Finalise the upload and queue transcoding |\n| POST | `/v1/uploads/:id/token` | upload | Re-mint an upload token |\n| GET | `/v1/assets` | read | List your assets |\n| GET | `/v1/assets/:id` | read | Get one asset |\n| DELETE | `/v1/assets/:id` | full | Erase an asset |\n| POST | `/v1/assets/:id/tokens` | playback | Mint playback credentials |\n| POST | `/v1/assets/:id/audio` | upload | Generate the audio-only rendition |\n| POST | `/v1/assets/:id/download` | playback | Link to the original upload |\n| GET | `/v1/assets/:id/audio-preview` | read | Read the current audio comparison |\n| POST | `/v1/assets/:id/audio-preview` | upload | Compare Original audio and three loudness targets |\n| GET | `/v1/assets/:id/audio-preview/:previewRev/:index` | read | Read one private AAC comparison clip |\n| POST | `/v1/assets/:id/reencode` | upload | Re-encode an asset from its stored source |\n| POST | `/v1/renders` | upload | Create a video from an edit |\n| POST | `/v1/assets/:id/render` | upload | Re-edit an asset |\n| POST | `/v1/assets/:id/poster` | upload | Re-take the poster from a chosen second |\n| POST | `/v1/assets/:id/poster/warm` | upload | Start a worker ahead of a poster request |\n| POST | `/v1/assets/:id/status` | \u2014 | Report transcode status |\n| GET | `/v1/assets/:id/subtitles` | read | List subtitle tracks |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | Attach or replace a subtitle track |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | Remove a subtitle track |\n| POST | `/v1/assets/:id/captions` | upload | Transcribe an asset (or redo it) |\n| POST | `/v1/assets/:id/captions/translate` | upload | Translate the transcript into more languages |\n| POST | `/v1/captions/callback/:id/:rev` | \u2014 | Transcription provider callback (not for clients) |\n| POST | `/v1/billing/webhook` | \u2014 | Stripe webhook (not for clients) |\n| GET | `/v1/account` | read | Whoami |\n| PUT | `/v1/account/captions` | full | Turn captions on or off |\n| GET | `/v1/usage` | read | Daily usage |\n| GET | `/v1/billing` | read | Billing status and this month so far |\n| GET | `/v1/events` | read | Tail the durable event log |\n| POST | `/v1/events/:id/redeliver` | full | Replay a stored event |\n| GET | `/v1/webhooks` | full | Get the webhook endpoint |\n| PUT | `/v1/webhooks` | full | Configure the webhook endpoint |\n| DELETE | `/v1/webhooks` | full | Disable the webhook endpoint |\n| GET | `/v1/keys` | full | List key metadata |\n| POST | `/v1/keys` | full | Mint an API key |\n| DELETE | `/v1/keys/:id` | full | Revoke a key |\n\nGenerated from the same OpenAPI description the API is checked against.\nFull reference with schemas and examples: https://docs.hostr.sh/reference/api/\n';
|
|
5794
5830
|
|
|
5795
5831
|
// src/init-content.ts
|
|
5796
5832
|
var BEGIN = "<!-- hostr:begin -->";
|
|
@@ -5971,10 +6007,13 @@ var subtitles = program2.command("subtitles").description("subtitle tracks (WebV
|
|
|
5971
6007
|
subtitles.command("list <assetId>").action(run((ctx, id) => subtitlesList(ctx, id)));
|
|
5972
6008
|
subtitles.command("set <assetId> <file>").description("attach or replace a WebVTT track (requires upload scope)").requiredOption("--lang <tag>", "BCP 47 language tag, e.g. en or pt-BR").option("--name <label>", "menu label; defaults to the lang tag").option("--default", "make this the default track").action(run((ctx, id, file, opts) => subtitlesSet(ctx, id, file, opts)));
|
|
5973
6009
|
subtitles.command("rm <assetId> <lang>").action(run((ctx, id, lang) => subtitlesRm(ctx, id, lang)));
|
|
5974
|
-
var captionsCmd = program2.command("captions").description("machine transcription + translation, attached as subtitle tracks (
|
|
6010
|
+
var captionsCmd = program2.command("captions").description("machine transcription + translation, attached as subtitle tracks (off by default; billed per minute when on)");
|
|
6011
|
+
captionsCmd.command("info").description("on/off, this month's minutes, prices, and how to run it").action(run((ctx) => captionsShowInfo(ctx)));
|
|
6012
|
+
captionsCmd.command("enable").description("turn captions and translation on for the account \u2014 starts billing per use (requires full scope)").option("--limit <minutes>", "monthly ceiling in transcription minutes (default: no limit; translation is not counted)").action(run((ctx, opts) => captionsEnable(ctx, opts)));
|
|
6013
|
+
captionsCmd.command("disable").description("turn captions off for the account; forgets any limit (requires full scope)").action(run((ctx) => captionsDisable(ctx)));
|
|
5975
6014
|
captionsCmd.command("status <assetId>").description("the asset's latest captions run: transcript and per-language state").action(run((ctx, id) => captionsStatus(ctx, id)));
|
|
5976
6015
|
captionsCmd.command("request <assetId>").description("transcribe the asset, or redo it; no flags reuses the stored request (requires upload scope)").option("--lang <tag>", "spoken language as a BCP 47 tag, or auto (default: auto)").option("--translate <tags>", "comma-separated target languages, e.g. en,es (at most 10)").option("--context <text>", "context for the transcriber: creator, title, terms (trimmed; cut at 1000 characters)").action(run((ctx, id, opts) => captionsRequest(ctx, id, opts)));
|
|
5977
|
-
captionsCmd.command("translate <assetId>").description("more languages from the stored transcript;
|
|
6016
|
+
captionsCmd.command("translate <assetId>").description("more languages from the stored transcript; billed per language-minute (requires upload scope)").requiredOption("--lang <tags>", "comma-separated target languages, e.g. fr,de").action(run((ctx, id, opts) => captionsTranslate(ctx, id, opts)));
|
|
5978
6017
|
var assets = program2.command("assets").description("asset listing and status");
|
|
5979
6018
|
assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").action(run(assetsList));
|
|
5980
6019
|
assets.command("get <id>").action(run((ctx, id) => assetsGet(ctx, id)));
|
|
@@ -5988,7 +6027,7 @@ program2.command("tail").description("event log (durable; ascending). One page b
|
|
|
5988
6027
|
program2.command("init").description("make this repo agent-ready: docs/hostr.md + AGENTS.md section + .env.example entries").option("--dir <dir>", "target directory (default: cwd)").action(async (opts) => {
|
|
5989
6028
|
await init({ ...globals(), ...opts });
|
|
5990
6029
|
});
|
|
5991
|
-
program2.command("upload [file]").description("upload a video \u2014 with no file, opens a local drag-drop page in the browser").option("--content-type <type>").option("--audio <preset>", "audio preset for the encode: asmr (256k + peak-limited loudness normalisation)").option("--wait", "block until transcode reaches ready/failed (file mode)").option("--captions", "transcribe the video once its audio exists (needs captions
|
|
6030
|
+
program2.command("upload [file]").description("upload a video \u2014 with no file, opens a local drag-drop page in the browser").option("--content-type <type>").option("--audio <preset>", "audio preset for the encode: asmr (256k + peak-limited loudness normalisation)").option("--wait", "block until transcode reaches ready/failed (file mode)").option("--captions", "transcribe the video once its audio exists (needs captions on: hostr captions enable)").option("--captions-lang <tag>", "spoken language for captions, or auto (implies --captions)").option("--translate <tags>", "caption translations, comma-separated, e.g. en,es (implies --captions)").option("--context <text>", "context for the transcriber: creator, title, terms (implies --captions)").action(run((ctx, file, opts) => upload(ctx, file, opts)));
|
|
5992
6031
|
program2.parseAsync().catch((e) => {
|
|
5993
6032
|
console.error(`error: ${e?.message ?? e}`);
|
|
5994
6033
|
process.exit(1);
|