# Fastwater API

Base URL: `https://fastwater.app`

All routes are under `/v1` and take a bearer token: a Clerk API key, an OAuth
access token, or a dashboard session token. Errors have one shape:
`{ "error": { "code", "message", "requestId" } }`.

## Quick start

1. **Create an API key.** In the dashboard, open *Account*, then *Create an
   API key* under API keys. A full-access key can do anything you can; a
   limited key has only the scopes you pick.

   ```sh
   export CLOUD_API_KEY=ak_...
   export CLOUD=https://fastwater.app
   ```

2. **Check the cache.** Cached torrents complete at once.

   ```sh
   curl "$CLOUD/v1/cache?hash=08ada5a7a6183aae1e09d831df6748d566095a10" \
     -H "Authorization: Bearer $CLOUD_API_KEY"
   ```

3. **Add a magnet.**

   ```sh
   curl -X POST "$CLOUD/v1/transfers" \
     -H "Authorization: Bearer $CLOUD_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"magnet":"magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel"}'
   ```

4. **Poll the transfer** every few seconds until `status` is `completed`.
   The response lists the new `fileIds`.

   ```sh
   curl "$CLOUD/v1/transfers/$TRANSFER_ID" -H "Authorization: Bearer $CLOUD_API_KEY"
   ```

5. **Get a playback URL.** It supports HTTP Range and expires at
   `expiresAt`. When it returns `403`, ask for a new one. The response also
   has signed WebVTT subtitle URLs and the saved position.

   ```sh
   curl -X POST "$CLOUD/v1/files/$FILE_ID/playback" -H "Authorization: Bearer $CLOUD_API_KEY"
   ```

6. **Save progress** about every 10 seconds and on pause.

   ```sh
   curl -X PUT "$CLOUD/v1/files/$FILE_ID/progress" \
     -H "Authorization: Bearer $CLOUD_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"positionSeconds":120,"durationSeconds":888}'
   ```

## TypeScript

`@putflix/cloud-sdk` wraps every route:

```ts
const cloud = createCloudClient({ baseUrl: CLOUD, getToken: () => CLOUD_API_KEY });
const transfer = await cloud.addMagnet(magnet);
const playback = await cloud.getPlayback(transfer.fileIds[0]);
```

## AI assistants (MCP)

Fastwater is a remote MCP server at `https://fastwater.app/mcp`
(Streamable HTTP). ChatGPT and Claude, on the web and on phones, can add
magnets, follow transfers, search your library, and give you links. Sign-in
is OAuth: the assistant opens Fastwater's sign-in, and the connection then
shows under *Connected apps* on the Account page, where you revoke it.

- **Claude**: *Settings*, *Connectors*, *Add custom connector*, then the URL.
  It also works in the Claude mobile apps once added.
- **ChatGPT**: *Settings*, *Apps*, *Advanced settings*, turn on *Developer
  mode*, then *Create app* with the URL and OAuth.
- **Claude Code**: `claude mcp add --transport http fastwater https://fastwater.app/mcp`.
- **Other clients** (Cursor, Codex, scripts): the same URL. A client without
  OAuth sends an API key as `Authorization: Bearer`.

Tools wrap the routes below and return the same JSON. Fastwater does not
search for torrents: the assistant uses magnets you give it.

## Command line

`fastwater` (npm package `@fastwaterapp/cli`) adds magnets, waits for transfers, lists and downloads
files, and prints links. Every command takes `--json`, so agents with a
shell use it too.

```sh
npx @fastwaterapp/cli login                     # approve the code in the browser
npx @fastwaterapp/cli add "$MAGNET" --wait
npx @fastwaterapp/cli files sintel
npx @fastwaterapp/cli download $FILE_ID -o ~/Downloads
npx @fastwaterapp/cli skill install             # the agent skill, for Claude Code
```

Sign-in pairs the computer like a TV: it is listed under *TV and devices*.
For CI, pipe an API key into `fastwater login --with-token` or set
`FASTWATER_TOKEN`. Exit codes: 0 success, 1 API error, 2 usage, 3 not
signed in.

## Docs for agents

`/llms.txt` lists these docs. `/docs.md` (also `/llms-full.txt`) is
this page as Markdown, and `/docs` returns it for `Accept: text/markdown`.

## OAuth apps

Third-party apps use Clerk's OAuth endpoints (authorization code with PKCE)
and these scopes: `files:read`, `files:write`, `progress:write`,
`transfers:read`, `transfers:write`. Users revoke apps on the Account page.

## WebDAV

On Standard and Pro, `https://fastwater.app/dav/` serves your files
read-only over WebDAV: one folder per transfer, its files inside. Create a key
on the Account page (*WebDAV*, *Create a WebDAV key*), one per app. Any
username works; the key is the password. For an app without a password field,
use the link with the key in it, `https://fastwater.app/dav/<key>/`, and
keep it secret. Sign a key out under *TV and devices*.

Files open through a redirect to a signed storage URL, so the client must
follow redirects. WebDAV does not save watch progress.

- **rclone**:

  ```sh
  rclone config create fastwater webdav url=https://fastwater.app/dav/ \
    vendor=other user=fastwater pass=$WEBDAV_KEY --obscure
  rclone mount fastwater: ~/Fastwater --read-only --vfs-cache-mode minimal
  ```

- **Infuse**: *Settings*, *Shares*, *Add Share*, *WebDAV*. Address
  `fastwater.app`, path `/dav/`, HTTPS on, port 443, then the username and
  key.
- **Kodi**: *Videos*, *Files*, *Add videos*, *Browse*, *Add network
  location*. Protocol *WebDAV server (HTTPS)*, server `fastwater.app`,
  remote path `dav/`, then the username and key.
- **VLC** cannot browse WebDAV, but it plays a file's link. Open the link
  with the key in a browser, copy a file's link, and open it in VLC (*Open
  Network Stream*).
- **Finder**: *Go*, *Connect to Server* (⌘K), `https://fastwater.app/dav/`,
  then sign in as a registered user.
- **Windows**: `net use F: https://fastwater.app/dav/ /user:fastwater $WEBDAV_KEY`.
  Windows copies files up to 50 MB by default. To raise the limit to 4 GB,
  set `FileSizeLimitInBytes` to `0xFFFFFFFF` under
  `HKLM\SYSTEM\CurrentControlSet\Services\WebClient\Parameters` and
  restart the WebClient service.

### Plex and Jellyfin

Mount Fastwater with rclone (above) on the machine that runs the server, as
the user the server runs as, then add the mount folder (`~/Fastwater`) to a
library. The first scan reads the start of every file, so a large library
takes a while. Turn off thumbnail and preview generation for the library to
keep scans short.

## Stremio

On Standard and Pro, the Fastwater add-on shows your movies and shows in
Stremio. On the *Apps* page, *Fastwater for Stremio*, *Set up* creates it and
offers *Install in Stremio*. Its link,
`https://fastwater.app/stremio/<key>/manifest.json`, holds a key: keep it
secret, and sign it out under *TV and devices*. Stremio keeps its own watch
progress. `https://fastwater.app/stremio/public/manifest.json` has no key: it is the
listing in Stremio's add-on catalog, whose *Configure* button opens the setup
on the *Apps* page. Steps for every app: https://fastwater.app/guides.

