# Multi Upload Tool: API Documentation
> The ultimate multi-platform video publishing tool. Upload videos to TikTok, YouTube Shorts, Instagram Reels, Facebook, LinkedIn, Pinterest, Bluesky, Threads, and X from one unified API.
## Base URL
`https://api.multi-upload-tool.com/api/v1`
## Authentication
All API requests require an API token in the `x-api-key` header.
```bash
curl -H "x-api-key: kmb_your_api_token_here" https://api.multi-upload-tool.com/api/v1/accounts
```
---
## 1. Accounts
### List Connected Accounts
Get a list of all accounts connected associated with your token.
**Endpoint**: `GET /accounts`
**Query Parameters**:
- `platform` (optional): Filter (tiktok, youtube, instagram, facebook, linkedin, pinterest, bluesky, threads, x)
- `status` (optional): Filter (active, disconnected, error)
- `tags` (optional): Filter by user tags
**Response**:
```json
{
"success": true,
"data": [
{
"id": 101,
"platform": "tiktok",
"accountName": "My TikTok Brand",
"status": "active"
}
]
}
```
### Get Account Details
**Endpoint**: `GET /accounts/:id`
### Disconnect Account
**Endpoint**: `DELETE /accounts/:id`
---
## 2. Upload Operations
### Single Upload
Upload a video or image to a **single** account. The platform is automatically determined by the `accountId`.
**Endpoint**: `POST /upload`
**Content-Type**: `multipart/form-data`
#### Common Parameters
- `accountId` (Required): The internal ID of the connected account (from `GET /accounts`).
- `video` OR `photo`(Required): The media file to upload. Either a file part, or a publicly reachable URL string in the same field.
- A file part must stay under **100MB**: the request body is capped at that size by our edge and a bigger one is rejected with `413` before it reaches the API. This is an edge limit, not a platform limit. For larger media, presign the upload (see "Large Files" below) and send the returned URL in this same field.
- `title` (Optional): Video title or main text.
- `description` (Optional): Caption, description, or post body.
- `async` (Query Param): Set `?async=false` to wait for upload completion (default is true/async for large files).
#### Platform-Specific Parameters
**TikTok**
- `schedule_date`: ISO 8601 Date (e.g. `2026-10-25T14:00:00Z`). Must be at least 15 min in future.
- `privacy_level`: `PUBLIC_TO_EVERYONE` (default), `MUTUAL_FOLLOW_FRIENDS`, `SELF_ONLY`, `FOLLOWER_OF_CREATOR`.
- `disable_comment`: `true` / `false`
- `disable_duet`: `true` / `false`
- `disable_stitch`: `true` / `false`
- `brand_content_toggle`: `true` / `false` (Discloses ad content)
- `brand_organic_toggle`: `true` / `false`
- `is_aigc`: `true` / `false` (AI Generated Content label)
**YouTube / Shorts**
- `title` / `youtube_title`: Video title (Required).
- `description` / `youtube_description`: Video description.
- `tags`: Comma-separated string or JSON array of tags.
- `privacyStatus`: `public`, `private`, `unlisted`.
- `madeForKids`: `true` / `false`.
- `categoryId`: YouTube numeric category ID (e.g., "24" for Entertainment).
- `scheduledDate`: ISO Date for scheduling.
- `thumbnail`: Custom thumbnail file.
**Instagram (Reels, Posts, Stories)**
- `caption` / `description`: Post caption.
- `media_type`: `VIDEO` (Reels), `IMAGE` (Post), `CAROUSEL`, `STORIES`. Auto-detected if omitted.
- `cover_url`: URL for custom cover image.
- `share_to_feed`: `true` / `false` (for Reels).
- `trial_reel`: `manual` / `auto`: publish as a Trial Reel, shown to non-followers first. Single-video Reels only; refused on a photo, carousel or story.
- `user_tags`: Tagged users JSON.
- `location_id`: Instagram Location ID.
- `schedule_date`: ISO Date.
Stories (`media_type=STORIES`; `instagram_as_story=true` on `/upload/bulk`, since `media_type`
there applies to every platform of the batch): exactly one media — a JPEG up to 8 MB (PNG is
refused) or an MP4/MOV of 3 to 60 seconds up to 100 MB. `collaborators` and `location_id` are
refused with a `400`. The caption is dropped (stories have none) and the AI label is not sent
(the API has no such field on stories). A story expires after 24 hours and has no permanent URL
and no analytics. Format, size and duration are checked once the file is fetched, and a story
that breaks them fails with the reason, without retrying.
**Facebook**
- `description`: Post text.
- `link`: URL to attach (for Link posts).
- `media_type`: `VIDEO`, `IMAGE`.
**LinkedIn**
- `title`: Post title (optional for text/images).
- `description`: Post text.
- `visibility`: `PUBLIC` (default) or `CONNECTIONS`.
- `media_type`: `VIDEO`, `IMAGE`, `DOCUMENT` (PDFs).
**Pinterest**
- `board_id` / `pinterest_board_id`: (Required) ID of the board to pin to.
- `link`: Destination URL.
- `alt_text`: Accessibility text.
**Bluesky**
- `description`: Post text.
- `bluesky_langs`: Array of language codes (e.g. `["en"]`).
- `bluesky_labels`: Content labels.
**X (Twitter)**
- `description`: Post text. 280 characters (25,000 with `x_premium`).
- Media: 1 video (MP4, MOV, WebM, up to 1 GB, 20 min; 125 min with `x_premium`) or up to 4 images (5 MB, GIF 15 MB). Text-only posts work.
- `x_reply_settings`: `everyone` (default), `following`, `mentionedUsers`, `subscribers`, `verified`.
- `x_poll_options` (2-4 choices, 25 chars each) + `x_poll_duration_minutes` (5-10080, default 1440). Text-only: X refuses a poll with media.
- `x_alt_text`, `x_tagged_users` (up to 10 @usernames): images only.
- `x_subtitles_url` (SRT/VTT, 1 MB max) + `x_subtitles_language` (2-letter code): video only.
- `x_community_id` + `x_share_with_followers`, `x_super_followers_only`, `x_made_with_ai`, `x_paid_partnership`, `x_premium`.
- Unknown `x_*` fields are refused with a `400`.
- Billing: X charges per API post, so X posts cost credits: 1 per post, 15 when the text contains a link. Plans include 50 (Basic), 150 (Pro) or 300 (Business, Agency, Scale) per month; packs never expire. `GET /x-credits` returns the balance.
- Duplicate content: the same text or exact same file on another of your X accounts within 30 days is refused (X bans cross-account duplicates); a bulk upload may target one X account.
---
### Large Files (over 100MB)
Presigned uploads send the bytes straight to storage, bypassing the 100MB cap on the `POST /upload` request body. Files under 100MB do not need this: keep posting them as a file part exactly as before.
**Endpoint**: `POST /upload/presign`
**Content-Type**: `application/json` (this endpoint is JSON, unlike the multipart upload endpoints)
#### Phase 1: presign
```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" }]
}'
```
- `route` (Required): the validation preset. `youtube_videos` (10 GiB), `tiktok_videos` (4 GiB), `pinterest_videos` (2 GiB), `threads_videos` (1 GiB), `linkedin_videos` (500 MiB) are multipart; `documents` (100 MiB, 1 file), `photos` (20 MiB, 35 files), `thumbnails` (20 MiB, 1 file), `instagram_photos` (8 MiB, 10 files), `threads_photos` (8 MiB, 20 files), `bluesky_photos` (2,000,000 bytes, 4 files) are single-request. The route caps size, count and MIME type; it does not tie the media to a platform, and the platform's own limits still apply when you publish.
- `files` (Required): `name`, `type` (MIME), and `size` = the **exact** byte length. The size is signed into the URLs, and `0` is rejected.
Response (multipart route, abridged):
```json
{
"success": true,
"data": {
"route": "tiktok_videos",
"partSize": 134217728,
"expiresAt": "2026-08-26T18:12:44.000Z",
"usage": { "windowDays": 30, "used": 12884901888, "limit": 107374182400, "remaining": 94489280512, "unlimited": false },
"files": [
{
"name": "clip.mp4",
"size": 734003200,
"contentType": "video/mp4",
"key": "public-api/42/1756226400000-a1b2c3d4-clip.mp4",
"bucketUrl": "https://multi-upload-tools.s3.eu-central-003.backblazeb2.com/public-api/42/1756226400000-a1b2c3d4-clip.mp4",
"accessUrl": "https://api.multi-upload-tool.com/api/public/image-proxy/public-api%2F42%2F1756226400000-a1b2c3d4-clip.mp4",
"uploadUrl": null,
"uploadHeaders": null,
"multipart": {
"uploadId": "4_z27c88f1d182b150597...",
"partSize": 134217728,
"completeUrl": "https://...&X-Amz-Signature=...",
"abortUrl": "https://...&X-Amz-Signature=...",
"parts": [
{ "partNumber": 1, "size": 134217728, "uploadUrl": "https://...&partNumber=1&X-Amz-Signature=..." },
{ "partNumber": 6, "size": 63963136, "uploadUrl": "https://...&partNumber=6&X-Amz-Signature=..." }
]
}
}
]
}
}
```
Single-request routes return `multipart: null` and instead give `uploadUrl` plus `uploadHeaders`:
```json
{
"name": "slide1.jpg", "size": 204800, "contentType": "image/jpeg",
"key": "public-api/42/1756226400000-9f0e1a2b-slide1.jpg",
"bucketUrl": "https://multi-upload-tools.s3.eu-central-003.backblazeb2.com/public-api/42/1756226400000-9f0e1a2b-slide1.jpg",
"accessUrl": "https://api.multi-upload-tool.com/api/public/image-proxy/public-api%2F42%2F1756226400000-9f0e1a2b-slide1.jpg",
"uploadUrl": "https://...&X-Amz-Signature=...",
"uploadHeaders": { "content-type": "image/jpeg", "content-length": "204800" },
"multipart": null
}
```
#### Phase 2: upload the bytes
Single-request routes - `PUT` the whole file to `uploadUrl`, sending exactly the headers from `uploadHeaders`. Both `content-type` and `content-length` are part of the signature; changing or dropping either returns `SignatureDoesNotMatch`. Valid 1 hour.
```bash
curl -X PUT "$UPLOAD_URL" \
-H "content-type: image/jpeg" \
-H "content-length: 204800" \
--data-binary @slide1.jpg
```
Multipart routes - for each entry in `multipart.parts`, `PUT` the byte range starting at `(partNumber - 1) * partSize`, `size` bytes long, with `Content-Length` equal to that part's `size`. Keep every response's `ETag` header and strip the quotes. Parts can go in parallel, and a failed part can be retried by re-`PUT`ting the same URL.
```bash
# part 1 of a 700MB file, partSize 134217728
dd if=clip.mp4 bs=134217728 skip=0 count=1 of=part1.bin
curl -D - -X PUT "$PART_1_URL" -H "Content-Length: 134217728" --data-binary @part1.bin
# -> ETag: "9f2c..." (keep it, without the quotes)
```
Then finish the upload by `POST`ing the XML manifest to `completeUrl`, parts sorted ascending:
```bash
curl -X POST "$COMPLETE_URL" \
-H "Content-Type: application/xml" \
-d '9f2c...1a71b...2'
```
If you abandon the upload, `curl -X DELETE "$ABORT_URL"`. 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 - upload parts concurrently rather than serially, and presign again if the window lapses.
#### Phase 3: publish
Each file comes back with two URLs for the same object. Pass one of them as the `video`, `photo` or `file` field of `POST /upload` (or `POST /upload/bulk`), together with the usual parameters:
```bash
curl -X POST "https://api.multi-upload-tool.com/api/v1/upload" \
-H "x-api-key: kmb_YOUR_TOKEN" \
-F "accountId=cmf3k9x2a0001l408q7vbn2hd" \
-F "video=https://multi-upload-tools.s3.eu-central-003.backblazeb2.com/public-api/42/1756226400000-a1b2c3d4-clip.mp4" \
-F "title=My Video"
```
- `bucketUrl` - use it for large video, as the dashboard itself does. The API reads the object straight from storage, so a multi-gigabyte file is never streamed back through the 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 for photos, and whenever you also need a URL anyone can fetch. 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 copy rehosted on your own domain is rejected by those platforms.
#### Errors
Standard envelope plus a machine-readable `code`: `{ "success": false, "error": "...", "code": "..." }`.
| Status | code | When |
|--------|------|------|
| 400 | `INVALID_JSON` | Body is not parseable JSON |
| 400 | `INVALID_REQUEST` | Missing/zero/non-integer `size`, or an empty `files` array |
| 404 | `UNKNOWN_ROUTE` | Unknown `route`; the body lists `validRoutes` |
| 400 | `TOO_MANY_FILES` / `FILE_TOO_LARGE` / `INVALID_FILE_TYPE` / `REJECTED` | Rejected by the route; the body echoes its `limits` |
| 403 | `STORAGE_QUOTA_EXCEEDED` | Rolling storage allowance exhausted; the body carries `usage` |
| 413 | `BODY_TOO_LARGE` | Request body over 256 KB; this endpoint declares files, it does not carry them |
| 429 | - | More than 120 presign requests / 15 min for this user (or 600 from this IP); `retryAfter` in seconds |
| 503 | - | Rate limiter or quota counter unreachable; nothing was issued or charged, retry |
#### Quota and rate limit
Presigning is metered **per user**, not per IP, so workers sharing one egress address are not throttled as a single client. The limit is 120 requests per 15 minutes, with a much looser per-IP backstop of 600 per 15 minutes. The body itself is capped at 256 KB: it only declares files, so anything bigger is refused with `413` before it is read.
Declared bytes count against a rolling 30-day allowance that depends on your plan. Every successful response carries `usage` (used - including the current request - allowance, remaining), so you can check headroom before failing. The debit happens when the URLs are issued 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`.
---
### Bulk Upload
Upload the same content to **multiple** accounts simultaneously.
**Endpoint**: `POST /upload/bulk`
**Parameters**:
- `accounts` (Required): JSON String array of account IDs. Example: `"[101, 102, 105]"`.
- All other parameters are the same as `POST /upload` (title, description, file, etc.).
---
## 3. History & Limits
### List Uploads
See history of previous uploads.
**Endpoint**: `GET /upload`
**Query Params**:
- `page`: Page number (default 1).
- `limit`: Items per page (default 20).
### Get Upload Details
**Endpoint**: `GET /upload/:id`
### Check Limits
Check daily posting limits for an account.
**Endpoint**: `GET /limits/:accountId`
---
## 4. Analytics
Views, likes, comments and shares for every connected account, in one shape whatever the
platform underneath. Requires a plan that includes analytics; otherwise every endpoint here
returns `403`.
The numbers are **not** read from the platform when you ask. A background collector refreshes
each account once a night (03:00 UTC, with a deeper crawl on Sundays) and these endpoints serve
that store — which is what lets one request answer for every account at once, and what makes
follower history possible at all. Every response carries `lastRefreshedAt`: show it rather than
presenting the numbers as live.
A metric a platform does not expose comes back as `0`, not omitted. YouTube reports no shares,
Pinterest no likes or comments, Bluesky no views, and LinkedIn personal profiles report nothing
at all (organisation Pages do).
### Global Analytics
**Endpoint**: `GET /analytics/overview`
**Query Params**:
- `tags`: comma-separated account tags, to narrow the aggregate.
- `timezone`: IANA zone, e.g. `Europe/Paris`. The zone the `postingInsights` weekday and hour
buckets are read in; defaults to `UTC`. Always send the user's own — an hour-of-day ranking
read in the wrong zone is off by their whole offset, silently, because every bucket still
holds a plausible number.
Returns `overview` (totalViews, totalLikes, totalComments, totalShares, videoCount, accountCount,
bestAccount), `videos`, `bestPerforming`, `topHashtags`, `dailyHistory`, `platformHistory`,
`lastRefreshedAt`, `neverSynced` and `syncErrors`.
`neverSynced: true` means nothing has ever been collected, as opposed to collected and empty —
"connect the account and wait for tonight" is a different message from "you have no posts yet".
`postingInsights` answers when to publish: `byWeekday` (7 entries, 0 = Sunday), `byHour` (24
entries), `bestDay` and `bestHours` (up to 5). A bucket with fewer than `minSamples` posts comes
back with `ranked: false` — it keeps its average, but it can never win. One lucky post is a
coincidence, not a slot. With nothing over the floor, `bestDay` is `null` and `bestHours` empty.
### Account Analytics
**Endpoint**: `GET /analytics/:connectedAccountId`
Same shape, one account, plus that account's tags.
### Force a Sync
**Endpoint**: `POST /analytics/:connectedAccountId/sync`
Reads the platform inside the request, up to the 1000 most recent posts, then returns the
refreshed analytics. This is a write — it rotates OAuth tokens, stores snapshots and consumes the
platform's rate limit — so it needs manage access to the account, not read access.
### LinkedIn Post Comments
**Endpoint**: `GET /analytics/:connectedAccountId/linkedin/comments?postId=`
Lists who commented on one LinkedIn post. LinkedIn only; any other platform returns `400`.
Read-only — there is no endpoint to reply to a comment.
---
## Examples
### cURL: Upload to TikTok
```bash
curl -X POST "https://api.multi-upload-tool.com/api/v1/upload" \
-H "x-api-key: kmb_YOUR_TOKEN" \
-F "accountId=cmf3k9x2a0001l408q7vbn2hd" \
-F "video=@/path/to/funny_cat.mp4" \
-F "title=Funny Cat Video" \
-F "privacy_level=PUBLIC_TO_EVERYONE" \
-F "disable_duet=false"
```
### cURL: Bulk Upload (YouTube + Instagram)
```bash
curl -X POST "https://api.multi-upload-tool.com/api/v1/upload/bulk" \
-H "x-api-key: kmb_YOUR_TOKEN" \
-F 'accounts=["cmf3k9x2a0001l408q7vbn2hd", "cmf3kb7re0004l408zt2m5wxc"]' \
-F "video=@/path/to/vlog.mp4" \
-F "title=My Morning Routine" \
-F "description=Check out my new morning routine! #vlog #morning" \
-F "privacyStatus=public" \
-F "share_to_feed=true"
```