# 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" ```