Multipart uploads for big files: who gets to assemble the parts list?
Four calls move a multi-gigabyte file: create, presign each part, PUT, complete. Complete checks your manifest against what arrived, so a short list is a 409, not a truncated object.
A large file moves in four calls on any S3-compatible backend, Infrai included: open an upload, sign each part, PUT the chunks, then post a manifest of part numbers and ETags to close it. What differs between backends is who is allowed to make those calls from where, and how strict the closing call is about the manifest you hand it.
Start with the part that decides your architecture. POST /v1/storage/bucket/set_cors/{bucket} exists and stores rules — you can read them back on the bucket — but the storage host still answers a real browser preflight with 403 and no Access-Control-* headers, so today the chunk PUTs have to originate from a server, a CLI or a native app rather than a tab. CORS is a browser policy; nothing else in the chain cares.
Open the upload and look at the limits it hands you
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/multipart/create/kb-multipart-video" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"key":"videos/u_1042/clip.mp4","content_type":"video/mp4"}'
{
"ok": true,
"data": {
"upload_id": "1785027538752eb227f42a6499760ef0276914f15ef5bb3c1a2d5cd362dc3e94",
"bucket_id": "bkt_e9f087f2f0f14b6ba50333",
"key": "videos/u_1042/clip.mp4",
"started_at": "2026-07-26T00:58:58.941245Z",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
5 MiB minimum per part, 10,000 parts maximum — the S3 numbers, which is what “S3-compatible” is supposed to mean. One nuance to design around: part_size_min is advisory, a sizing recommendation rather than a rejection rule. An upload made entirely of 1 MiB parts still assembled in our testing, so the ceiling on part count is yours to respect; the server will happily let a chatty client burn its 10,000 slots.
Each part gets its own signed URL, valid for an hour:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/multipart/presign_part/${UPLOAD_ID}/1" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
Pushing the chunks from Node 22
This reads a file from disk, signs each part on demand, and keeps the ETag the storage layer returns. Nothing here needs an SDK.
import { open, stat } from "node:fs/promises";
const API = "https://api.infrai.cc";
const PART_SIZE = 8 * 1024 * 1024;
export async function pushParts(uploadId, filePath) {
const { size } = await stat(filePath);
const handle = await open(filePath, "r");
const manifest = [];
try {
for (let partNumber = 1, offset = 0; offset < size; partNumber++, offset += PART_SIZE) {
const length = Math.min(PART_SIZE, size - offset);
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, offset);
const signed = await fetch(`${API}/v1/storage/multipart/presign_part/${uploadId}/${partNumber}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!signed.ok) throw new Error(`presign part ${partNumber}: ${signed.status}`);
const { data } = await signed.json();
const put = await fetch(data.url, { method: "PUT", body: buffer });
if (!put.ok) throw new Error(`part ${partNumber} rejected: ${put.status}`);
manifest.push({ part_number: partNumber, etag: (put.headers.get("etag") ?? "").replace(/"/g, "") });
}
} finally {
await handle.close();
}
return manifest;
}
Quoted ETags are accepted, so stripping the quotes is optional — we strip them because the manifest is easier to eyeball in a log.
The closing call checks the manifest against what arrived
This is the step that decides whether you ship a broken object, and on Infrai it is a real comparison rather than a formality. Post a list that omits a part you actually uploaded, add a part number that was never signed, repeat one twice, or quote an ETag that doesn’t match the stored chunk, and the assemble is refused with 409 STORAGE_MULTIPART_INCONSISTENT and a message naming which of those it found. An upload_id the service has never seen answers 409 as well.
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/multipart/complete/${UPLOAD_ID}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"parts":[{"part_number":1,"etag":"2278358e4765e1fc16bad6efb3c1db37"}]}'
That closes the classic “the ZIP downloads but won’t open” failure at the source: a retry loop that gave up on part 7 can no longer produce a valid-looking object missing its tail. What it does instead is fail loudly at the last call, which is the moment your code has the least context — so keep the parts list server-side anyway. Your backend signs part N, records that it signed part N, and builds the manifest from its own records; then a 409 tells you which chunk to re-push rather than sending you back to the top of a four-gigabyte upload.
const API = "https://api.infrai.cc";
export async function completeUpload(uploadId, manifest, expectedParts) {
if (manifest.length !== expectedParts) {
throw new Error(`refusing to complete: have ${manifest.length} of ${expectedParts} parts`);
}
const res = await fetch(`${API}/v1/storage/multipart/complete/${uploadId}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ parts: manifest }),
});
const payload = await res.json();
if (!res.ok) {
const code = payload?.error?.code ?? "UNKNOWN";
const detail = payload?.error?.message ?? "";
if (code === "STORAGE_MULTIPART_INCONSISTENT") {
throw new Error(`manifest rejected, repair the parts list before retrying: ${detail}`);
}
throw new Error(`complete failed ${code}: ${detail}`);
}
return payload.data;
}
Branching on the code rather than on the status class is what makes that function safe to put behind a retry wrapper. A 409 is a statement about your payload, so waiting and re-sending the identical list will fail identically; the message tells you whether a part is short, extra, duplicated or hashed differently, and each of those has a different repair. Transport-level 5xx around the same call is the case where a plain retry is right.
When you give up, close the upload out — abandoned multipart uploads keep their parts around:
curl -sS -X DELETE \
"https://api.infrai.cc/v1/storage/multipart/abort/${UPLOAD_ID}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Then verify what actually landed, which costs nothing:
curl -sS -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-multipart-video/videos/u_1042/clip.mp4" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
An ETag ending in -1 tells you the object was assembled from a single part. If you expected six, that suffix is your alarm — a cheap post-condition even now that complete does the counting for you.
Three shapes, and when each is right
| Shape | Browser can drive it | Bytes through your server | Good for |
|---|---|---|---|
| Presigned multipart, client-driven | On S3 or Cloudflare R2, yes — Infrai, no | None | Multi-GB media from a web app you can’t proxy |
| Presigned multipart, server-driven | n/a | All of them | CI artefacts, transcoder output, backups, anything server-side |
| Single presigned PUT | Same CORS rule applies | None | Anything under ~100 MB; the simplest thing that works |
A tab pushing a 4 GB video with pause and resume is not a good fit for this surface, and that’s worth saying plainly rather than working around. Use S3 or R2 directly and configure the bucket CORS rules — that’s what they’re for, and pretending otherwise wastes an afternoon. Buy R2 specifically if egress volume is your dominant cost and you want it to be zero.
If the big file is produced by your own infrastructure, the server-driven path is the one to build, and the step after complete is already on the same key: POST /v1/queue/publish hands the object key to a transcode worker, POST /v1/email/send tells the user it’s ready, and POST /v1/logs/ingest keeps the trail — no second account, no second vendor, no second bill to reconcile at the end of the month.
Cost of the four calls
Opening an upload, signing parts and aborting are free and rate-limited. Two steps meter. PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} bills $0.0001 per call, and POST /v1/storage/multipart/complete/{upload_id} bills $0.0002 per assemble — both as of 26 July 2026.
Reading the finished object back is the line that behaves differently, because it is priced by volume rather than by request. GET /v1/storage/object/get/{bucket}/{key} meters at $0.104 per GB, so on a multi-gigabyte asset the lever is which rendition you serve — a 4 MB preview instead of the 4 GB master is three orders of magnitude off the bill, and calling it ten times changes nothing that matters. Counting downloads is the wrong instinct here; counting bytes is the right one. Check today’s rates rather than trusting a page:
curl -sS -X GET "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'), c['billing'].get('unit', '')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Print the unit alongside the number, as that snippet does. A rate can move without changing shape, but a unit moving from per-call to per-GB rewrites your cost model, and only the second column tells you which happened.
Bigger parts mean fewer billable calls and a coarser retry unit; smaller parts retry cheaply and cost more calls. New accounts start with $2 of credit to work that out on.