# Fastwater API

Base URL: `https://staging.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*, *Developer*,
   then *Create an API key*. 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/fastwater-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]);
```

## Browser apps

`/v1` answers CORS from any origin, so a web app on `localhost` or its own
site can call it with `fetch` and a bearer token (no cookies). Media URLs
(`playback.url`, downloads) allow any origin too, so `<video>`, hls.js,
and `crossorigin` players work. Load subtitle tracks from
`/v1/files/{id}/subtitles/{trackId}` with the same token.

## 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* in *Account*, *Devices*, 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 in *Account*, *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 in *Account*, *Devices*.

## WebDAV

On every plan, `https://fastwater.app/dav/` serves your files
read-only over WebDAV: one folder per transfer, its files inside. Create a key
on the Apps page (*Set up* on a WebDAV app's card), 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 in *Account*, *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 every plan, 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 in *Account*, *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/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
    cancelAtPeriodEnd: boolean;
    active: boolean;
    trialEnd: string | null; // date-time
    needsPaymentMethod: boolean;
    savesOnly: 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`. |
| `putioImport` | query | `"true" \| "false"` |  |

`200` Transfers

```ts
{
  data: {
    id: string;
    status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
    outcome: "hit" | "attached" | "miss";
    sourceType: "magnet" | "torrent" | "upload" | "putio";
    infoHash: string;
    name: string | null;
    totalBytes: number | null;
    progress: number;
    speedBps: number;
    etaSeconds: number | null;
    processingProgress: number | null;
    fileCount: number | null;
    error: string | null;
    folder: string | null;
    fileIds: string[];
    rssFeed: {
      id: string;
      title: string;
    } | null;
    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 (and an optional `folder`). 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;
  episode?: {
    season: number;
    episode: number;
  };
}
```

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

```ts
{
  torrent: File;
  folder?: string;
}
```

`201` Created

```ts
{
  id: string;
  status: "queued" | "downloading" | "ingesting" | "completed" | "failed" | "cancelled";
  outcome: "hit" | "attached" | "miss";
  sourceType: "magnet" | "torrent" | "upload" | "putio";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  processingProgress: number | null;
  fileCount: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  rssFeed: {
    id: string;
    title: string;
  } | null;
  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" | "upload" | "putio";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  processingProgress: number | null;
  fileCount: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  rssFeed: {
    id: string;
    title: string;
  } | null;
  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" | "upload" | "putio";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  processingProgress: number | null;
  fileCount: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  rssFeed: {
    id: string;
    title: string;
  } | null;
  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" | "upload" | "putio";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  processingProgress: number | null;
  fileCount: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  rssFeed: {
    id: string;
    title: string;
  } | null;
  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/watch

Wait on a downloading transfer to play it.

Play while downloading (PRD §10.10). Call it when the user presses Play or opens a transfer that is not ready, then every few seconds while the answer is `ready_in` or `starting_in`: each call keeps the download in order for the stream. `streaming` gives an HLS URL to play from the start; `ready` means the files are stored.

Bearer token with `transfers:read`.

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

Body (`application/json`, optional):

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

`200` When it plays

```ts
{
  state: "ready" | "ready_in" | "starting_in" | "streaming" | "unavailable";
  seconds: number | null;
  reason: "queued" | "measuring" | "finishing_soon" | "too_slow" | "not_streamable" | "needs_h264" | "processing" | "failed" | "cancelled" | null;
  hls: {
    url: string; // uri
    expiresAt: string; // date-time
  } | null;
  fileIds: string[];
  availableSeconds: number | null;
  durationSeconds: number | null;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` Every screen is taken (`screen_limit`); `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/files

List files, newest first or by `sort`.

Filter by name (`q`), `type`, `transferId`, or `folder` (`subfolders=true` adds the folders inside it); `filed=false` leaves out files filed under a folder, and `clutter=false` leaves out release leftovers (`.nfo`, checksums, tracker `.txt` ads, `Thumbs.db`). `sort` is `newest` (default), `oldest`, `name` (A to Z), `name_desc`, `size` (largest first), or `smallest`. `total` counts every matching file; `clutterCount` counts the clutter among them, even when it is left out. 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` |  |
| `subfolders` | query | `"true" \| "false"` |  |
| `filed` | query | `"true" \| "false"` |  |
| `clutter` | query | `"true" \| "false"` |  |
| `sort` | query | `"newest" \| "oldest" \| "name" \| "name_desc" \| "size" \| "smallest"` | Default `"newest"`. |

`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
    kept?: "self" | "folder" | 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;
    clutter?: boolean;
    thumbnailUrl?: string | null;
    createdAt: string; // date-time
    updatedAt: string; // date-time
  }[];
  nextCursor: string | null;
  total?: number;
  clutterCount?: number;
}
```

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

### GET /v1/folders

List the folders, newest first.

Folders hold files: a transfer added with `folder` (such as a show's `Name (Year)`) puts its files there, and so does a torrent with more than one file (named after it). Empty folders made with `POST /folders` are listed too. A folder inside another is a path, `Roms/GBA`; each `name` is the full path, and a folder's `fileCount` and `sizeBytes` take in the folders inside it. List a folder's files with `GET /files?folder=`.

Bearer token with `files:read`.

`200` Folders

```ts
{
  data: {
    name: string;
    kept?: "self" | "folder" | null;
    fileCount: number;
    sizeBytes: number;
    posterPath?: string | null;
    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

Make an empty folder.

It stays until deleted. A path such as `Roms/GBA` makes it inside another folder. Names match without case.

Bearer token with `files:write`.

Body (`application/json`):

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

`201` Folder

```ts
{
  name: string;
  kept?: "self" | "folder" | null;
  fileCount: number;
  sizeBytes: number;
  posterPath?: string | null;
  updatedAt: string; // date-time
}
```

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

### POST /v1/folders/rename

Rename a folder.

`name` is the folder's path; `newName` is one name, which it takes in the same folder. Its files and the folders inside it move with it. A rename that changes only the case is allowed.

Bearer token with `files:write`.

Body (`application/json`):

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

`200` Renamed

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

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` A folder with the new name exists; `429` Rate limited.

### POST /v1/folders/move

Move a folder into another, or to the top.

`name` is the folder's path; `parent` is the folder to put it in (made if it does not exist), or null for the top. Its files and the folders inside it move with it. A folder cannot go inside itself.

Bearer token with `files:write`.

Body (`application/json`):

```ts
{
  name: string;
  parent: string | null;
}
```

`200` Moved

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

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `409` The target has a folder with this name; `429` Rate limited.

### POST /v1/files/move

Move files to a folder, or to the top.

Up to 500 file IDs. `folder` is a path such as `Roms/GBA`; `folder: null` moves them out of any folder; a folder that does not exist is made. IDs that are not your files are skipped.

Bearer token with `files:write`.

Body (`application/json`):

```ts
{
  fileIds: string[];
  folder: string | null;
}
```

`200` Moved

```ts
{
  moved: number;
  folder: string | null;
}
```

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

### POST /v1/folders/keep

Keep a folder from auto-delete.

`keep: true` keeps every file in the folder and the folders inside it, including files added later, from the account's auto-delete setting; `keep: false` lets it delete them again. A plan's retention (Lite) still applies. A kept folder stays when it is empty.

Bearer token with `files:write`.

Body (`application/json`):

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

`200` Kept

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

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

### POST /v1/files/keep

Keep files from auto-delete.

Up to 500 file IDs. `keep: true` keeps them from the account's auto-delete setting; `keep: false` lets it delete them again. A plan's retention (Lite) still applies. IDs that are not your files are skipped.

Bearer token with `files:write`.

Body (`application/json`):

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

`200` Kept

```ts
{
  updated: number;
}
```

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

### POST /v1/folders/delete

Delete a folder and every file in it.

The folder's path matches without case, as in `GET /folders`. The folders inside it go too. `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
  kept?: "self" | "folder" | 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;
  clutter?: boolean;
  thumbnailUrl?: string | 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
  kept?: "self" | "folder" | 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;
  clutter?: boolean;
  thumbnailUrl?: string | 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.

### GET /v1/files/:id/content

Read a text, PDF, or image file.

For AI assistants and scripts. A text file comes back as UTF-8 (`kind: "text"`), its first 1 MiB, with `truncated` set when there is more; a PDF as the text of its pages (up to 50 MiB files); a PNG, JPEG, GIF, or WebP image up to 3.75 MB as base64 (`kind: "image"`). Videos, audio, and other binary files answer `unsupported_file` (415): use the download link. Counts toward the same rate limit as download links.

Bearer token with `files:read`.

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

`200` The file's text or image

```ts
{
  kind: "text";
  name: string;
  mimeType: string;
  sizeBytes: number;
  text: string;
  truncated: boolean;
  pages: number | null;
} | {
  kind: "image";
  name: string;
  mimeType: string;
  sizeBytes: number;
  data: string;
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `415` Not a text, PDF, or image file, or too large to read; `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;
  hls?: {
    url: string; // uri
    tvUrl?: string; // uri
  } | 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. With `inline=true`, an image, PDF, audio, or text file gets `Content-Disposition: inline` and a type a browser shows (text is always `text/plain`), for a preview in the page.

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"`. |
| `inline` | 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
      kept?: "self" | "folder" | 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;
      clutter?: boolean;
      thumbnailUrl?: string | 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.

### POST /v1/uploads

Start uploading a file from your device.

Reserves the file's size in your storage and returns how to split it: send each part with `PUT /v1/uploads/{id}/parts/{n}` (every part `partSize` bytes but the last), then `POST /v1/uploads/{id}/complete`. The worker then reads the file like a torrent's (media info, MP4 remux, subtitles), as a transfer with `sourceType: "upload"`. Files up to 5 TiB. An upload left unfinished for 2 days is dropped.

Bearer token with `transfers:write`.

Body (`application/json`):

```ts
{
  name: string;
  sizeBytes: number;
  mimeType?: string;
  folder?: string;
}
```

`201` Upload started

```ts
{
  id: string;
  partSize: number;
  partCount: number;
  uploadedParts: number[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed, or over the storage quota (`quota_exceeded`); `404` Not found; `409` The upload has ended, or parts are missing; `429` Rate limited.

### POST /v1/uploads/errors

Report an upload that failed in the client.

Goes to Fastwater's logs, so support can see what went wrong. Send it once, when an upload gives up; leave the file's name out.

Bearer token with `transfers:write`.

Body (`application/json`):

```ts
{
  uploadId?: string;
  stage: "read" | "create" | "part" | "complete" | "resume";
  sizeBytes: number;
  extension?: string;
  status?: number;
  attempts: number;
  message: string;
  client: string;
}
```

`204` Logged

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed, or over the storage quota (`quota_exceeded`); `404` Not found; `409` The upload has ended, or parts are missing; `429` Rate limited.

### GET /v1/uploads/:id

The parts received so far, to resume an upload.

Bearer token with `transfers:write`.

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

`200` Upload

```ts
{
  id: string;
  partSize: number;
  partCount: number;
  uploadedParts: number[];
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed, or over the storage quota (`quota_exceeded`); `404` Not found; `409` The upload has ended, or parts are missing; `429` Rate limited.

### DELETE /v1/uploads/:id

Cancel an upload and free its reserved space.

Bearer token with `transfers:write`.

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

`204` Cancelled

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed, or over the storage quota (`quota_exceeded`); `404` Not found; `409` The upload has ended, or parts are missing; `429` Rate limited.

### PUT /v1/uploads/:id/parts/:n

Send one part of an upload.

The body is the part's bytes (`application/octet-stream`) with its `Content-Length`. Parts are numbered from 1, can go in any order and in parallel, and a part sent again replaces it.

Bearer token with `transfers:write`.

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

`204` Stored

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed, or over the storage quota (`quota_exceeded`); `404` Not found; `409` The upload has ended, or parts are missing; `429` Rate limited.

### POST /v1/uploads/:id/complete

Finish an upload and queue it for processing.

Returns the transfer. Calling it again returns the same transfer. A 409 for a joined file of the wrong size drops the parts: get the upload again and send every part.

Bearer token with `transfers:write`.

| 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" | "upload" | "putio";
  infoHash: string;
  name: string | null;
  totalBytes: number | null;
  progress: number;
  speedBps: number;
  etaSeconds: number | null;
  processingProgress: number | null;
  fileCount: number | null;
  error: string | null;
  folder: string | null;
  fileIds: string[];
  rssFeed: {
    id: string;
    title: string;
  } | null;
  createdAt: string; // date-time
  updatedAt: string; // date-time
}
```

Errors: `400` Invalid request; `401` Missing or invalid token; `403` Not allowed, or over the storage quota (`quota_exceeded`); `404` Not found; `409` The upload has ended, or parts are missing; `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;
    thumbnailUrl?: string | 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;
  hls?: {
    url: string; // uri
    tvUrl?: string; // uri
  } | null;
}
```

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/saves/devices

Devices that sync saves.

SaveSync's apps, each linked with a code shown on the device (`POST /v1/devices/pairings`). Newest first. Needs a dashboard session or an API key without scopes.

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" | "savesync_handheld";
    createdAt: string; // date-time
    lastUsedAt: string | null; // date-time
    lastActivity: {
      id: string;
      action: "upload" | "download" | "delete" | "restore";
      path: string;
      sizeBytes: number | null;
      deviceId: string | null;
      deviceName: string | null;
      createdAt: string; // date-time
    } | null;
  }[];
}
```

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

### GET /v1/saves/activity

What devices did with your saves.

Uploads, downloads, and deletes by SaveSync's apps, and restores from the dashboard, newest first, for the last 90 days. `deviceId` limits it to one device. Needs a dashboard session or an API key without scopes.

Bearer token.

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

`200` Activity

```ts
{
  data: {
    id: string;
    action: "upload" | "download" | "delete" | "restore";
    path: string;
    sizeBytes: number | null;
    deviceId: string | null;
    deviceName: string | null;
    createdAt: string; // date-time
  }[];
  nextCursor: string | null;
}
```

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

### DELETE /v1/saves/devices/:id

Turn a device off.

It stops syncing at once. Needs a dashboard session or an API key without scopes.

Bearer token.

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

`204` Turned off

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

### GET /v1/saves/account

SaveSync access and storage.

Whether the account can sync saves (any Fastwater plan, or a SaveSync plan), and the storage its saves use. A SaveSync plan limits that storage; a Fastwater plan does not.

Bearer token with `saves:read`.

`200` SaveSync access

```ts
{
  access: boolean;
  source: "fastwater" | "savesync" | null;
  planName: string | null;
  usedBytes: number;
  quotaBytes: number | null;
  plans: {
    key: "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    interval: "month" | "year";
    storageQuotaBytes: number;
  }[];
}
```

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

### GET /v1/saves

Game saves in a folder.

SaveSync's tree, the one its apps sync over WebDAV at `/saves`. `path` is a folder (`saves/mGBA`); leave it out for the root. `deleted=true` also lists deleted files, which a restore brings back.

Bearer token with `saves:read`.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `path` | query | `string` | Default `""`. |
| `deleted` | query | `"true" \| "false"` | Default `"false"`. |

`200` The folder's files and folders

```ts
{
  path: string;
  data: {
    id: string;
    name: string;
    path: string;
    isFolder: boolean;
    sizeBytes: number | null;
    updatedAt: string; // date-time
    deletedAt: string | null; // date-time
    versions: number;
  }[];
}
```

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

### GET /v1/saves/:id/versions

A save file's kept uploads.

Newest first. SaveSync keeps the last 20 uploads of each file, even after it is deleted.

Bearer token with `saves:read`.

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

`200` Versions

```ts
{
  data: {
    id: string;
    sizeBytes: number;
    sha256: string;
    createdAt: string; // date-time
    current: boolean;
  }[];
}
```

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

### POST /v1/saves/versions/:id/restore

Bring back an older save.

Makes the version the file's current one (as a new version, so the one it replaces is kept), and brings the file back if it was deleted. Devices get it on their next sync.

Bearer token with `saves:write`.

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

`204` Restored

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No plan with SaveSync; `403` Not allowed; `404` Not found; `409` A file is in the way; `429` Rate limited.

### POST /v1/saves/versions/:id/download

Download one version of a save.

Bearer token with `saves:read`.

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

`200` A short-lived download link

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

Errors: `400` Invalid request; `401` Missing or invalid token; `402` No plan with SaveSync; `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/cache/sources

List the cached torrents of a movie, season, or episode.

Torrents stored in full for a TMDB title, so a transfer of one completes at once. For an episode, the list includes season packs that hold it (single episodes first); for a season, only packs.

Bearer token.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `tmdbId` | query | `number` | Required. |
| `kind` | query | `"movie" \| "show"` | Required. |
| `season` | query | `number \| null` |  |
| `episode` | query | `number \| null` |  |

`200` Cached torrents, at most 20

```ts
{
  data: {
    infoHash: string;
    name: string;
    sizeBytes: number;
    episodes: number[];
    files: {
      name: string;
      path: string;
      sizeBytes: number;
      mimeType: string;
      playable: boolean;
      videoCodec: string | null;
      audioCodecs: string[];
      height: number | null;
    }[];
    storedAt: string; // date-time
  }[];
}
```

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

### POST /v1/cache/usenet

Check which torrents download from Usenet (up to 100).

True for a torrent whose release is posted on Usenet: a transfer of it downloads from there, usually in seconds, whatever its seeders. Send each torrent's name and size as the site lists them. An empty object when Usenet is off.

Bearer token.

Body (`application/json`):

```ts
{
  sources: {
    hash: string;
    name: string;
    sizeBytes?: number | null;
  }[];
}
```

`200` Usenet availability by hash

```ts
Record<string, boolean>
```

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" | "saves:read" | "saves:write" | "feeds:read" | "feeds: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" | "saves:read" | "saves:write" | "feeds:read" | "feeds:write")[];
}
```

`201` Created

```ts
{
  secret: string;
  key: {
    id: string;
    name: string;
    scopes: ("files:read" | "files:write" | "progress:write" | "transfers:read" | "transfers:write" | "saves:read" | "saves:write" | "feeds:read" | "feeds: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/feeds

List RSS feeds.

RSS feeds add a transfer for each new item that matches their keywords (PRD §9.7). Each is checked about every 15 minutes. URLs are shown as scheme and host only: the rest may hold a passkey.

Bearer token with `feeds:read`.

`200` Feeds

```ts
{
  data: {
    id: string;
    title: string;
    url: string;
    host: string;
    folder: string | null;
    include: string;
    exclude: string;
    status: "ok" | "error" | "paused";
    pauseReason: "user" | "errors" | "no_subscription" | null;
    lastError: string | null;
    lastFetchAt: string | null; // date-time
    lastSuccessAt: string | null; // date-time
    nextFetchAt: string; // date-time
    addedCount: number;
    failedCount: number;
    createdAt: string; // date-time
  }[];
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/feeds

Add an RSS feed.

Items may be magnets or .torrent links. The feed is fetched now: `feed_unreachable` or `feed_invalid` when it cannot be. Only items that appear later are added, unless `processExisting`. Include terms are comma-separated; a term matches when every word of it is in the title (case and `.`, `_`, `-` ignored, a word matching the start of a title word). An item is added when any include term matches (or there are none) and no exclude term does. At most 100 feeds (`too_many_feeds`).

Bearer token with `feeds:write`.

Body (`application/json`):

```ts
{
  url: string; // uri
  title?: string;
  folder?: string | null;
  include?: string;
  exclude?: string;
  processExisting?: boolean;
}
```

`201` Created

```ts
{
  feed: {
    id: string;
    title: string;
    url: string;
    host: string;
    folder: string | null;
    include: string;
    exclude: string;
    status: "ok" | "error" | "paused";
    pauseReason: "user" | "errors" | "no_subscription" | null;
    lastError: string | null;
    lastFetchAt: string | null; // date-time
    lastSuccessAt: string | null; // date-time
    nextFetchAt: string; // date-time
    addedCount: number;
    failedCount: number;
    createdAt: string; // date-time
  };
  preview: {
    title: string | null;
    itemCount: number;
    matchCount: number;
    items: {
      title: string;
      publishedAt: string | null; // date-time
      matches: boolean;
      hasTorrent: boolean;
    }[];
  };
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `409` Too many feeds; `429` Rate limited.

### POST /v1/feeds/preview

Preview a feed and its matches.

Fetches the feed and shows its newest 20 items and which the keywords take, without saving anything.

Bearer token with `feeds:read`.

Body (`application/json`):

```ts
{
  url: string; // uri
  include?: string;
  exclude?: string;
}
```

`200` Preview

```ts
{
  title: string | null;
  itemCount: number;
  matchCount: number;
  items: {
    title: string;
    publishedAt: string | null; // date-time
    matches: boolean;
    hasTorrent: boolean;
  }[];
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/feeds/:id

Get an RSS feed, with its full URL.

Bearer token with `feeds:read`.

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

`200` Feed

```ts
{
  id: string;
  title: string;
  url: string;
  host: string;
  folder: string | null;
  include: string;
  exclude: string;
  status: "ok" | "error" | "paused";
  pauseReason: "user" | "errors" | "no_subscription" | null;
  lastError: string | null;
  lastFetchAt: string | null; // date-time
  lastSuccessAt: string | null; // date-time
  nextFetchAt: string; // date-time
  addedCount: number;
  failedCount: number;
  createdAt: string; // date-time
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### PATCH /v1/feeds/:id

Change an RSS feed.

Leave a field out to keep it. A new URL is fetched first. New keywords apply to items that appear later.

Bearer token with `feeds:write`.

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

Body (`application/json`):

```ts
{
  url?: string; // uri
  title?: string;
  folder?: string | null;
  include?: string;
  exclude?: string;
}
```

`200` Feed

```ts
{
  id: string;
  title: string;
  url: string;
  host: string;
  folder: string | null;
  include: string;
  exclude: string;
  status: "ok" | "error" | "paused";
  pauseReason: "user" | "errors" | "no_subscription" | null;
  lastError: string | null;
  lastFetchAt: string | null; // date-time
  lastSuccessAt: string | null; // date-time
  nextFetchAt: string; // date-time
  addedCount: number;
  failedCount: number;
  createdAt: string; // date-time
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/feeds/:id

Delete an RSS feed.

Its transfers and files stay.

Bearer token with `feeds:write`.

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

`204` Deleted

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/feeds/:id/pause

Pause an RSS feed.

It is not checked until resumed.

Bearer token with `feeds:write`.

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

`200` Feed

```ts
{
  id: string;
  title: string;
  url: string;
  host: string;
  folder: string | null;
  include: string;
  exclude: string;
  status: "ok" | "error" | "paused";
  pauseReason: "user" | "errors" | "no_subscription" | null;
  lastError: string | null;
  lastFetchAt: string | null; // date-time
  lastSuccessAt: string | null; // date-time
  nextFetchAt: string; // date-time
  addedCount: number;
  failedCount: number;
  createdAt: string; // date-time
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/feeds/:id/resume

Resume an RSS feed.

Checked within 5 minutes. Items that appeared while it was paused are added if they match.

Bearer token with `feeds:write`.

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

`200` Feed

```ts
{
  id: string;
  title: string;
  url: string;
  host: string;
  folder: string | null;
  include: string;
  exclude: string;
  status: "ok" | "error" | "paused";
  pauseReason: "user" | "errors" | "no_subscription" | null;
  lastError: string | null;
  lastFetchAt: string | null; // date-time
  lastSuccessAt: string | null; // date-time
  nextFetchAt: string; // date-time
  addedCount: number;
  failedCount: number;
  createdAt: string; // date-time
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/feeds/:id/refresh

Check an RSS feed soon.

Checked on the next run, within 5 minutes.

Bearer token with `feeds:write`.

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

`200` Feed

```ts
{
  id: string;
  title: string;
  url: string;
  host: string;
  folder: string | null;
  include: string;
  exclude: string;
  status: "ok" | "error" | "paused";
  pauseReason: "user" | "errors" | "no_subscription" | null;
  lastError: string | null;
  lastFetchAt: string | null; // date-time
  lastSuccessAt: string | null; // date-time
  nextFetchAt: string; // date-time
  addedCount: number;
  failedCount: number;
  createdAt: string; // date-time
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### GET /v1/feeds/:id/items

An RSS feed's history.

Every item seen, newest first, with what happened to it.

Bearer token with `feeds:read`.

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

`200` Items

```ts
{
  data: {
    id: string;
    title: string;
    publishedAt: string | null; // date-time
    status: "baseline" | "skipped" | "waiting" | "added" | "failed";
    reason: string | null;
    transferId: string | null;
    detectedAt: string; // date-time
    processedAt: string | null; // date-time
  }[];
  nextCursor: string | null;
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### DELETE /v1/feeds/:id/items

Clear an RSS feed's history.

Items seen before are still not added again.

Bearer token with `feeds:write`.

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

`204` Cleared

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### POST /v1/feeds/:id/items/:itemId/retry

Add an item now.

Adds a failed or skipped item now, whatever the keywords. `waiting` when at the active-transfer limit.

Bearer token with `feeds:write`.

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

`200` Item

```ts
{
  id: string;
  title: string;
  publishedAt: string | null; // date-time
  status: "baseline" | "skipped" | "waiting" | "added" | "failed";
  reason: string | null;
  transferId: string | null;
  detectedAt: string; // date-time
  processedAt: string | null; // date-time
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `402` No active subscription; `403` Not allowed; `404` Not found; `409` Already added; `429` Rate limited.

### POST /v1/feeds/:id/retry

Retry an RSS feed's failed items.

They are added on the next check, within 5 minutes.

Bearer token with `feeds:write`.

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

`200` Queued

```ts
{
  retried: number;
}
```

Errors: `400` Invalid request, or the feed could not be fetched or read; `401` Missing or invalid token; `403` Not allowed; `404` Not found; `429` Rate limited.

### 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" | "savesync_handheld";
}
```

`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" | "savesync_handheld";
    createdAt: string; // date-time
    lastSeenAt: string; // date-time
  };
}
```

Errors: `400` Invalid request; `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" | "savesync_handheld";
  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" | "savesync_handheld";
    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" | "savesync_handheld";
    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" | "savesync_handheld";
    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/imports

The files brought from Put.io.

A Put.io switcher's files (PRD §22): each one's state (copying from Put.io, imported, not found, skipped past the quota of the plan after the trial, or failed), resumed files first, then the newest. Empty for anyone who did not switch from Put.io.

Bearer token with `files:read`.

`200` Imported files

```ts
{
  items: {
    id: string;
    putioFileId: number;
    name: string;
    sizeBytes: number;
    state: "copying" | "imported" | "not_found" | "skipped" | "failed";
    progress: number | null;
    fileId: string | null;
    startSeconds: number | null;
    putioCreatedAt: string; // date-time
  }[];
  counts: {
    copying?: number;
    imported?: number;
    not_found?: number;
    skipped?: number;
    failed?: number;
  };
}
```

Errors: `401` Missing or invalid token.

### 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
    kept?: "self" | "folder" | 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;
    clutter?: boolean;
    thumbnailUrl?: string | 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
        kept?: "self" | "folder" | 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;
        clutter?: boolean;
        thumbnailUrl?: string | 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/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" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  }[];
  trial: {
    days: number;
    plan: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
    storageQuotaBytes: number;
  } | null;
}
```

### 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" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    } | 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" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    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" | "savesync" | "savesync_yearly";
}
```

`200` Checkout

```ts
{
  clientSecret: string;
  plan: {
    key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  };
  trialPlan: {
    key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  } | 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" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    } | 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" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    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" | "savesync" | "savesync_yearly";
}
```

`200` Plan change

```ts
{
  billing: {
    subscription: {
      status: string;
      plan: {
        key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
        name: string;
        priceCents: number;
        storageQuotaBytes: number;
        maxActiveTransfers: number;
        maxScreens: number;
        fileRetentionDays: number | null;
        webdav: boolean;
        priorityDownloads: boolean;
        maxPeople: number;
        savesOnly: boolean;
      };
      trialEnd: string | null; // date-time
      periodEnd: string; // date-time
      cancelAtPeriodEnd: boolean;
      nextPlan: {
        key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
        name: string;
        priceCents: number;
        storageQuotaBytes: number;
        maxActiveTransfers: number;
        maxScreens: number;
        fileRetentionDays: number | null;
        webdav: boolean;
        priorityDownloads: boolean;
        maxPeople: number;
        savesOnly: boolean;
      } | 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" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    }[];
    trial: {
      days: number;
      plan: {
        key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
        name: string;
        priceCents: number;
        storageQuotaBytes: number;
        maxActiveTransfers: number;
        maxScreens: number;
        fileRetentionDays: number | null;
        webdav: boolean;
        priorityDownloads: boolean;
        maxPeople: number;
        savesOnly: boolean;
      };
      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" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    } | 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" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    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" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    } | 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" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    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" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    trialEnd: string | null; // date-time
    periodEnd: string; // date-time
    cancelAtPeriodEnd: boolean;
    nextPlan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    } | 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" | "savesync" | "savesync_yearly";
    name: string;
    priceCents: number;
    storageQuotaBytes: number;
    maxActiveTransfers: number;
    maxScreens: number;
    fileRetentionDays: number | null;
    webdav: boolean;
    priorityDownloads: boolean;
    maxPeople: number;
    savesOnly: boolean;
  }[];
  trial: {
    days: number;
    plan: {
      key: "lite" | "standard" | "pro" | "savesync" | "savesync_yearly";
      name: string;
      priceCents: number;
      storageQuotaBytes: number;
      maxActiveTransfers: number;
      maxScreens: number;
      fileRetentionDays: number | null;
      webdav: boolean;
      priorityDownloads: boolean;
      maxPeople: number;
      savesOnly: boolean;
    };
    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.
