> ## Documentation Index
> Fetch the complete documentation index at: https://www.dropfans.io/developers/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload images, audio and video

> Small files go through one multipart call; videos go straight to the CDN with a three-step TUS flow.

- Source: https://www.dropfans.io/developers/guides/upload-media
- Section: Guides
- OpenAPI: https://www.dropfans.io/developers/openapi.json

Three media types, two paths: images and audio go through one multipart request; video is too big for that and uploads straight to the CDN.

## Images — one multipart call

Send `fileType=image`, `originalName`, and **both** an original (`displayFile`) and a thumbnail (`thumbnailFile`) part. The whole multipart body must stay under 4 MB — compress client-side before sending.

```bash tab="curl"
curl -X POST "https://www.dropfans.io/api/external/vault" \
  -H "Authorization: Bearer $DROPFANS_API_KEY" \
  -F "fileType=image" \
  -F "originalName=beach.jpg" \
  -F "displayFile=@beach.jpg" \
  -F "thumbnailFile=@beach_thumb.jpg"
```

```javascript tab="Node"
import { openAsBlob } from 'node:fs';

const form = new FormData();
form.set('fileType', 'image');
form.set('originalName', 'beach.jpg');
form.set('displayFile', await openAsBlob('beach.jpg'), 'beach.jpg');
form.set('thumbnailFile', await openAsBlob('beach_thumb.jpg'), 'beach_thumb.jpg');

const res = await fetch('https://www.dropfans.io/api/external/vault', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + process.env.DROPFANS_API_KEY },
  body: form,
});
const { item } = await res.json();
```

```python tab="Python"
import os, requests

HEADERS = {"Authorization": f"Bearer {os.environ['DROPFANS_API_KEY']}"}
res = requests.post(
    "https://www.dropfans.io/api/external/vault",
    headers=HEADERS,
    data={"fileType": "image", "originalName": "beach.jpg"},
    files={
        "displayFile": open("beach.jpg", "rb"),
        "thumbnailFile": open("beach_thumb.jpg", "rb"),
    },
)
item = res.json()["item"]
```

The response is `{ "success": true, "item": { … } }`. The item starts `PENDING` — see [Moderation & statuses](https://www.dropfans.io/developers/concepts/moderation-and-statuses.md).

## Audio — same call, one file part

Send `fileType=audio` with a single `file` part (≤ 20 MB, else `413`) and an optional `durationSeconds` (capped at 3600 seconds):

```bash
curl -X POST "https://www.dropfans.io/api/external/vault" \
  -H "Authorization: Bearer $DROPFANS_API_KEY" \
  -F "fileType=audio" \
  -F "originalName=voice-note.ogg" \
  -F "durationSeconds=42" \
  -F "file=@voice-note.ogg"
```

## Video — three steps via TUS

Videos (up to 500 MB) never touch our servers directly — you upload straight to the Bunny CDN with the resumable [TUS protocol](https://tus.io/).

### Step 1 — start

```bash
curl -X POST "https://www.dropfans.io/api/external/vault/video-upload" \
  -H "Authorization: Bearer $DROPFANS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "originalName": "clip.mp4", "fileSize": 104857600 }'
```

```json
{
  "videoId": "9f2c…",
  "tusEndpoint": "https://video.bunnycdn.com/tusupload",
  "libraryId": 572783,
  "signature": "…",
  "expires": 1755640000000,
  "completionToken": "…"
}
```

Keep `completionToken` — it is valid for 8 hours and step 3 requires it.

### Step 2 — TUS upload to the CDN

Send the file to `tusEndpoint` with these TUS metadata headers: `AuthorizationSignature` (= `signature`), `AuthorizationExpire` (= `expires`), `VideoId` (= `videoId`), `LibraryId` (= `libraryId`). With `tus-js-client`:

```javascript
import * as tus from 'tus-js-client';
import fs from 'node:fs';

await new Promise((resolve, reject) => {
  const upload = new tus.Upload(fs.createReadStream('clip.mp4'), {
    endpoint: creds.tusEndpoint,
    chunkSize: 50 * 1024 * 1024, // 50 MB chunks
    retryDelays: [0, 1500, 4000, 8000, 15000],
    headers: {
      AuthorizationSignature: creds.signature,
      AuthorizationExpire: String(creds.expires),
      VideoId: creds.videoId,
      LibraryId: String(creds.libraryId),
    },
    metadata: { filetype: 'video/mp4', title: 'clip.mp4' },
    uploadSize: fs.statSync('clip.mp4').size,
    onError: reject,
    onSuccess: resolve,
  });
  upload.start();
});
```

### Step 3 — complete

```bash
curl -X POST "https://www.dropfans.io/api/external/vault/video-upload/complete" \
  -H "Authorization: Bearer $DROPFANS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "videoId": "9f2c…", "originalName": "clip.mp4", "completionToken": "…" }'
```

A `409` means the CDN has not surfaced the finished upload yet — **this is expected**, retry on a ladder of roughly 2 s, 4 s, 6 s, 10 s, 15 s, 20 s, 30 s (give up after ~8 attempts). Retry `409` and `5xx` only; a `403` means the completion token expired (restart from step 1) and a `413` means the file was over the cap. The call is idempotent — completing twice returns the same item.

### Then poll processing

[POST /vault/video-status](https://www.dropfans.io/developers/reference/video-status.md) (up to 50 ids) reports `{ isReady, isProcessing, isFailed }` per video. Poll every 10–15 seconds until `isReady` or `isFailed`. Processing is separate from moderation — after `isReady`, the item still clears review like everything else.

## Folders and tags

- Create folders with `POST /vault/folders` (`{ "name": … }`, ≤ 50 chars, duplicate names `409`); move items with `PATCH /vault/{id}/folder`; pass `folderId` at upload time to file directly.
- Tag items with `PATCH /vault/{id}/tags` — `{ "contentTags": [ … ] }`, up to 50 tags of ≤ 64 characters each.

Next: [Sell a drop end-to-end](https://www.dropfans.io/developers/guides/sell-a-drop.md) or [Moderation & statuses](https://www.dropfans.io/developers/concepts/moderation-and-statuses.md).

---

Previous: [Sell a drop end-to-end](https://www.dropfans.io/developers/guides/sell-a-drop.md) · Next: [Links & Telegram deep links](https://www.dropfans.io/developers/guides/links-and-deep-links.md) · All pages: [llms.txt](https://www.dropfans.io/developers/llms.txt)
