---
title: "Presign a large media upload"
method: POST
path: "/upload/presign"
tags: ["Uploads"]
---

# Presign a large media upload

`POST /upload/presign`

## Request

- Base URL: `https://api.multi-upload-tool.com/api/v1`
- URL: `https://api.multi-upload-tool.com/api/v1/upload/presign`
- Auth: API key in header `x-api-key`

Creates presigned URLs so large media is uploaded straight to object storage instead of through this API.

`POST /upload` accepts the file itself, but its request body is capped at **100 MB by our edge**: a bigger multipart request is rejected with `413` before it ever reaches the API. That ceiling is an edge limit, not a platform one. The real per-media ceilings are the `maxFileSize` of the presign route you choose (up to 10 GiB) and, at publication time, the target platform's own limits.

Nothing changes for files under 100 MB: keep sending them to `POST /upload` as a file part exactly as before. Presign only what is too big for that path.

### Phase 1 - presign

Declare each file's exact byte length; it is signed into the URLs you get back.

```bash
curl -X POST "https://api.multi-upload-tool.com/api/v1/upload/presign" \
  -H "x-api-key: kmb_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"route":"tiktok_videos","files":[{"name":"clip.mp4","size":734003200,"type":"video/mp4"}]}'
```

### Phase 2 - upload the bytes

They travel directly to storage and never touch this API or its edge, which is what removes the 100 MB ceiling.

**Single-request routes** (`photos`, `instagram_photos`, `threads_photos`, `x_photos`, `bluesky_photos`, `thumbnails`, `documents`) return `uploadUrl` and `uploadHeaders`. `PUT` the whole file to `uploadUrl`, sending exactly the headers returned: `content-type` and `content-length` are part of the signature, so changing or omitting either fails the `PUT` with `SignatureDoesNotMatch`. Valid for 1 hour.

**Multipart routes** (`youtube_videos`, `tiktok_videos`, `pinterest_videos`, `threads_videos`, `x_videos`, `linkedin_videos`) return `multipart`. For each entry of `multipart.parts`, `PUT` the byte range starting at `(partNumber - 1) * partSize` and `size` bytes long to that part's `uploadUrl`, with `Content-Length` equal to that part's `size`. Keep each response's `ETag` header, quotes stripped. Parts may be uploaded in parallel, and a single failed part can be retried by re-`PUT`ting the same URL. Once every part is stored, `POST` to `multipart.completeUrl` with `Content-Type: application/xml` and the parts sorted ascending:

```xml
<CompleteMultipartUpload><Part><ETag>abc123</ETag><PartNumber>1</PartNumber></Part><Part><ETag>def456</ETag><PartNumber>2</PartNumber></Part></CompleteMultipartUpload>
```

If you abandon the upload, send `DELETE` to `multipart.abortUrl`: parts left behind are stored and billed and are not cleaned up for you. Part and complete URLs last 6 hours and cannot be refreshed, so upload parts concurrently rather than one after another, and presign again if the window lapses.

### Phase 3 - publish

Each presigned file comes back with two URLs for the same object. Pass one of them as the `video`, `photo` or `file` field of `POST /upload` - those fields already accept a URL string - or of `POST /upload/bulk` to fan the same media out across accounts.

- **`bucketUrl`** - use it for large video. The API reads the object straight from storage, so the file is never streamed back through this API and its edge on the way to the platform. It points at private storage and is not fetchable on its own.
- **`accessUrl`** - use it when you also need a URL anyone can fetch, and for photos. It is served from a domain the platforms have verified, which is what lets TikTok and the other pull-based platforms fetch the media. Pass it through unchanged; a rehosted copy on your own domain is rejected by those platforms.

```bash
curl -X POST "https://api.multi-upload-tool.com/api/v1/upload" \
  -H "x-api-key: kmb_YOUR_TOKEN" \
  -F "accountId=101" \
  -F "video=https://multi-upload-tools.s3.eu-central-003.backblazeb2.com/public-api/42/1756226400000-a1b2c3d4-clip.mp4" \
  -F "title=My Video"
```

### Routes

`route` selects the validation preset applied to the files you declare. It does not tie the media to a platform - any presigned object may be published to any account - but choosing the preset that matches your target rejects an oversized or wrong-typed file here, instead of at publication time where the platform's own limits still apply.

