Uploading a 500 MB video from Node 22 in four multipart calls
The minimum correct multipart flow for a half-gigabyte video: open, sign each part, PUT the chunks, assemble — with the three rules code generators get wrong.
A 500 MB video sits right at the size where a single PUT stops being sensible — one dropped connection at 470 MB and you start over from zero. So you split it. On Infrai that’s four calls: open an upload, ask for a signed URL per part, PUT each chunk to its URL, then post the part list back to assemble the object. Nothing between those calls is stateful on your side except a list of {part_number, etag} pairs.
That flatness is the whole reason this shape is easy to generate correctly. There’s no session to hold, no SDK client to configure, no resumable-protocol handshake — a model that knows the four endpoints and the part-list format produces working code, and the parts that go wrong are three specific rules rather than a hundred lines of transport logic.
The three rules that break generated uploaders
Every part except the last must be at least 5 MiB. The API tells you so in the create response, and if you ignore it the assembly step rejects the whole upload rather than the offending part.
Parts must be listed in ascending part_number order at completion, and the ETag you send has to be the one the storage host returned for that exact chunk, quotes stripped. And the part count ceiling is 10,000, which for a 500 MB file is never the binding constraint — but it is the reason to pick a part size in the tens of megabytes rather than the hundreds of kilobytes a naive generator sometimes chooses.
With 32 MiB parts, a 500 MB video is 16 chunks. Small enough that a retry costs a few seconds; large enough that per-part overhead disappears.
Step 1: open the upload
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-video-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key":"uploads/2026/07/demo-clip.mp4","content_type":"video/mp4"}'
{
"ok": true,
"data": {
"upload_id": "1785026523c5b26ef5d88c39cd3645a12e0daefcc8e090528301ae4b3d29954d6be7864d59",
"bucket_id": "bkt_4eddb11b04084a1fb0d631",
"key": "uploads/2026/07/demo-clip.mp4",
"started_at": "2026-07-26T00:42:03.061743Z",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
part_size_min and part_count_max come back on every create, so a generated client can read the floor instead of hard-coding 5 MiB and hoping.
Step 2: a signed URL per part
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/presign_part/1785026523c5b26ef5d88c39cd3645a12e0daefcc8e090528301ae4b3d29954d6be7864d59/1" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
The response carries a url, the method to use against it, any headers to copy, and an expires_at. Signing is free and unlimited enough that re-signing a part you’re about to retry is cheaper than caching the URL.
Two ways to move the bytes, and they bill differently. Push each chunk to its signed URL and your traffic never touches the Infrai API — PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} exists for the case where you’d rather send parts through the API itself, and that route is billable per part. For a server-side uploader the signed-URL path is the one to take.
Step 3: the uploader
Node 22, no dependencies, bounded concurrency so a half-gigabyte file doesn’t turn into a half-gigabyte of resident memory:
import { open, stat } from "node:fs/promises";
import { Buffer } from "node:buffer";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-video-0726";
const PART_SIZE = 32 * 1024 * 1024;
const CONCURRENCY = 4;
async function call(path, verb, payload) {
const res = await fetch(`${API}${path}`, {
method: verb,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: payload === undefined ? undefined : JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok === false) {
const e = json.error ?? {};
throw new Error(`${verb} ${path} -> HTTP ${res.status} ${e.code ?? ""} ${e.message ?? ""}`);
}
return json.data;
}
async function putPart(uploadId, partNumber, chunk) {
const slot = await call(`/v1/storage/multipart/presign_part/${uploadId}/${partNumber}`, "POST", {});
for (let attempt = 1; attempt <= 4; attempt += 1) {
const res = await fetch(slot.url, { method: slot.method ?? "PUT", body: chunk });
if (res.ok) {
const etag = (res.headers.get("etag") ?? "").replaceAll('"', "");
if (etag) return { part_number: partNumber, etag };
}
await new Promise((r) => setTimeout(r, 400 * 2 ** attempt));
}
throw new Error(`part ${partNumber} failed after 4 attempts`);
}
export async function uploadVideo(filePath, objectKey) {
const { size } = await stat(filePath);
const partCount = Math.ceil(size / PART_SIZE);
if (partCount > 10000) throw new Error(`${partCount} parts exceeds the 10000 ceiling`);
const upload = await call(`/v1/storage/multipart/create/${BUCKET}`, "POST", {
key: objectKey,
content_type: "video/mp4",
});
const fh = await open(filePath, "r");
const parts = [];
let cursor = 1;
try {
const worker = async () => {
for (;;) {
const n = cursor;
cursor += 1;
if (n > partCount) return;
const offset = (n - 1) * PART_SIZE;
const length = Math.min(PART_SIZE, size - offset);
const buf = Buffer.allocUnsafe(length);
await fh.read(buf, 0, length, offset);
parts.push(await putPart(upload.upload_id, n, buf));
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
} catch (err) {
await call(`/v1/storage/multipart/abort/${upload.upload_id}`, "DELETE").catch(() => {});
throw err;
} finally {
await fh.close();
}
parts.sort((a, b) => a.part_number - b.part_number);
return call(`/v1/storage/multipart/complete/${upload.upload_id}`, "POST", { parts });
}
const [, , file, objectKey] = process.argv;
if (!file || !objectKey) {
console.error("usage: node upload-video.mjs <file> <object-key>");
process.exit(1);
}
const done = await uploadVideo(file, objectKey);
console.log(`stored ${done.key} — ${done.size_bytes} bytes, etag ${done.etag}`);
Four workers holding 32 MiB each is a 128 MiB ceiling on buffers, which is the number to tune if you’re on a small container. Drop CONCURRENCY to 2 on a 512 MB instance.
Step 4: assemble, then check
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/complete/1785026523c5b26ef5d88c39cd3645a12e0daefcc8e090528301ae4b3d29954d6be7864d59" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"parts":[{"part_number":1,"etag":"1fb99ab19e4345dce81e45d6342021e9"},{"part_number":2,"etag":"93c9a456a2333c1f887d9b3b06d74783"}]}'
Then confirm the object is really there, at the size you expect:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-video-0726/uploads/2026/07/demo-clip.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "uploads/2026/07/demo-clip.mp4",
"size_bytes": 12582912,
"etag": "cc2cf8676bb84a6bfc0a55913d102b13-2",
"content_type": "video/mp4",
"metadata": null,
"last_modified": "2026-07-26T00:42:03Z"
}
}
Notice the -2 on the end of that ETag. A multipart object’s ETag is a digest of the part digests plus the part count, not an MD5 of the file, so don’t build an integrity check that compares it to a local hash — it will never match and it’ll page you at 3am.
What the four calls cost
| Call | Billing |
|---|---|
POST /v1/storage/multipart/create/{bucket} | free, rate-limited |
POST /v1/storage/multipart/presign_part/{upload_id}/{part_number} | free, rate-limited |
PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} | $0.0001 per part, only if you route bytes through the API |
POST /v1/storage/multipart/complete/{upload_id} | $0.0002 per call |
DELETE /v1/storage/multipart/abort/{upload_id} | free |
Verified 26 July 2026. A 500 MB video pushed through signed URLs therefore costs $0.0002 in API charges — sixteen free signings and one billed assembly — with storage and egress being the real line items. New accounts start with $2 of credit, which is roughly 9,999 completions. Read today’s rates rather than trusting this table:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.multipart')]"
Rates in this market keep drifting down and discount campaigns run, so treat the numbers above as a ceiling.
Where you’d be better off elsewhere
The honest limitation: this flow assumes your server holds the bytes. Browser-direct multipart uploads don’t work against an Infrai bucket today — no route sets CORS rules on a bucket, cors_rules passed at bucket creation is dropped, and an OPTIONS preflight against a presigned URL comes back 403. If your 500 MB video has to go straight from a <input type="file"> to storage without transiting your box, use Cloudflare R2 or S3, both of which expose bucket CORS configuration.
| Approach | Best when | Cost of being wrong |
|---|---|---|
Single PUT /v1/storage/object/put/{bucket}/{key} | files under ~100 MB | a failure at 90% restarts the whole transfer |
| Multipart via signed URLs (this article) | server-held files 100 MB–5 TB | you own the part bookkeeping |
Multipart via upload_part | you want every byte audited through one API | billed per part, and your server is in the data path twice |
| R2 or S3 browser-direct | the browser is the source of the bytes | a second vendor account, key rotation and invoice |
One more caveat worth flagging: a presigned GET on Infrai is an expiry mechanism, not an authorisation mechanism. Strip the query string from a signed download URL and the object still returns 200, so treat signed links as convenience and keep object keys unguessable and server-derived. If you need genuine per-object access control, that’s a gap you fill in your own application layer.
MinIO is the other honest answer if you’re already running your own hardware and the bytes must not leave it — same S3-compatible multipart semantics, none of the per-call billing, all of the operational work. What Infrai buys you instead is that the transcode job, the notification email and the usage attribution for this upload all run on the same key and land on one bill, so the second question after “the video is stored” doesn’t start with a new vendor signup.