> ## Documentation Index
> Fetch the complete documentation index at: https://api.fanvue.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload media in parts

> Upload an image, video, voice note or document to a creator's Fanvue media with the multipart upload API, then wait until it is ready to attach.

By the end of this page you'll have a file in the creator's media with a `mediaUuid` you can attach to a message or post. Uploads are multipart: you open a session, upload each part to a signed URL, complete the session and poll until the media is ready.

You need an access token with the `write:media` scope, and a file of up to 1.5 GiB, or 100 MiB for a document. Agencies use the creator-scoped routes, covered in [Agency uploads](#agency-uploads).

## Step 1: Create the session

`POST /media/uploads` creates a media record and opens the session. `mediaType` is `image`, `video`, `audio` or `document`. Pass `sizeBytes` when you know it, so the response includes the exact `totalParts`.

```bash theme={null}
curl -X POST "https://api.fanvue.com/media/uploads" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Fanvue-API-Version: 2025-06-26" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Lesson 1",
    "filename": "lesson-1.mp4",
    "mediaType": "video",
    "sizeBytes": 73400320
  }'
```

```json theme={null}
{
  "mediaUuid": "0b4a3c1e-2f6d-4b8a-9c1d-5e7f8a9b0c1d",
  "uploadId": "2~kq9VbA7p3Xe1Zr5mN8wT4yU6iO0sD2fG",
  "partSize": 6291456,
  "maxParts": 256,
  "totalParts": 12
}
```

| Field | Meaning |
| - | - |
| `mediaUuid` | The media item. Poll it in step 5 and attach it to messages or posts. |
| `uploadId` | The session. Use it on every later upload call. |
| `partSize` | Bytes per part, except the final part, which may be smaller. |
| `maxParts` | Highest part number the session accepts. |
| `totalParts` | Parts to upload, computed from `sizeBytes`. `null` when `sizeBytes` was omitted; compute `ceil(fileSize / partSize)` yourself. |

A `sizeBytes` over the platform maximum, or over 100 MiB for a document, returns `400`.

## Step 2: Sign the parts

`GET /media/uploads/{uploadId}/parts/urls?from=1&to=12` returns signed URLs for a contiguous range of part numbers in one request. Part numbers are 1-based.

```bash theme={null}
curl "https://api.fanvue.com/media/uploads/2~kq9VbA7p3Xe1Zr5mN8wT4yU6iO0sD2fG/parts/urls?from=1&to=12" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Fanvue-API-Version: 2025-06-26"
```

```json theme={null}
{
  "partSize": 6291456,
  "parts": [
    { "partNumber": 1, "url": "https://uploads.example.net/...&partNumber=1" },
    { "partNumber": 2, "url": "https://uploads.example.net/...&partNumber=2" }
  ]
}
```

Signed URLs are valid for a limited time, so for a slow upload sign the range you are about to send rather than the whole file. A `to` higher than `maxParts` returns `413`.

`GET /media/uploads/{uploadId}/parts/{partNumber}/url` signs one part and returns the bare URL as `text/plain`. Use it for a single-part retry, and use the range route otherwise, because it costs one request against your rate limit instead of one per part.

## Step 3: Upload the bytes

`PUT` the raw bytes of each part to its signed URL, with no `Authorization` header. Record the `ETag` response header for every part, because the completion call needs it.

## Step 4: Complete the session

Send `PATCH /media/uploads/{uploadId}` with every part number and its `ETag`.

```bash theme={null}
curl -X PATCH "https://api.fanvue.com/media/uploads/2~kq9VbA7p3Xe1Zr5mN8wT4yU6iO0sD2fG" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Fanvue-API-Version: 2025-06-26" \
  -H "Content-Type: application/json" \
  -d '{
    "parts": [
      { "PartNumber": 1, "ETag": "\"9bb58f26192e4ba00f01e2e7b136bbd8\"" },
      { "PartNumber": 2, "ETag": "\"5d41402abc4b2a76b9719d911017c592\"" }
    ]
  }'
```

```json theme={null}
{ "status": "processing" }
```

The response status is `processing`, not `ready`, because transcoding and moderation run after this call. The document size ceiling is enforced here, against the uploaded bytes.

## Step 5: Poll until ready

Call `GET /media/{uuid}` with the `mediaUuid` from step 1 until `status` is `ready` or `error`.

```bash theme={null}
curl "https://api.fanvue.com/media/0b4a3c1e-2f6d-4b8a-9c1d-5e7f8a9b0c1d?variants=thumbnail,main" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Fanvue-API-Version: 2025-06-26"
```

On `error`, `errorReason` names the cause; [Check processing status](/docs/tutorials/working-with-media#check-processing-status) lists the values.

<Warning>
  Attach media to a message or post only after `status` is `ready`. Media attached while `processing` reaches the recipient before they can open it. If you attached too early, wait for `ready` and send again.
</Warning>

## Upload a file in parts with TypeScript

This script runs all five steps for one file.

```typescript theme={null}
import { open } from "node:fs/promises";

const API = "https://api.fanvue.com";
const headers = {
  Authorization: `Bearer ${process.env.FANVUE_TOKEN}`,
  "X-Fanvue-API-Version": "2025-06-26",
  "Content-Type": "application/json",
};

async function api(path: string, init: RequestInit = {}) {
  const res = await fetch(`${API}${path}`, { ...init, headers: { ...headers, ...init.headers } });
  if (!res.ok) throw new Error(`${res.status} on ${path}: ${await res.text()}`);
  return res.json();
}

export async function upload(filePath: string, name: string, mediaType: "image" | "video" | "audio" | "document") {
  const file = await open(filePath);
  const { size } = await file.stat();

  const session = await api("/media/uploads", {
    method: "POST",
    body: JSON.stringify({ name, filename: filePath.split("/").pop(), mediaType, sizeBytes: size }),
  });
  const totalParts: number = session.totalParts ?? Math.ceil(size / session.partSize);

  const signed = await api(`/media/uploads/${session.uploadId}/parts/urls?from=1&to=${totalParts}`);

  const parts: { PartNumber: number; ETag: string }[] = [];
  for (const { partNumber, url } of signed.parts) {
    const start = (partNumber - 1) * session.partSize;
    const length = Math.min(session.partSize, size - start);
    const buffer = Buffer.alloc(length);
    await file.read(buffer, 0, length, start);

    const put = await fetch(url, { method: "PUT", body: buffer });
    if (!put.ok) throw new Error(`part ${partNumber} failed with ${put.status}`);
    parts.push({ PartNumber: partNumber, ETag: put.headers.get("etag") ?? "" });
  }
  await file.close();

  await api(`/media/uploads/${session.uploadId}`, { method: "PATCH", body: JSON.stringify({ parts }) });

  for (;;) {
    const media = await api(`/media/${session.mediaUuid}`);
    if (media.status === "ready") return media;
    if (media.status === "error") throw new Error(`processing failed: ${media.errorReason}`);
    await new Promise((r) => setTimeout(r, 1500));
  }
}
```

## With the SDK

`createFanvueClient().vault` wraps steps 1, 2, 4 and 5 as `createUploadSession`, `getUploadPartUrl`, `completeUpload` and `getMedia`. `pollUntilReady` from the media helpers polls `getMedia` every 1.5 seconds for up to 300 seconds and returns `ready`, `error` or `timeout`. See [SDK API client](/docs/app-store/sdk/api-client).

## Agency uploads

The creator-scoped routes mirror the self-serve ones under `/creators/{creatorUserUuid}/media/uploads*` and need `write:creator` and `write:media`. The creator-scoped single-part URL route has its own limit of 1000 requests per minute; the range route costs one request however many parts it signs.

## Limits

| Limit | Value |
| - | - |
| Maximum file size | 1.5 GiB |
| Maximum document size | 100 MiB, enforced when the session completes |
| Part size | `partSize` from the session response; every part except the last is exactly this size |
| Maximum parts | `maxParts` from the session response |
| `mediaType` values | `image`, `video`, `audio`, `document` |

`audio` is a voice note. Upload it the same way, wait for `ready`, then attach the `mediaUuid` to a chat message.

## See also

* [MCP custom tools](/docs/mcp-server/custom-tools)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.