| route | multipart | max files | max size per file | accepted types |
|---|---|---|---|---|
| `youtube_videos` | yes | 1 | 10 GiB | `video/*`, `application/octet-stream` |
| `tiktok_videos` | yes | 1 | 4 GiB | `video/mp4`, `video/webm` |
| `pinterest_videos` | yes | 1 | 2 GiB | `video/mp4`, `video/quicktime`, `video/x-m4v` |
| `threads_videos` | yes | 1 | 1 GiB | `video/mp4`, `video/quicktime` |
| `x_videos` | yes | 1 | 1 GiB | `video/mp4`, `video/quicktime` |
| `linkedin_videos` | yes | 1 | 500 MiB | `video/mp4` |
| `documents` | no | 1 | 100 MiB | PDF, PPT, PPTX, DOC, DOCX |
| `photos` | no | 35 | 20 MiB | `image/jpeg`, `image/png`, `image/webp` |
| `thumbnails` | no | 1 | 20 MiB | `image/jpeg`, `image/png`, `image/webp`, `image/gif` |
| `instagram_photos` | no | 10 | 8 MiB | `image/jpeg`, `image/png`, `image/webp` |
| `threads_photos` | no | 20 | 8 MiB | `image/jpeg`, `image/png` |
| `x_photos` | no | 4 | 5 MiB | `image/jpeg`, `image/png`, `image/webp`, `image/gif` |
| `bluesky_photos` | no | 4 | 2 000 000 bytes | `image/jpeg`, `image/png`, `image/webp`, `image/gif` |

A declared `size` of `0` is rejected: a zero-byte multipart upload can never be completed.

### Quota and rate limit

Presigning is metered per user rather than per IP, so a fleet of workers behind one egress address is not throttled as if it were one client, and one busy token cannot spend another customer's allowance. The limit is **120 requests / 15 min**; exceeding it returns `429` with `retryAfter` in seconds. A far looser per-IP backstop of 600 requests / 15 min also applies, and answers `429` the same way.

The request body only declares files, so it is capped at 256 KB - a bigger one is refused with `413` and `code: BODY_TOO_LARGE` without being read.

The bytes you declare are charged against a rolling 30-day allowance that depends on your plan. Every successful response carries a `usage` block - what is used including this request, the allowance, and what is left - so you can see the headroom without failing first. Exceeding it returns `403` with `code: STORAGE_QUOTA_EXCEEDED`. The debit happens when the URLs are issued, using the sizes you declared, and is not refunded if you never upload the bytes: presigning and walking away spends the allowance. Plans with no configured allowance report `"unlimited": true` and are never blocked.

## Headers

- `X-Team-ID` integer

## Request body

- PresignRequest
  - `route` 'youtube_videos' | 'tiktok_videos' | 'pinterest_videos' | 'threads_videos' | 'x_videos' | 'linkedin_videos' | 'documents' | 'photos' | 'thumbnails' | 'instagram_photos' | 'threads_photos' | 'x_photos' | 'bluesky_photos', required: Validation preset applied to the declared files: it fixes the accepted MIME types, the maximum number of files, the maximum size per file, and whether the upload is multipart. It does not tie the media to a platform.
  - `files` PresignFile[], required
    - `name` string, required: Original file name. Sanitised into the stored object's key; the extension is kept and is what identifies the media type downstream.
    - `size` integer, required: Exact byte length. Zero is rejected, and a value that disagrees with what you upload fails the signed request.
    - `type` string, required: MIME type, checked against the route's accepted types.
  - `metadata` object: Accepted and ignored, so bodies produced by generic presigned-upload clients validate.

## Response `200`

One presigned entry per declared file, plus the caller's current storage usage.

