API documentation
Upload a file once, send it to the file hosts you pick, and get one share link back — from your own code.
The MirrorUploadX API is a versioned REST API. You upload a file in chunks, start a distribution to the hosts you choose, and every copy is gathered on one share page with live status. All requests go to https://api.mirroruploadx.com and every path starts with /api/v1.
This page walks through the common flow. Every endpoint, field and response is in the full API reference (Swagger), and the machine-readable OpenAPI schema works with any client generator.
Authentication
Create a key on the API keys page of your dashboard. The full key (it starts with mux_) is shown only once, so store it right away. Send it in the x-api-key header on every request; Authorization: Bearer mux_… is accepted the same way for clients that only speak bearer tokens.
Keys are scoped: a key only reaches the endpoints its scopes cover, and a call with the wrong scope returns 403 INSUFFICIENT_SCOPE with the needed scope in the message. Account settings, key management and webhooks are not reachable with an API key at all (API_KEY_NOT_ALLOWED).
| Scope | Reaches |
|---|---|
upload:write | Upload sessions (create, chunks, complete, cancel), your upload quota, remote URL uploads, starting distribution |
file:read | Listing files, file details, mirror and job status |
file:write | Share settings on a file |
file:delete | Deleting a file and its mirrors |
Upload a file and distribute it
- Open a session with
POST /api/v1/upload-sessions.filenameandsizeBytes(the exact byte length) are required;mimeTypeand a UUIDidempotencyKeyare optional — repeating a request with the same key returns the existing session instead of opening a second one. The reply carriesdata.sessionId(notid) anddata.chunkSize. - Send the bytes with
PATCH /api/v1/upload-sessions/{id}/chunks: raw bytes, content typeapplication/offset+octet-stream, and the chunk's start position in theupload-offsetheader. Chunks go in order — usedata.uploadedBytesfrom each reply as the next offset. Send chunks ofchunkSizebytes; every chunk except the last must be at least 5 MiB, and an upload can have at most 10 000 chunks. The final chunk's reply carriesdata.fileId. - Distribute with
POST /api/v1/mirror-tasks/start:fileIdandproviderSlugsare required (slugs come fromGET /api/v1/providers). Optional:minSuccess(default 1 — how many hosts must accept the file for the job to count as completed),sharePassword,expiresInDaysorexpiresAt. The reply hasuploadJobId,taskCount,shareSlug,alreadyStartedandskipped[]— a host that cannot take this file is skipped, not failed, so readskipped[]. One file can go to at most 50 hosts right now. - Watch it finish with
GET /api/v1/mirror-tasks/{uploadJobId}every few seconds untildata.terminalistrue. The reply lists every host intasks[](providerSlug,status,mirrorUrl,errorCode) and the share link indata.shareUrl.
The largest file accepted right now is 5.00 GB.
set -euo pipefail
API=https://api.mirroruploadx.com
FILE=clip.mp4
SIZE=$(wc -c < "$FILE" | tr -d ' ')
# 1. Open an upload session
SESSION_JSON=$(curl -sS --fail-with-body -X POST "$API/api/v1/upload-sessions" \
-H "x-api-key: $MUX_API_KEY" -H "content-type: application/json" \
-d '{"filename":"clip.mp4","sizeBytes":'"$SIZE"',"mimeType":"video/mp4"}')
SESSION=$(echo "$SESSION_JSON" | jq -r .data.sessionId)
CHUNK=$(echo "$SESSION_JSON" | jq -r .data.chunkSize)
# 2. Send the bytes in order, one chunk per request
OFFSET=0
while [ "$OFFSET" -lt "$SIZE" ]; do
dd if="$FILE" of=part.bin bs="$CHUNK" skip=$((OFFSET / CHUNK)) count=1 status=none
REPLY=$(curl -sS --fail-with-body -X PATCH "$API/api/v1/upload-sessions/$SESSION/chunks" \
-H "x-api-key: $MUX_API_KEY" \
-H "upload-offset: $OFFSET" \
-H "content-type: application/offset+octet-stream" \
--data-binary @part.bin)
OFFSET=$(echo "$REPLY" | jq -r .data.uploadedBytes)
done
FILE_ID=$(echo "$REPLY" | jq -r .data.fileId)
# 3. Distribute to the hosts you pick
JOB=$(curl -sS --fail-with-body -X POST "$API/api/v1/mirror-tasks/start" \
-H "x-api-key: $MUX_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"'"$FILE_ID"'","providerSlugs":["gofile","pixeldrain"],"minSuccess":1}' \
| jq -r .data.uploadJobId)
# 4. Poll until the job is terminal, then print the share link
until curl -sS --fail-with-body "$API/api/v1/mirror-tasks/$JOB" \
-H "x-api-key: $MUX_API_KEY" > job.json \
&& [ "$(jq -r .data.terminal job.json)" = "true" ]; do
sleep 5
done
jq -r '.data.status, .data.shareUrl' job.jsonimport os, time, requests
API = "https://api.mirroruploadx.com"
H = {"x-api-key": os.environ["MUX_API_KEY"]}
def call(method, path, headers=None, **kw):
r = requests.request(method, API + path, headers={**H, **(headers or {})}, timeout=120, **kw)
body = r.json()
if not body.get("success"):
err = body["error"]
raise RuntimeError(f'{r.status_code} {err["code"]}: {err["message"]}')
return body["data"]
path = "clip.mp4"
size = os.path.getsize(path)
# 1. Open an upload session
s = call("POST", "/api/v1/upload-sessions",
json={"filename": os.path.basename(path), "sizeBytes": size, "mimeType": "video/mp4"})
session, chunk = s["sessionId"], s["chunkSize"]
# 2. Send the bytes in order; the reply says where to continue
offset, last = 0, None
with open(path, "rb") as f:
while offset < size:
f.seek(offset)
last = call("PATCH", f"/api/v1/upload-sessions/{session}/chunks",
headers={"upload-offset": str(offset),
"content-type": "application/offset+octet-stream"},
data=f.read(chunk))
offset = last["uploadedBytes"]
file_id = last["fileId"]
# 3. Distribute
job = call("POST", "/api/v1/mirror-tasks/start",
json={"fileId": file_id, "providerSlugs": ["gofile", "pixeldrain"], "minSuccess": 1})
for sk in job["skipped"]:
print("skipped", sk["slug"], sk["reason"], sk["detail"])
# 4. Poll until terminal
job_id = job["uploadJobId"]
while True:
st = call("GET", f"/api/v1/mirror-tasks/{job_id}")
if st["terminal"]:
break
time.sleep(5)
print(st["status"], st["shareUrl"])
for t in st["tasks"]:
print(t["providerSlug"], t["status"], t["mirrorUrl"] or t["errorCode"])// Node.js 18+ (global fetch), ES module
import { open, stat } from 'node:fs/promises';
import { basename } from 'node:path';
const API = 'https://api.mirroruploadx.com';
const KEY = process.env.MUX_API_KEY;
async function call(method, path, { json, body, headers = {} } = {}) {
const res = await fetch(API + path, {
method,
headers: { 'x-api-key': KEY, ...(json ? { 'content-type': 'application/json' } : {}), ...headers },
body: json ? JSON.stringify(json) : body,
});
const out = await res.json();
if (!out.success) throw new Error(`${res.status} ${out.error.code}: ${out.error.message}`);
return out.data;
}
const path = 'clip.mp4';
const { size } = await stat(path);
// 1. Open an upload session
const { sessionId, chunkSize } = await call('POST', '/api/v1/upload-sessions', {
json: { filename: basename(path), sizeBytes: size, mimeType: 'video/mp4' },
});
// 2. Send the bytes in order; the reply says where to continue
const fh = await open(path);
let offset = 0;
let last;
while (offset < size) {
const buf = Buffer.alloc(Math.min(chunkSize, size - offset));
await fh.read(buf, 0, buf.length, offset);
last = await call('PATCH', `/api/v1/upload-sessions/${sessionId}/chunks`, {
headers: { 'upload-offset': String(offset), 'content-type': 'application/offset+octet-stream' },
body: buf,
});
offset = last.uploadedBytes;
}
await fh.close();
// 3. Distribute
const job = await call('POST', '/api/v1/mirror-tasks/start', {
json: { fileId: last.fileId, providerSlugs: ['gofile', 'pixeldrain'], minSuccess: 1 },
});
for (const s of job.skipped) console.log('skipped', s.slug, s.reason, s.detail);
// 4. Poll until terminal
let st;
do {
await new Promise((r) => setTimeout(r, 5000));
st = await call('GET', `/api/v1/mirror-tasks/${job.uploadJobId}`);
} while (!st.terminal);
console.log(st.status, st.shareUrl);
for (const t of st.tasks) console.log(t.providerSlug, t.status, t.mirrorUrl ?? t.errorCode);Skip the second call
Pass distribution when you open the session and the server starts distributing as soon as the last chunk lands. Check data.distribution.started in the final chunk's reply: when it is false the upload still succeeded, but nothing was queued.
curl -sS -X POST "https://api.mirroruploadx.com/api/v1/upload-sessions" \
-H "x-api-key: $MUX_API_KEY" -H "content-type: application/json" \
-d '{
"filename": "clip.mp4",
"sizeBytes": 10485760,
"mimeType": "video/mp4",
"distribution": { "providerSlugs": ["gofile", "pixeldrain"], "minSuccess": 1 }
}'
# The reply to the final chunk then carries data.distribution:
# { "started": true, "uploadJobId": "…", "taskCount": 2, "shareSlug": "…", "skipped": [] }
# or { "started": false, "error": { "code": "…", "message": "…" } }If the final reply is lost
Call GET /api/v1/upload-sessions/{id}: a fileId means the file exists; finalizable: true means every byte arrived and POST /api/v1/upload-sessions/{id}/complete finishes it. That call is safe to retry and never creates a second file. A mismatched offset gets 409 CHUNK_OFFSET_CONFLICT — send HEAD on the session and resume from its Upload-Offset header. Calling start again for a running job is not an error: you get 200 with alreadyStarted: true.
Upload from a URL
POST /api/v1/remote-uploads takes { "urls": [...] } and fetches each file for you (scope upload:write). Only http and https links are fetched, private network addresses are refused, and only a limited number of redirects is followed. One request carries at most 10 URLs, and each file can be up to 100.0 MB — both are returned by GET /api/v1/platform; split longer lists.
The request waits while the files download. If the connection drops first, the file still lands in your account — find it with GET /api/v1/files. Each fileId is then distributed with POST /api/v1/mirror-tasks/start, as above.
curl -sS -X POST "https://api.mirroruploadx.com/api/v1/remote-uploads" \
-H "x-api-key: $MUX_API_KEY" -H "content-type: application/json" \
-d '{"urls":["https://example.com/files/archive.zip"]}'
# One result per URL, in order. A failed URL does not fail the batch:
# {"success":true,"data":{"results":[
# {"url":"https://example.com/files/archive.zip","ok":true,
# "fileId":"…","scan":…,"distributable":true}
# ]}}
# or {"url":"…","ok":false,"code":"REMOTE_TOO_LARGE","error":"…"}
#
# Then distribute each fileId with POST /api/v1/mirror-tasks/start.Webhooks
Add an endpoint on the Webhooks page. The URL must use https://; its signing secret (whsec_…) is shown once, when you create it. An endpoint with no events selected receives all of them.
| Event | When |
|---|---|
upload.completed | Every selected host accepted the file |
upload.partially_completed | At least minSuccess hosts accepted the file, some did not |
upload.failed | Fewer hosts than minSuccess accepted the file |
upload.quarantined | The malware scan flagged the file (only when scanning is enabled) |
provider.upload_succeeded | One host accepted the file — sent once per host |
provider.upload_failed | One host gave up on the file — sent once per host |
webhook.test | You pressed Test on the Webhooks page |
Each delivery is one POST with a JSON body and the headers X-MUX-Event and X-MUX-Signature. Any 2xx answer within 10 seconds counts as delivered; redirects are not followed. A failed delivery is not retried — keep your own log, and use GET /api/v1/mirror-tasks/{uploadJobId} to catch up. After 15 failed deliveries in a row the endpoint is paused; turning it back on resets the count.
{
"event": "provider.upload_succeeded",
"createdAt": "2026-09-25T12:00:00.000Z",
"data": {
"uploadJobId": "…",
"fileId": "…",
"provider": "gofile",
"providerName": "Gofile",
"status": "succeeded",
"mirrorUrl": "https://…"
}
}upload.* events carry uploadJobId, fileId and status; provider.upload_failed carries errorCode instead of mirrorUrl.
Verifying the signature
The header looks like t=1727265600,v1=5f2b…: t is the signing time in Unix seconds, v1 is the hex HMAC-SHA256 of <t>.<raw body> keyed with your endpoint secret. Hash the raw bytes exactly as received, compare in constant time, and reject timestamps more than 5 minutes old.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // too old: replay
const expected = createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest();
const got = Buffer.from(parts.v1, 'hex');
return got.length === expected.length && timingSafeEqual(got, expected);
}
const app = express();
// Raw bytes, not parsed JSON: the signature covers the body exactly as sent.
app.post('/hooks/mirroruploadx', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('x-mux-signature') ?? '', process.env.MUX_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body);
console.log(event, data.uploadJobId);
res.sendStatus(204); // any 2xx counts as delivered
});
app.listen(3000);import hashlib, hmac, os, time
from flask import Flask, abort, request
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/hooks/mirroruploadx")
def hook():
# get_data(): the raw bytes, exactly as signed
if not verify(request.get_data(), request.headers.get("X-MUX-Signature", ""),
os.environ["MUX_WEBHOOK_SECRET"]):
abort(401)
payload = request.get_json()
print(payload["event"], payload["data"].get("uploadJobId"))
return "", 204 # any 2xx counts as deliveredRate limits
Limits are counted per API key, not per IP address. Every response on a limited endpoint carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (epoch seconds). Going over returns 429 RATE_LIMITED with a Retry-After header — wait that many seconds. Chunk uploads get a wide budget; the narrow limits are on opening sessions and starting distributions.
The exact numbers for your keys, and how much of each you have used right now, are on the API keys page. Which endpoints can return 429 is listed in the API reference.
Errors
Successful replies are { "success": true, "data": … }. Errors use one shape, next to the HTTP status. code is stable — branch on it; message is for people and may change. details is added when there is more context, and requestId helps us find your request if you contact support.
{
"success": false,
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This endpoint needs one of these API key scopes: upload:write.",
"requestId": "…",
"retryable": false
}
}| Code | HTTP | Meaning |
|---|---|---|
NO_TOKEN | 401 | No API key was sent |
INVALID_API_KEY | 401 | The key is wrong or revoked |
INSUFFICIENT_SCOPE | 403 | The key lacks the scope this endpoint needs |
API_KEY_NOT_ALLOWED | 403 | This endpoint cannot be used with an API key |
VALIDATION_ERROR | 400 | The body or a parameter is invalid; see details |
TOO_MANY_MIRRORS | 400 | More hosts than one file may have |
QUOTA_EXCEEDED | 409 | A daily or concurrent upload limit; see details |
CHUNK_OFFSET_CONFLICT | 409 | upload-offset does not match the bytes received |
CHUNK_TOO_SMALL | 409 | A chunk other than the last is under 5 MiB |
RATE_LIMITED | 429 | Too many requests; wait for Retry-After |
MAINTENANCE | 503 | New uploads are paused for now; retry after Retry-After |
The API reference lists the codes each endpoint can return.