## Reference

### GET /v1/plans

Plans, prices, and limits.

No token needed. The landing page reads it (PRD §3).

No auth.

`200` Plans

```ts
{
  data: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  }[];
  trial: {
    days: number;
    plan: "lite" | "standard" | "pro";
    storageQuotaBytes: number;
  } | null;
}
```

### POST /v1/devices/pairings

Start device pairing.

No token. The device shows the code and a QR code for `verificationUrl`, then polls. The code expires after 10 minutes.

No auth.

Body (`application/json`):

```ts
{
  platform: "android_tv" | "fire_tv" | "apple_tv" | "lg_tv" | "samsung_tv" | "roku" | "kodi" | "jellyfin" | "webdav" | "stremio" | "cli" | "ios" | "android";
}
```

`201` Pairing

```ts
{
  code: string;
  pollToken: string;
  verificationUrl: string;
  expiresAt: string; // date-time
  intervalSeconds: number;
}
```

Errors: `400` Invalid request; `429` Rate limited.

### POST /v1/devices/pairings/poll

Poll a device pairing.

No token. Poll every `intervalSeconds`. After the user approves, the first poll returns the API key, once.

No auth.

Body (`application/json`):

```ts
{
  pollToken: string;
}
```

`200` Pending, or claimed with the key

```ts
{
  status: "pending";
} | {
  status: "claimed";
  apiKey: string;
  device: {
    id: string;
    name: string;
    platform: "android_tv" | "fire_tv" | "apple_tv" | "lg_tv" | "samsung_tv" | "roku" | "kodi" | "jellyfin" | "webdav" | "stremio" | "cli" | "ios" | "android";
    createdAt: string; // date-time
    lastSeenAt: string; // date-time
  };
}
```

Errors: `400` Invalid request; `404` Not found; `429` Rate limited.

### GET /v1/archives/:token

A zip from POST /v1/files/archive.

The link `POST /v1/files/archive` returns. It needs no token.

No auth.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `token` | path | `string` | Required. |

`200` The zip

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/shared/:token

The files a share link opens.

Needs no token. Send the browser's `viewerId` to learn whether it can still play (`canWatch`).

No auth.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `token` | path | `string` | Required. |
| `viewerId` | query | `string` |  |

`200` Shared files