- PresignResponse
  - `success` unknown, required
  - `data` object, required
    - `route` 'youtube_videos' | 'tiktok_videos' | 'pinterest_videos' | 'threads_videos' | 'x_videos' | 'linkedin_videos' | 'documents' | 'photos' | 'thumbnails' | 'instagram_photos' | 'threads_photos' | 'x_photos' | 'bluesky_photos', required: Validation preset applied to the declared files: it fixes the accepted MIME types, the maximum number of files, the maximum size per file, and whether the upload is multipart. It does not tie the media to a platform.
    - `partSize` integer, nullable: Bytes per part on multipart routes, `null` on single-request routes. Repeated inside each file's `multipart` block.
    - `expiresAt` string, date-time: When the returned upload URLs stop being accepted: one hour out for single-request routes, six for multipart ones. There is no refresh; presign again.
    - `usage` StorageUsage: Presigned bytes charged against the caller's rolling allowance.
      - `windowDays` integer: Length of the rolling window, in days.
      - `used` integer: Bytes presigned inside the window, including the current request.
      - `limit` integer: Allowance in bytes for the window. `-1` when the plan has no allowance configured.
      - `remaining` integer: Bytes still available. `-1` when unlimited.
      - `unlimited` boolean: When true the quota never blocks the request.
    - `files` PresignedFile[], required
      - `name` string
      - `size` integer
      - `contentType` string
      - `key` string: Object key the media is stored under.
      - `bucketUrl` string, uri: Preferred for large video: pass it as the `video` field of `POST /upload` or `POST /upload/bulk` and the media is read straight from storage, instead of streaming every gigabyte back through this API. Points at private storage, so it is not fetchable on its own - only this API can read it.
      - `accessUrl` string, uri: The publicly fetchable form of the same object: pass it as the `video`, `photo` or `file` field of `POST /upload` or `POST /upload/bulk`. Served from a platform-verified domain, so pull-based platforms can fetch it. Readable once the bytes are stored. Pass it through unchanged; a rehosted copy on your own domain is rejected by those platforms.
      - `uploadUrl` string, uri, nullable: Single-request routes only: `PUT` the whole file here, with `uploadHeaders`. Valid 1 hour. `null` on multipart routes.
      - `uploadHeaders` object, nullable: Headers to send verbatim with the single `PUT`. They are part of the signature. `null` on multipart routes.
      - `multipart` PresignMultipart: Present on multipart routes. Upload every part, then complete; abort if you give up.
        - `uploadId` string: Storage-side identifier of the multipart upload.
        - `partSize` integer: Bytes per part, except the last one.
        - `completeUrl` string, uri: `POST` the `CompleteMultipartUpload` XML here, `Content-Type: application/xml`, parts sorted ascending. Valid 6 hours.
        - `abortUrl` string, uri: `DELETE` here to discard an abandoned upload. Parts left behind are stored and billed.
        - `parts` PresignPart[]
          - `partNumber` integer: 1-based. Part `n` covers the bytes starting at `(n - 1) * partSize`.
          - `size` integer: Bytes this part must carry, sent as its `Content-Length`. The last part is shorter than `partSize`.
          - `uploadUrl` string, uri: `PUT` the part's bytes here.

## Other responses

- `400`: The body is not valid JSON (`INVALID_JSON`), a declared file is missing an exact non-zero `size` (`INVALID_REQUEST`), or the route rejected the files: `TOO_MANY_FILES`, `FILE_TOO_LARGE`, `INVALID_FILE_TYPE`, `REJECTED`. Failures caused by the route echo its `limits`.
- `401`: The API key is missing, invalid, revoked or expired.
- `403`: The rolling storage allowance would be exceeded (`STORAGE_QUOTA_EXCEEDED`, with a `usage` block), or the request is not permitted inside the requested team.
- `404`: Unknown `route` (`UNKNOWN_ROUTE`). The response lists the accepted names in `validRoutes`.
- `413`: The request body is larger than 256 KB (`BODY_TOO_LARGE`). This endpoint only declares files; the bytes themselves go to the presigned URLs.
- `429`: More than 120 presign requests in 15 minutes for this user, or more than 600 from this IP. `retryAfter` gives the seconds to wait.
- `503`: The rate limiter or the quota counter could not be reached. No URLs were issued and nothing was charged; retry.

---

[API](https://skmtc.dev/multi-upload-tool/apis/multi-upload-tool-api.md) · [All operations](https://skmtc.dev/multi-upload-tool/apis/multi-upload-tool-api/llms.txt) · [OpenAPI document](https://skmtc.dev/multi-upload-tool/apis/multi-upload-tool-api/revisions/bb7e72abbb76?raw)
