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. The one nobody validates is complete — and a short list truncates your 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 much the closing call checks. Both answers surprised us.

Start with the part that decides your architecture: on Infrai the chunk PUTs cannot come from a browser tab, because the bucket answers the cross-origin preflight with 403 and there’s no API route to add a CORS rule. Native apps, CLI tools and your own server are unaffected — CORS is a browser policy and nothing else enforces it.

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. Worth flagging: part_size_min is advertised but not enforced. An upload made entirely of 1 MiB parts completed without complaint in our testing, so don’t rely on the server to catch a client that chunks too small.

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 trusts whatever you send it

Here’s the finding that changes how you build this. We opened an upload, pushed 6 MiB as part 1 and 2 MiB as part 2, then called complete with only part 1 in the list. The response was 200 OK. The object exists, found is true, and size_bytes is 6291456 — the last 2 MiB are simply gone, with no error anywhere in the chain.

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"}]}'

Nothing compares the manifest against what actually arrived. That is the mechanical cause behind most “the ZIP downloads but won’t open” reports — a browser tab that lost a part, or a retry loop that gave up on part 7, produces a perfectly valid-looking object that’s missing its tail.

So the parts list is a security-and-integrity input, not a convenience. Keep it server-side: your backend signs part N, records that it signed part N, and builds the manifest from its own records rather than from whatever the client posts back. If the client is a browser you don’t control, that rule isn’t optional.

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 (/InvalidPart/i.test(detail)) throw new Error(`bad manifest, do not retry: ${detail}`);
    throw new Error(`complete failed ${code}: ${detail}`);
  }
  return payload.data;
}

That InvalidPart check earns its place. Send a manifest with an ETag that doesn’t match the stored part and the API answers 503 VENDOR_DOWN with retryable: true, wrapping an S3 InvalidPart error underneath. A generic retry-on-5xx client will hammer that forever, because the payload is wrong and no amount of waiting fixes it. Read the message, not just the status.

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.

Three shapes, and when each is right

ShapeBrowser can drive itBytes through your serverGood for
Presigned multipart, client-drivenOn S3 or Cloudflare R2, yes — Infrai, noNoneMulti-GB media from a web app you can’t proxy
Presigned multipart, server-drivenn/aAll of themCI artefacts, transcoder output, backups, anything server-side
Single presigned PUTSame CORS rule appliesNoneAnything under ~100 MB; the simplest thing that works

If your requirement is a browser pushing a 4 GB video with pause and resume, use S3 or R2 directly and configure the bucket CORS rules — that’s what they’re for, and pretending otherwise wastes an afternoon. If the big file is produced by your own infrastructure, the Infrai path is fine and the same key that stores the object also runs the queue that triggers the transcode and the email that tells the user it’s ready.

Cost of the four calls

Opening an upload and signing parts are free and rate-limited. PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} bills $0.0001 per call and POST /v1/storage/multipart/complete/{upload_id} $0.0002, verified 26 July 2026 — so a 4 GB file in 8 MiB parts is 500 part calls, about five cents of API charges before storage. New accounts get $2 of free credit. 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')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.multipart')]"

Bigger parts mean fewer billable calls and a coarser retry unit; smaller parts retry cheaply and cost more calls. Rates have trended down over time, so treat those figures as a ceiling.

References

Browse more storage developer guides