```ts
{
  senderName: string | null;
  files: {
    id: string;
    name: string;
    sizeBytes: number;
    type: "video" | "audio" | "image" | "subtitle" | "other";
    playable: boolean;
    durationSeconds: number | null;
    media: {
      titleId: string;
      kind: "movie" | "show";
      title: string;
      year: number | null;
      season: number | null;
      episode: number | null;
      episodeName: string | null;
      stillPath: string | null;
      posterPath: string | null;
      backdropPath: string | null;
      logoPath: string | null;
    } | null;
  }[];
  maxViewers: number;
  viewers: number;
  canWatch: boolean;
  expiresAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/shared/:token/files/:fileId/playback

Play a shared file.

Needs no token. The first call from a `viewerId` takes a viewer place, and fails with `403` when every place is taken. The URL supports HTTP Range and never outlives the link.

No auth.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `token` | path | `string` | Required. |
| `fileId` | path | `string` | Required. |

Body (`application/json`):

```ts
{
  viewerId: string;
  deviceLabel?: string;
  hevc?: "none" | "main" | "main10";
}
```

`200` Playback

```ts
{
  url: string; // uri
  expiresAt: string; // date-time
  mimeType: string;
  playable: boolean;
  progress: {
    fileId: string;
    positionSeconds: number;
    durationSeconds: number;
    completed: boolean;
    clientId: string | null;
    updatedAt: string; // date-time
  } | null;
  subtitles: {
    id: string;
    language: string | null;
    label: string;
    forced: boolean;
    default: boolean;
    source: "embedded" | "external" | "opensubtitles" | "subdl" | "generated";
    url: string; // uri
  }[];
  conversion: {
    state: "queued" | "converting" | "failed";
    progress: number;
    etaSeconds: number | null;
  } | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/me

The signed-in user, plan, and storage usage.

Bearer token.

`200` Account

```ts
{
  user: {
    id: string;
    email: string | null;
    status: "active" | "disabled";
    createdAt: string; // date-time
  };
  subscription: {
    status: string;
    periodEnd: string; // date-time
    active: boolean;
    trialEnd: string | null; // date-time
    needsPaymentMethod: boolean;
  } | null;
  plan: {
    id: string;
    name: string;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  } | null;
  storage: {
    usedBytes: number;
    reservedBytes: number;
    quotaBytes: number;
  };
  settings: {
    fileRetentionDays: number | null;
    audioLanguage: "en" | "es" | "fr" | "de" | "it" | "pt" | "nl" | "sv" | "no" | "da" | "fi" | "is" | "pl" | "cs" | "sk" | "hu" | "ro" | "bg" | "hr" | "sr" | "sl" | "et" | "lv" | "lt" | "el" | "tr" | "ru" | "uk" | "ca" | "eu" | "gl" | "he" | "ar" | "fa" | "hi" | "bn" | "ta" | "te" | "th" | "vi" | "id" | "ms" | "tl" | "zh" | "ja" | "ko" | null;
    subtitleLanguage: "en" | "es" | "fr" | "de" | "it" | "pt" | "nl" | "sv" | "no" | "da" | "fi" | "is" | "pl" | "cs" | "sk" | "hu" | "ro" | "bg" | "hr" | "sr" | "sl" | "et" | "lv" | "lt" | "el" | "tr" | "ru" | "uk" | "ca" | "eu" | "gl" | "he" | "ar" | "fa" | "hi" | "bn" | "ta" | "te" | "th" | "vi" | "id" | "ms" | "tl" | "zh" | "ja" | "ko" | null;
  };
  tvPaired: boolean;
  household: {
    role: "owner" | "member";
    owner: {
      email: string | null;
    };
    people: number;
    own: {
      usedBytes: number;
      limitBytes: number | null;
    };
  } | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### PATCH /v1/me/settings

Change account settings.

`fileRetentionDays` deletes each file that many days after it was added; `null` keeps files until you delete them. Files you already have count from the day of the change. A plan's own retention (Lite) still applies; the earlier date wins. `audioLanguage` and `subtitleLanguage` are ISO 639-1 codes every app starts playback with: the audio track in that language when a file has one (`null` plays the file's default track), and subtitles in that language when a file has them (`null` keeps subtitles off). Leave a field out to keep its value. Changing `fileRetentionDays` needs the `files:write` scope; the languages need only `progress:write`, which TV keys have.

Bearer token with `progress:write`.

Body (`application/json`):

```ts
{
  fileRetentionDays?: number | null;
  audioLanguage?: "en" | "es" | "fr" | "de" | "it" | "pt" | "nl" | "sv" | "no" | "da" | "fi" | "is" | "pl" | "cs" | "sk" | "hu" | "ro" | "bg" | "hr" | "sr" | "sl" | "et" | "lv" | "lt" | "el" | "tr" | "ru" | "uk" | "ca" | "eu" | "gl" | "he" | "ar" | "fa" | "hi" | "bn" | "ta" | "te" | "th" | "vi" | "id" | "ms" | "tl" | "zh" | "ja" | "ko" | null;
  subtitleLanguage?: "en" | "es" | "fr" | "de" | "it" | "pt" | "nl" | "sv" | "no" | "da" | "fi" | "is" | "pl" | "cs" | "sk" | "hu" | "ro" | "bg" | "hr" | "sr" | "sl" | "et" | "lv" | "lt" | "el" | "tr" | "ru" | "uk" | "ca" | "eu" | "gl" | "he" | "ar" | "fa" | "hi" | "bn" | "ta" | "te" | "th" | "vi" | "id" | "ms" | "tl" | "zh" | "ja" | "ko" | null;
}
```

`200` Settings

```ts
{
  fileRetentionDays: number | null;
  audioLanguage: "en" | "es" | "fr" | "de" | "it" | "pt" | "nl" | "sv" | "no" | "da" | "fi" | "is" | "pl" | "cs" | "sk" | "hu" | "ro" | "bg" | "hr" | "sr" | "sl" | "et" | "lv" | "lt" | "el" | "tr" | "ru" | "uk" | "ca" | "eu" | "gl" | "he" | "ar" | "fa" | "hi" | "bn" | "ta" | "te" | "th" | "vi" | "id" | "ms" | "tl" | "zh" | "ja" | "ko" | null;
  subtitleLanguage: "en" | "es" | "fr" | "de" | "it" | "pt" | "nl" | "sv" | "no" | "da" | "fi" | "is" | "pl" | "cs" | "sk" | "hu" | "ro" | "bg" | "hr" | "sr" | "sl" | "et" | "lv" | "lt" | "el" | "tr" | "ru" | "uk" | "ca" | "eu" | "gl" | "he" | "ar" | "fa" | "hi" | "bn" | "ta" | "te" | "th" | "vi" | "id" | "ms" | "tl" | "zh" | "ja" | "ko" | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### GET /v1/transfers

List transfers, newest first.

Bearer token with `transfers:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `cursor` | query | `string` |  |
| `limit` | query | `number` | Default `50`. |

`200` Transfers

```ts
{
  data: {
    id: string;
    status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
    outcome: "hit" | "attached" | "miss";
    sourceType: "magnet" | "torrent";
    infoHash: string;
    name: string | null;
    totalBytes: number | null;
    progress: number;
    speedBps: number;
    etaSeconds: number | null;
    error: string | null;
    folder: string | null;
    fileIds: string[];
    createdAt: string; // date-time
    updatedAt: string; // date-time
  }[];
  nextCursor: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### POST /v1/transfers

Add a magnet link or a .torrent file.

Send JSON `{ magnet }`, or multipart form data with a `torrent` file. A cache hit returns a completed transfer at once. Only the prefetch account may send `kind: "prefetch"`.

Bearer token with `transfers:write`.

Body (`application/json`, optional):

```ts
{
  magnet: string;
  kind?: "user" | "prefetch";
  fallbacks?: string[];
  folder?: string;
}
```

Body (`multipart/form-data`, optional):

```ts
{
  torrent: File;
}
```

`201` Created

```ts
{
  id: string;
  status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
  outcome: "hit" | "attached" | "miss";
  sourceType: "magnet" | "torrent";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### GET /v1/transfers/:id

Get a transfer.

Bearer token with `transfers:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`200` Transfer

```ts
{
  id: string;
  status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
  outcome: "hit" | "attached" | "miss";
  sourceType: "magnet" | "torrent";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### DELETE /v1/transfers/:id

Cancel an active transfer.

Bearer token with `transfers:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`200` Cancelled

```ts
{
  id: string;
  status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
  outcome: "hit" | "attached" | "miss";
  sourceType: "magnet" | "torrent";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### POST /v1/transfers/:id/retry

Retry a failed or cancelled transfer.

Creates a new transfer from the same magnet or .torrent file.

Bearer token with `transfers:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`201` Created

```ts
{
  id: string;
  status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
  outcome: "hit" | "attached" | "miss";
  sourceType: "magnet" | "torrent";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.

### GET /v1/files

List files, newest first.

Filter by name (`q`), `type`, `transferId`, or `folder`; `filed=false` leaves out files filed under a folder. Each file includes its watch progress.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `cursor` | query | `string` |  |
| `limit` | query | `number` | Default `50`. |
| `q` | query | `string` |  |
| `type` | query | `"video" \| "audio" \| "image" \| "subtitle" \| "other"` |  |
| `transferId` | query | `string` |  |
| `folder` | query | `string` |  |
| `filed` | query | `"true" \| "false"` |  |

`200` Files

```ts
{
  data: {
    id: string;
    name: string;
    sizeBytes: number;
    mimeType: string;
    type: "video" | "audio" | "image" | "subtitle" | "other";
    playable: boolean;
    mediaInfo: {
      container: string;
      durationSeconds: number | null;
      bitRate: number | null;
      video: {
        codec: string;
        profile: string | null;
        width: number;
        height: number;
        pixFmt: string | null;
      } | null;
      audio: {
        codec: string;
        channels: number | null;
        language: string | null;
        title: string | null;
        default: boolean;
      }[];
      subtitles: {
        codec: string;
        language: string | null;
        title: string | null;
        forced: boolean;
        default: boolean;
      }[];
      playable: boolean;
    } | null;
    original?: {
      name: string;
      sizeBytes: number;
      mimeType: string;
      mediaInfo: {
        container: string;
        durationSeconds: number | null;
        bitRate: number | null;
        video: {
          codec: string;
          profile: string | null;
          width: number;
          height: number;
          pixFmt: string | null;
        } | null;
        audio: {
          codec: string;
          channels: number | null;
          language: string | null;
          title: string | null;
          default: boolean;
        }[];
        subtitles: {
          codec: string;
          language: string | null;
          title: string | null;
          forced: boolean;
          default: boolean;
        }[];
        playable: boolean;
      } | null;
    } | null;
    subtitleCount: number;
    transferId: string;
    folder?: string | null;
    progress: {
      fileId: string;
      positionSeconds: number;
      durationSeconds: number;
      completed: boolean;
      clientId: string | null;
      updatedAt: string; // date-time
    } | null;
    expiresAt: string | null; // date-time
    media?: {
      titleId: string;
      kind: "movie" | "show";
      title: string;
      year: number | null;
      season: number | null;
      episode: number | null;
      episodeName: string | null;
      stillPath: string | null;
      posterPath: string | null;
      backdropPath: string | null;
      logoPath: string | null;
    } | null;
    createdAt: string; // date-time
    updatedAt: string; // date-time
  }[];
  nextCursor: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/folders

List the folders transfers were filed under, newest first.

A transfer added with `folder` (such as a show's `Name (Year)`) is filed under it. List a folder's files with `GET /files?folder=`.

Bearer token with `files:read`.

`200` Folders

```ts
{
  data: {
    name: string;
    fileCount: number;
    sizeBytes: number;
    updatedAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/folders/delete

Delete every file in a folder.

The folder's name matches without case, as in `GET /folders`. `deleted` counts the files.

Bearer token with `files:write`.

Body (`application/json`):

```ts
{
  name: string;
}
```

`200` Deleted

```ts
{
  deleted: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/files/:id

Get a file with its media info and watch progress.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`200` File

```ts
{
  id: string;
  name: string;
  sizeBytes: number;
  mimeType: string;
  type: "video" | "audio" | "image" | "subtitle" | "other";
  playable: boolean;
  mediaInfo: {
    container: string;
    durationSeconds: number | null;
    bitRate: number | null;
    video: {
      codec: string;
      profile: string | null;
      width: number;
      height: number;
      pixFmt: string | null;
    } | null;
    audio: {
      codec: string;
      channels: number | null;
      language: string | null;
      title: string | null;
      default: boolean;
    }[];
    subtitles: {
      codec: string;
      language: string | null;
      title: string | null;
      forced: boolean;
      default: boolean;
    }[];
    playable: boolean;
  } | null;
  original?: {
    name: string;
    sizeBytes: number;
    mimeType: string;
    mediaInfo: {
      container: string;
      durationSeconds: number | null;
      bitRate: number | null;
      video: {
        codec: string;
        profile: string | null;
        width: number;
        height: number;
        pixFmt: string | null;
      } | null;
      audio: {
        codec: string;
        channels: number | null;
        language: string | null;
        title: string | null;
        default: boolean;
      }[];
      subtitles: {
        codec: string;
        language: string | null;
        title: string | null;
        forced: boolean;
        default: boolean;
      }[];
      playable: boolean;
    } | null;
  } | null;
  subtitleCount: number;
  transferId: string;
  folder?: string | null;
  progress: {
    fileId: string;
    positionSeconds: number;
    durationSeconds: number;
    completed: boolean;
    clientId: string | null;
    updatedAt: string; // date-time
  } | null;
  expiresAt: string | null; // date-time
  media?: {
    titleId: string;
    kind: "movie" | "show";
    title: string;
    year: number | null;
    season: number | null;
    episode: number | null;
    episodeName: string | null;
    stillPath: string | null;
    posterPath: string | null;
    backdropPath: string | null;
    logoPath: string | null;
  } | null;
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### PATCH /v1/files/:id

Rename a file.

Bearer token with `files:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

Body (`application/json`):

```ts
{
  name: string;
}
```

`200` Renamed

```ts
{
  id: string;
  name: string;
  sizeBytes: number;
  mimeType: string;
  type: "video" | "audio" | "image" | "subtitle" | "other";
  playable: boolean;
  mediaInfo: {
    container: string;
    durationSeconds: number | null;
    bitRate: number | null;
    video: {
      codec: string;
      profile: string | null;
      width: number;
      height: number;
      pixFmt: string | null;
    } | null;
    audio: {
      codec: string;
      channels: number | null;
      language: string | null;
      title: string | null;
      default: boolean;
    }[];
    subtitles: {
      codec: string;
      language: string | null;
      title: string | null;
      forced: boolean;
      default: boolean;
    }[];
    playable: boolean;
  } | null;
  original?: {
    name: string;
    sizeBytes: number;
    mimeType: string;
    mediaInfo: {
      container: string;
      durationSeconds: number | null;
      bitRate: number | null;
      video: {
        codec: string;
        profile: string | null;
        width: number;
        height: number;
        pixFmt: string | null;
      } | null;
      audio: {
        codec: string;
        channels: number | null;
        language: string | null;
        title: string | null;
        default: boolean;
      }[];
      subtitles: {
        codec: string;
        language: string | null;
        title: string | null;
        forced: boolean;
        default: boolean;
      }[];
      playable: boolean;
    } | null;
  } | null;
  subtitleCount: number;
  transferId: string;
  folder?: string | null;
  progress: {
    fileId: string;
    positionSeconds: number;
    durationSeconds: number;
    completed: boolean;
    clientId: string | null;
    updatedAt: string; // date-time
  } | null;
  expiresAt: string | null; // date-time
  media?: {
    titleId: string;
    kind: "movie" | "show";
    title: string;
    year: number | null;
    season: number | null;
    episode: number | null;
    episodeName: string | null;
    stillPath: string | null;
    posterPath: string | null;
    backdropPath: string | null;
    logoPath: string | null;
  } | null;
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/files/:id

Delete a file and its watch progress.

Bearer token with `files:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Deleted

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/files/delete

Delete many files at once.

Up to 500 file IDs. IDs that are not your files are skipped; `deleted` counts the rest.

Bearer token with `files:write`.

Body (`application/json`):

```ts
{
  fileIds: string[];
}
```

`200` Deleted

```ts
{
  deleted: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/files/archive

Download many files as one zip.

Up to 100 file IDs. Returns a link that works without a token for 10 minutes: open it to download the zip, which streams as it is made. Files are stored uncompressed, so the zip's size is known and downloads show progress.

Bearer token with `files:read`.

Body (`application/json`):

```ts
{
  fileIds: string[];
  original?: boolean;
}
```

`200` Zip link

```ts
{
  url: string; // uri
  expiresAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/files/:id/playback

Get a signed playback URL, subtitles, and the saved position.

The URL supports HTTP Range and expires at `expiresAt`. When it returns `403`, call this endpoint again for a new URL.

Send a stable `clientId` per device and the same one with progress updates. Each client that played in the last 60 seconds is a screen; a new client past the plan's screens gets `409` `screen_limit` with the active sessions (PRD §10.2).

`prepare: true` returns the same answer before the Play press, so a player can start buffering on the detail screen: it claims no screen, though it gets `409` `screen_limit` when other clients use every screen, the URL expires within 15 minutes, and it has its own rate limit. On Play, call again without `prepare` while playing the prepared URL, and stop if that call returns `409`.

Send `hevc` with what the screen decodes of HEVC. A screen that cannot decode the file's video plays an H.264 copy, which the worker makes the first time a screen asks. Until it is ready, the answer has `conversion` (queued or converting, with progress), claims no screen (with the same `409` as `prepare`), and its URL is the HEVC file: show the wait, and ask again with `prepare: true` every few seconds until `conversion` is null (PRD §9.3).

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

Body (`application/json`, optional):

```ts
{
  clientId?: string;
  deviceLabel?: string;
  prepare?: boolean;
  original?: boolean;
  hevc?: "none" | "main" | "main10";
}
```

`200` Playback

```ts
{
  url: string; // uri
  expiresAt: string; // date-time
  mimeType: string;
  playable: boolean;
  progress: {
    fileId: string;
    positionSeconds: number;
    durationSeconds: number;
    completed: boolean;
    clientId: string | null;
    updatedAt: string; // date-time
  } | null;
  subtitles: {
    id: string;
    language: string | null;
    label: string;
    forced: boolean;
    default: boolean;
    source: "embedded" | "external" | "opensubtitles" | "subdl" | "generated";
    url: string; // uri
  }[];
  conversion: {
    state: "queued" | "converting" | "failed";
    progress: number;
    etaSeconds: number | null;
  } | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `409` Screen limit (`screen_limit`); `429` Rate limited.

### GET /v1/files/:id/subtitles/:trackId

Get a subtitle track as WebVTT.

The track `/playback` lists, read through the API. Players that can fetch the signed `url` should; this is for apps installed on a TV, whose requests come from `file://` and which storage's CORS rules do not admit.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |
| `trackId` | path | `string` | Required. |

`200` WebVTT

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/files/:id/download

Download a file.

Redirects (`302`) to a signed URL with `Content-Disposition: attachment`. With `redirect=false`, returns the URL as JSON.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |
| `redirect` | query | `"true" \| "false"` | Default `"true"`. |
| `original` | query | `"true" \| "false"` | Default `"false"`. |

`200` Signed URL (redirect=false)

```ts
{
  url: string; // uri
  expiresAt: string; // date-time
}
```

`302` Redirect to the signed URL

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/files/:id/progress

Get the watch progress of a file.

Returns `null` when the user has not played the file.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`200` Progress

```ts
{
  fileId: string;
  positionSeconds: number;
  durationSeconds: number;
  completed: boolean;
  clientId: string | null;
  updatedAt: string; // date-time
} | null
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### PUT /v1/files/:id/progress

Save the watch progress of a file.

Send about every 10 seconds during playback, and on pause. A write with an `updatedAt` older than the stored one is ignored; the response is the stored progress. `409` `playback_stopped` means the user stopped this device's session: stop the player.

Bearer token with `progress:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

Body (`application/json`):

```ts
{
  positionSeconds: number;
  durationSeconds: number;
  clientId?: string;
  updatedAt?: string; // date-time
}
```

`200` Stored progress

```ts
{
  fileId: string;
  positionSeconds: number;
  durationSeconds: number;
  completed: boolean;
  clientId: string | null;
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Session stopped (`playback_stopped`); `429` Rate limited.

### GET /v1/progress

Get the watch progress of up to 100 files.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `fileIds` | query | `string` | Required. |

`200` Progress for the files the user has played

```ts
{
  data: {
    fileId: string;
    positionSeconds: number;
    durationSeconds: number;
    completed: boolean;
    clientId: string | null;
    updatedAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/progress/recent

Continue watching: recent progress, newest first.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `limit` | query | `number` | Default `20`. |
| `completed` | query | `"true" \| "false"` | Default `"false"`. |

`200` Recent progress with each file

```ts
{
  data: {
    fileId: string;
    positionSeconds: number;
    durationSeconds: number;
    completed: boolean;
    clientId: string | null;
    updatedAt: string; // date-time
    file: {
      id: string;
      name: string;
      sizeBytes: number;
      mimeType: string;
      type: "video" | "audio" | "image" | "subtitle" | "other";
      playable: boolean;
      mediaInfo: {
        container: string;
        durationSeconds: number | null;
        bitRate: number | null;
        video: {
          codec: string;
          profile: string | null;
          width: number;
          height: number;
          pixFmt: string | null;
        } | null;
        audio: {
          codec: string;
          channels: number | null;
          language: string | null;
          title: string | null;
          default: boolean;
        }[];
        subtitles: {
          codec: string;
          language: string | null;
          title: string | null;
          forced: boolean;
          default: boolean;
        }[];
        playable: boolean;
      } | null;
      original?: {
        name: string;
        sizeBytes: number;
        mimeType: string;
        mediaInfo: {
          container: string;
          durationSeconds: number | null;
          bitRate: number | null;
          video: {
            codec: string;
            profile: string | null;
            width: number;
            height: number;
            pixFmt: string | null;
          } | null;
          audio: {
            codec: string;
            channels: number | null;
            language: string | null;
            title: string | null;
            default: boolean;
          }[];
          subtitles: {
            codec: string;
            language: string | null;
            title: string | null;
            forced: boolean;
            default: boolean;
          }[];
          playable: boolean;
        } | null;
      } | null;
      subtitleCount: number;
      transferId: string;
      folder?: string | null;
      progress: {
        fileId: string;
        positionSeconds: number;
        durationSeconds: number;
        completed: boolean;
        clientId: string | null;
        updatedAt: string; // date-time
      } | null;
      expiresAt: string | null; // date-time
      media?: {
        titleId: string;
        kind: "movie" | "show";
        title: string;
        year: number | null;
        season: number | null;
        episode: number | null;
        episodeName: string | null;
        stillPath: string | null;
        posterPath: string | null;
        backdropPath: string | null;
        logoPath: string | null;
      } | null;
      createdAt: string; // date-time
      updatedAt: string; // date-time
    };
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/playback/support

What the user's screens decode.

The least HEVC support among the screens that played in the last 90 days and sent `hevc`, for choosing sources. Null before any did.

Bearer token with `files:read`.

`200` HEVC support

```ts
{
  hevc: "none" | "main" | "main10" | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/playback/reports

Report how a playback went.

Startup time, stalls, and throughput for one playback, for the performance report. Send the running totals about every 30 seconds and when the player closes or fails; the last report for a `playbackId` wins.

Bearer token with `progress:write`.

Body (`application/json`):

```ts
{
  playbackId: string;
  fileId: string;
  client: "tv" | "web" | "putflix" | "mobile";
  device?: string;
  urlMs: number | null;
  startupMs: number | null;
  firstByteMs?: number | null;
  metadataMs?: number | null;
  stalls: number;
  stallMs: number;
  seeks: number;
  seekWaitMs: number;
  playedMs: number;
  throughputBps: number | null;
  droppedFrames: number | null;
  error: string | null;
  timeZone?: string | null;
  prepared?: boolean;
}
```

`204` Saved

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/playback-sessions

Devices playing now.

Sessions with a playback request or progress update in the last 60 seconds.

Bearer token with `files:read`.

`200` Active sessions

```ts
{
  data: {
    id: string;
    clientId: string;
    deviceLabel: string;
    fileId: string;
    fileName: string;
    lastSeenAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/playback-sessions/:id

Stop playback on a device.

Frees the screen at once. The device's next progress update gets `409` `playback_stopped`. Its signed URL works until it expires.

Bearer token with `progress:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Stopped

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/shares

Share links that still work.

Bearer token with `files:read`.

`200` Share links, newest first

```ts
{
  data: {
    id: string;
    url: string; // uri
    files: {
      id: string;
      name: string;
    }[];
    maxViewers: number;
    viewers: number;
    expiresAt: string; // date-time
    revokedAt: string | null; // date-time
    createdAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/shares

Share files with a link.

A link that streams up to 100 files to people without a Fastwater account. Each browser that plays from it takes one of `maxViewers` places (up to 10); a full link plays only for the browsers that already have a place. Links last `expiresInDays` (up to 30). Viewers use none of your screens. Deleting a file takes it out of every link.

Bearer token with `files:write`.

Body (`application/json`):

```ts
{
  fileIds: string[];
  maxViewers?: number;
  expiresInDays?: number;
}
```

`201` Share link

```ts
{
  id: string;
  url: string; // uri
  files: {
    id: string;
    name: string;
  }[];
  maxViewers: number;
  viewers: number;
  expiresAt: string; // date-time
  revokedAt: string | null; // date-time
  createdAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `409` Too many open links; `429` Rate limited.

### DELETE /v1/shares/:id

Turn off a share link.

The link stops working at once. A viewer who is playing keeps the video until its URL expires.

Bearer token with `files:write`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Turned off

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/cache

Check which info hashes are cached (up to 50).

`cached` is true when every file of the torrent is stored, so a new transfer completes at once. Send hashes in hex or base32; the response keys are the hashes as sent.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `hash` | query | `string` | Required. |

`200` Availability by hash

```ts
Record<string, {
  cached: boolean;
  files?: {
    name: string;
    path: string;
    sizeBytes: number;
    mimeType: string;
    playable: boolean;
    videoCodec: string | null;
    audioCodecs: string[];
    height: number | null;
  }[];
}>
```

Errors: `400` Invalid request; `401` Missing or invalid token; `429` Rate limited.

### POST /v1/cache

Check which info hashes are cached (up to 500).

`cached` is true when every file of the torrent is stored, so a new transfer completes at once. Send hashes in hex or base32; the response keys are the hashes as sent.

Bearer token.

Body (`application/json`):

```ts
{
  hashes: string[];
}
```

`200` Availability by hash

```ts
Record<string, {
  cached: boolean;
  files?: {
    name: string;
    path: string;
    sizeBytes: number;
    mimeType: string;
    playable: boolean;
    videoCodec: string | null;
    audioCodecs: string[];
    height: number | null;
  }[];
}>
```

Errors: `400` Invalid request; `401` Missing or invalid token; `429` Rate limited.

### GET /v1/apps

List the OAuth apps the user authorized.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Apps

```ts
{
  data: {
    id: string;
    clientId: string;
    name: string;
    firstSeenAt: string; // date-time
    lastSeenAt: string; // date-time
  }[];
}
```

Errors: `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/apps/:id

Revoke an app's access.

The app's tokens stop working at once. Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Revoked

Errors: `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/api-keys

List the user's API keys.

Keys of paired devices are under `/devices`. Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Keys

```ts
{
  data: {
    id: string;
    name: string;
    scopes: ("files:read" | "files:write" | "progress:write" | "transfers:read" | "transfers:write")[];
    createdAt: string; // date-time
    lastUsedAt: string | null; // date-time
  }[];
}
```

Errors: `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/api-keys

Create an API key.

The secret is in this response only. A key without scopes has full access. Needs a dashboard session or an API key without scopes.

Bearer token.

Body (`application/json`):

```ts
{
  name: string;
  scopes?: ("files:read" | "files:write" | "progress:write" | "transfers:read" | "transfers:write")[];
}
```

`201` Created

```ts
{
  secret: string;
  key: {
    id: string;
    name: string;
    scopes: ("files:read" | "files:write" | "progress:write" | "transfers:read" | "transfers:write")[];
    createdAt: string; // date-time
    lastUsedAt: string | null; // date-time
  };
}
```

Errors: `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` A key with this name exists; `429` Rate limited.

### DELETE /v1/api-keys/:id

Revoke an API key.

The key stops working at once. Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Revoked

Errors: `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/devices/pairings/:code

Look up a pairing code before approving it.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `code` | path | `string` | Required. |

`200` Pairing

```ts
{
  code: string;
  platform: "android_tv" | "fire_tv" | "apple_tv" | "lg_tv" | "samsung_tv" | "roku" | "kodi" | "jellyfin" | "webdav" | "stremio" | "cli" | "ios" | "android";
  expiresAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/devices/pairings/:code/approve

Approve a pairing code.

Signs the device in to this account. Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `code` | path | `string` | Required. |

Body (`application/json`, optional):

```ts
{
  deviceName?: string;
}
```

`204` Approved

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/devices

List paired devices.

Bearer token.

`200` Devices

```ts
{
  data: {
    id: string;
    name: string;
    platform: "android_tv" | "fire_tv" | "apple_tv" | "lg_tv" | "samsung_tv" | "roku" | "kodi" | "jellyfin" | "webdav" | "stremio" | "cli" | "ios" | "android";
    createdAt: string; // date-time
    lastSeenAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/devices/webdav

Create a WebDAV key.

A key with `files:read` for one WebDAV client (PRD §10.1), listed and revoked with the devices. The secret is returned only here. Needs a plan with WebDAV.

Bearer token.

Body (`application/json`, optional):

```ts
{
  name?: string;
}
```

`201` Key

```ts
{
  apiKey: string;
  url: string;
  keyUrl: string;
  device: {
    id: string;
    name: string;
    platform: "android_tv" | "fire_tv" | "apple_tv" | "lg_tv" | "samsung_tv" | "roku" | "kodi" | "jellyfin" | "webdav" | "stremio" | "cli" | "ios" | "android";
    createdAt: string; // date-time
    lastSeenAt: string; // date-time
  };
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/devices/stremio

Create a Stremio add-on.

A key with `files:read` for the Stremio library add-on (PRD §10.5), listed and revoked with the devices. The manifest URL holds the key and is returned only here. Needs a plan with WebDAV.

Bearer token.

Body (`application/json`, optional):

```ts
{
  name?: string;
}
```

`201` Add-on

```ts
{
  manifestUrl: string;
  installUrl: string;
  device: {
    id: string;
    name: string;
    platform: "android_tv" | "fire_tv" | "apple_tv" | "lg_tv" | "samsung_tv" | "roku" | "kodi" | "jellyfin" | "webdav" | "stremio" | "cli" | "ios" | "android";
    createdAt: string; // date-time
    lastSeenAt: string; // date-time
  };
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/devices/current

Sign this device out.

For a paired device: revokes the key the request uses.

Bearer token.

`204` Signed out

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/devices/:id

Sign a device out.

Revokes the device's API key at once.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Revoked

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/library

Movies and shows in the user's files, newest addition first.

Titles matched to TMDB from the user's video files. Files with no match are only in `/files`. Images are TMDB paths, for `https://image.tmdb.org/t/p/<size><path>`.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `cursor` | query | `string` |  |
| `limit` | query | `number` | Default `50`. |
| `q` | query | `string` |  |
| `kind` | query | `"movie" \| "show"` |  |

`200` Titles

```ts
{
  data: {
    id: string;
    tmdbId: number;
    kind: "movie" | "show";
    title: string;
    year: number | null;
    overview: string | null;
    posterPath: string | null;
    backdropPath: string | null;
    logoPath: string | null;
    genres: string[];
    voteAverage: number | null;
    runtime: number | null;
    releaseDate: string | null;
    seasonCount: number | null;
    addedAt: string; // date-time
  }[];
  nextCursor: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/library/movies/:id

A movie with the user's copies.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`200` Movie

```ts
{
  id: string;
  tmdbId: number;
  kind: "movie" | "show";
  title: string;
  year: number | null;
  overview: string | null;
  posterPath: string | null;
  backdropPath: string | null;
  logoPath: string | null;
  genres: string[];
  voteAverage: number | null;
  runtime: number | null;
  releaseDate: string | null;
  seasonCount: number | null;
  addedAt: string; // date-time
  cast: {
    name: string;
    character: string | null;
    profilePath: string | null;
  }[];
  status: string | null;
  lastAirDate: string | null;
  files: {
    id: string;
    name: string;
    sizeBytes: number;
    mimeType: string;
    type: "video" | "audio" | "image" | "subtitle" | "other";
    playable: boolean;
    mediaInfo: {
      container: string;
      durationSeconds: number | null;
      bitRate: number | null;
      video: {
        codec: string;
        profile: string | null;
        width: number;
        height: number;
        pixFmt: string | null;
      } | null;
      audio: {
        codec: string;
        channels: number | null;
        language: string | null;
        title: string | null;
        default: boolean;
      }[];
      subtitles: {
        codec: string;
        language: string | null;
        title: string | null;
        forced: boolean;
        default: boolean;
      }[];
      playable: boolean;
    } | null;
    original?: {
      name: string;
      sizeBytes: number;
      mimeType: string;
      mediaInfo: {
        container: string;
        durationSeconds: number | null;
        bitRate: number | null;
        video: {
          codec: string;
          profile: string | null;
          width: number;
          height: number;
          pixFmt: string | null;
        } | null;
        audio: {
          codec: string;
          channels: number | null;
          language: string | null;
          title: string | null;
          default: boolean;
        }[];
        subtitles: {
          codec: string;
          language: string | null;
          title: string | null;
          forced: boolean;
          default: boolean;
        }[];
        playable: boolean;
      } | null;
    } | null;
    subtitleCount: number;
    transferId: string;
    folder?: string | null;
    progress: {
      fileId: string;
      positionSeconds: number;
      durationSeconds: number;
      completed: boolean;
      clientId: string | null;
      updatedAt: string; // date-time
    } | null;
    expiresAt: string | null; // date-time
    media?: {
      titleId: string;
      kind: "movie" | "show";
      title: string;
      year: number | null;
      season: number | null;
      episode: number | null;
      episodeName: string | null;
      stillPath: string | null;
      posterPath: string | null;
      backdropPath: string | null;
      logoPath: string | null;
    } | null;
    createdAt: string; // date-time
    updatedAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/library/shows/:id

A show with the user's episodes by season.

Bearer token with `files:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`200` Show

```ts
{
  id: string;
  tmdbId: number;
  kind: "movie" | "show";
  title: string;
  year: number | null;
  overview: string | null;
  posterPath: string | null;
  backdropPath: string | null;
  logoPath: string | null;
  genres: string[];
  voteAverage: number | null;
  runtime: number | null;
  releaseDate: string | null;
  seasonCount: number | null;
  addedAt: string; // date-time
  cast: {
    name: string;
    character: string | null;
    profilePath: string | null;
  }[];
  status: string | null;
  lastAirDate: string | null;
  seasons: {
    season: number;
    episodes: {
      season: number;
      episode: number;
      name: string | null;
      overview: string | null;
      stillPath: string | null;
      airDate: string | null;
      runtime: number | null;
      files: {
        id: string;
        name: string;
        sizeBytes: number;
        mimeType: string;
        type: "video" | "audio" | "image" | "subtitle" | "other";
        playable: boolean;
        mediaInfo: {
          container: string;
          durationSeconds: number | null;
          bitRate: number | null;
          video: {
            codec: string;
            profile: string | null;
            width: number;
            height: number;
            pixFmt: string | null;
          } | null;
          audio: {
            codec: string;
            channels: number | null;
            language: string | null;
            title: string | null;
            default: boolean;
          }[];
          subtitles: {
            codec: string;
            language: string | null;
            title: string | null;
            forced: boolean;
            default: boolean;
          }[];
          playable: boolean;
        } | null;
        original?: {
          name: string;
          sizeBytes: number;
          mimeType: string;
          mediaInfo: {
            container: string;
            durationSeconds: number | null;
            bitRate: number | null;
            video: {
              codec: string;
              profile: string | null;
              width: number;
              height: number;
              pixFmt: string | null;
            } | null;
            audio: {
              codec: string;
              channels: number | null;
              language: string | null;
              title: string | null;
              default: boolean;
            }[];
            subtitles: {
              codec: string;
              language: string | null;
              title: string | null;
              forced: boolean;
              default: boolean;
            }[];
            playable: boolean;
          } | null;
        } | null;
        subtitleCount: number;
        transferId: string;
        folder?: string | null;
        progress: {
          fileId: string;
          positionSeconds: number;
          durationSeconds: number;
          completed: boolean;
          clientId: string | null;
          updatedAt: string; // date-time
        } | null;
        expiresAt: string | null; // date-time
        media?: {
          titleId: string;
          kind: "movie" | "show";
          title: string;
          year: number | null;
          season: number | null;
          episode: number | null;
          episodeName: string | null;
          stillPath: string | null;
          posterPath: string | null;
          backdropPath: string | null;
          logoPath: string | null;
        } | null;
        createdAt: string; // date-time
        updatedAt: string; // date-time
      }[];
    }[];
  }[];
  nextUp: {
    season: number;
    episode: number;
    fileId: string;
    resume: boolean;
  } | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/billing

Subscription, plans, and trial eligibility.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Billing

```ts
{
  subscription: {
    status: string;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    } | null;
    nextPlanAt: string | null; // date-time
    card: {
      brand: string;
      last4: string;
    } | null;
    paymentMethod: {
      type: string;
      brand: string | null;
      last4: string | null;
      email: string | null;
    } | null;
    nextChargeCents: number | null;
    needsPaymentMethod: boolean;
  } | null;
  plans: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    storageQuotaBytes: number;
  } | null;
  referred: boolean;
  collectsTax: boolean;
  storedBytes: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/checkout

Start checkout.

Returns a Checkout Session client secret for Stripe Elements. With a trial, the trial is on Standard and `plan` applies after it.

Bearer token.

Body (`application/json`, optional):

```ts
{
  plan: "lite" | "standard" | "pro";
}
```

`200` Checkout

```ts
{
  clientSecret: string;
  plan: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  };
  trialPlan: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  } | null;
  trialEnd: string | null; // date-time
  firstChargeAt: string; // date-time
  firstChargeCents: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/sync

Read the subscription from Stripe now.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Billing

```ts
{
  subscription: {
    status: string;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    } | null;
    nextPlanAt: string | null; // date-time
    card: {
      brand: string;
      last4: string;
    } | null;
    paymentMethod: {
      type: string;
      brand: string | null;
      last4: string | null;
      email: string | null;
    } | null;
    nextChargeCents: number | null;
    needsPaymentMethod: boolean;
  } | null;
  plans: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    storageQuotaBytes: number;
  } | null;
  referred: boolean;
  collectsTax: boolean;
  storedBytes: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/plan

Change plan.

During a trial the plan applies at the trial end. An upgrade applies at once with proration; a downgrade at the next period, if the files fit.

Bearer token.

Body (`application/json`, optional):

```ts
{
  plan: "lite" | "standard" | "pro";
}
```

`200` Plan change

```ts
{
  billing: {
    subscription: {
      status: string;
      plan: {
        key: "lite" | "standard" | "pro";
        name: string;
        priceCents: number;
        storageQuotaBytes: number;
        maxActiveTransfers: number;
        maxScreens: number;
        fileRetentionDays: number | null;
        webdav: boolean;
        priorityDownloads: boolean;
        maxPeople: number;
      };
      trialEnd: string | null; // date-time
      periodEnd: string; // date-time
      cancelAtPeriodEnd: boolean;
      nextPlan: {
        key: "lite" | "standard" | "pro";
        name: string;
        priceCents: number;
        storageQuotaBytes: number;
        maxActiveTransfers: number;
        maxScreens: number;
        fileRetentionDays: number | null;
        webdav: boolean;
        priorityDownloads: boolean;
        maxPeople: number;
      } | null;
      nextPlanAt: string | null; // date-time
      card: {
        brand: string;
        last4: string;
      } | null;
      paymentMethod: {
        type: string;
        brand: string | null;
        last4: string | null;
        email: string | null;
      } | null;
      nextChargeCents: number | null;
      needsPaymentMethod: boolean;
    } | null;
    plans: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    }[];
    trial: {
      days: number;
      plan: {
        key: "lite" | "standard" | "pro";
        name: string;
        priceCents: number;
        storageQuotaBytes: number;
        maxActiveTransfers: number;
        maxScreens: number;
        fileRetentionDays: number | null;
        webdav: boolean;
        priorityDownloads: boolean;
        maxPeople: number;
      };
      storageQuotaBytes: number;
    } | null;
    referred: boolean;
    collectsTax: boolean;
    storedBytes: number;
  };
  paymentClientSecret: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/cancel

Cancel at the end of the period.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Billing

```ts
{
  subscription: {
    status: string;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    } | null;
    nextPlanAt: string | null; // date-time
    card: {
      brand: string;
      last4: string;
    } | null;
    paymentMethod: {
      type: string;
      brand: string | null;
      last4: string | null;
      email: string | null;
    } | null;
    nextChargeCents: number | null;
    needsPaymentMethod: boolean;
  } | null;
  plans: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    storageQuotaBytes: number;
  } | null;
  referred: boolean;
  collectsTax: boolean;
  storedBytes: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/resume

Undo a cancel.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Billing

```ts
{
  subscription: {
    status: string;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    } | null;
    nextPlanAt: string | null; // date-time
    card: {
      brand: string;
      last4: string;
    } | null;
    paymentMethod: {
      type: string;
      brand: string | null;
      last4: string | null;
      email: string | null;
    } | null;
    nextChargeCents: number | null;
    needsPaymentMethod: boolean;
  } | null;
  plans: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    storageQuotaBytes: number;
  } | null;
  referred: boolean;
  collectsTax: boolean;
  storedBytes: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/card

Start a card update.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Card setup

```ts
{
  clientSecret: string;
  sessionId: string;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/billing/card/complete

Use the confirmed card for future charges.

Needs a dashboard session or an API key without scopes.

Bearer token.

Body (`application/json`, optional):

```ts
{
  sessionId: string;
}
```

`200` Billing

```ts
{
  subscription: {
    status: string;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    } | null;
    nextPlanAt: string | null; // date-time
    card: {
      brand: string;
      last4: string;
    } | null;
    paymentMethod: {
      type: string;
      brand: string | null;
      last4: string | null;
      email: string | null;
    } | null;
    nextChargeCents: number | null;
    needsPaymentMethod: boolean;
  } | null;
  plans: {
    key: "lite" | "standard" | "pro";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
    };
    storageQuotaBytes: number;
  } | null;
  referred: boolean;
  collectsTax: boolean;
  storedBytes: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### GET /v1/billing/invoices

Invoices, newest first.

Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Invoices

```ts
{
  invoices: {
    id: string;
    number: string | null;
    createdAt: string; // date-time
    totalCents: number;
    status: string;
    hostedUrl: string | null;
    pdfUrl: string | null;
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found, or billing is not configured; `409` Not possible in the current state; `429` Rate limited.

### GET /v1/household

Your household.

Pro is shared by up to 6 people, each with their own account and files. The owner pays and manages the household; the storage and screens are pooled. Needs a dashboard session or an API key without scopes.

Bearer token.

`200` Household

```ts
{
  role: "owner" | "member";
  owner: {
    email: string | null;
  };
  maxPeople: number;
  people: {
    id: string;
    email: string | null;
    role: "owner" | "member";
    you: boolean;
    joinedAt: string; // date-time
    storedBytes: number | null;
    storageLimitBytes: number | null;
  }[];
  invites: {
    id: string;
    email: string | null;
    createdAt: string; // date-time
    expiresAt: string; // date-time
  }[];
  storage: {
    usedBytes: number;
    quotaBytes: number;
  };
  inviteBlocked: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/household/invites

Invite someone.

Owner only. Returns a single-use link that expires in 14 days, and emails it to `email` when given. Needs a dashboard session or an API key without scopes.

Bearer token.

Body (`application/json`, optional):

```ts
{
  email?: string; // email
}
```

`201` Invite

```ts
{
  invite: {
    id: string;
    email: string | null;
    createdAt: string; // date-time
    expiresAt: string; // date-time
  };
  url: string;
  emailed: boolean;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### DELETE /v1/household/invites/:id

Withdraw an invite.

Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Withdrawn

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### PATCH /v1/household/members/:id

Limit a member's storage.

Owner only. `null` lets the member use the whole plan. Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

Body (`application/json`, optional):

```ts
{
  storageLimitBytes: number | null;
}
```

`200` Household

```ts
{
  role: "owner" | "member";
  owner: {
    email: string | null;
  };
  maxPeople: number;
  people: {
    id: string;
    email: string | null;
    role: "owner" | "member";
    you: boolean;
    joinedAt: string; // date-time
    storedBytes: number | null;
    storageLimitBytes: number | null;
  }[];
  invites: {
    id: string;
    email: string | null;
    createdAt: string; // date-time
    expiresAt: string; // date-time
  }[];
  storage: {
    usedBytes: number;
    quotaBytes: number;
  };
  inviteBlocked: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### DELETE /v1/household/members/:id

Remove a member.

Owner only. The member keeps their account, and their files stay for the lapse grace period unless they choose a plan. Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | `string` | Required. |

`204` Removed

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/household/leave

Leave the household.

Members only. Your files stay for the lapse grace period unless you choose a plan. Needs a dashboard session or an API key without scopes.

Bearer token.

`204` Left

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### GET /v1/household/join/:token

Preview an invite.

What joining does: whose plan, whether your files fit, and the plan of yours it ends. Needs a dashboard session or an API key without scopes.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `token` | path | `string` | Required. |

`200` Preview

```ts
{
  owner: {
    email: string | null;
  };
  planName: string | null;
  people: number;
  maxPeople: number;
  problem: string | null;
  ownPlan: {
    name: string;
    trialing: boolean;
    refundCents: number;
  } | null;
  storedBytes: number;
  freeBytes: number;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### POST /v1/household/join

Join a household.

Ends your own plan: a trial at once, a paid plan at once with the unused part refunded. Needs a dashboard session or an API key without scopes.

Bearer token.

Body (`application/json`, optional):

```ts
{
  token: string;
}
```

`200` Household

```ts
{
  role: "owner" | "member";
  owner: {
    email: string | null;
  };
  maxPeople: number;
  people: {
    id: string;
    email: string | null;
    role: "owner" | "member";
    you: boolean;
    joinedAt: string; // date-time
    storedBytes: number | null;
    storageLimitBytes: number | null;
  }[];
  invites: {
    id: string;
    email: string | null;
    createdAt: string; // date-time
    expiresAt: string; // date-time
  }[];
  storage: {
    usedBytes: number;
    quotaBytes: number;
  };
  inviteBlocked: string | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Not possible in the current state; `429` Rate limited.

### GET /v1/referrals

Your invite link and the friends who used it.

Give a month, get a month: a friend who signs up through the link gets a month-long free trial, and when they first pay, you get a month of your plan as credit on your next bill. `url` is null for a household member, whose owner pays.

Bearer token.

`200` Referrals

```ts
{
  url: string | null;
  friendTrialDays: number;
  earnedCents: number;
  friends: {
    id: string;
    email: string | null;
    status: "waiting" | "earned" | "not_counted";
    creditCents: number | null;
    joinedAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `429` Rate limited.
