What multi-GB instructor uploads need beyond a single HTTP request

Multi-GB lecture video needs chunked transfer, not a longer timeout. Measured part sizes, the truncation trap in the assemble call, and what browsers still can't do.

Split the file. For a 4 GB lecture recording the standard answer is a multipart upload: the client cuts the file into numbered parts, sends each part on its own request, and one final call assembles them into a single object. Infrai exposes that as four routes reachable with the same key that runs your queue and your email, and the part bytes travel straight to the storage backend rather than through your application server.

The reason isn’t really size. It’s failure granularity.

A single PUT of 4 GB is one bet that one TCP connection survives however long an instructor’s home upstream needs — at 20 Mbit/s that’s roughly 27 minutes of uninterrupted transfer. Drop it at minute 26 and you have nothing. Cut the same file into 8 MiB parts and a dropped connection costs one part and a retry, which is why every large-file transfer product converges on this shape whatever it calls the feature.

Three ways to move the bytes

We measured all three against a live Infrai bucket on 2026-07-26, from one machine in one region.

ShapeCallMeasuredWhere it stops working
Base64 inside a JSON bodyPUT /v1/storage/object/put/{bucket}/{key}2 MiB stored in 7.4 sencoding inflates the payload by a third; a 10 MiB body hadn’t returned after 2 minutes
One presigned PUTPOST /v1/storage/object/presign/{bucket}/{key}signing takes ~60 msone connection, no resume, no progress reporting
Multipart partsPOST /v1/storage/multipart/create/{bucket} and the three routes that follow5 MiB part in 2.2 syou now own a parts ledger

The base64 envelope is genuinely handy for a syllabus, a thumbnail or a certificate PDF — the sort of artefact Gotenberg or wkhtmltopdf hands you at a few hundred kilobytes. Above a megabyte or two it’s the wrong tool, and the capability schema says so in as many words.

The two numbers that decide your part size

Opening an upload tells you both of them:

export INFRAI_API_KEY=your_infrai_api_key

curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-coursevid-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"key":"courses/c-101/lesson-02.mp4","content_type":"video/mp4"}'
{
  "ok": true,
  "data": {
    "upload_id": "1785044954303e159af244af42cb4359ac50d45d125cd6fcadebca3b228bbfafadd41760a1",
    "bucket_id": "bkt_2f84e8db14b14c58ba4a94",
    "key": "courses/c-101/lesson-02.mp4",
    "started_at": "2026-07-26T05:49:14Z",
    "part_size_min": 5242880,
    "part_count_max": 10000
  }
}

part_size_min is 5 MiB and every part except the last has to reach it. part_count_max is 10,000. At the floor those two multiply out to about 50 GiB for one object, so a raw camera master from a three-hour workshop is worth sizing before you promise instructors “any file”. For a 4 GB lecture, 8 MiB parts give 500 chunks, each independently retryable, and a progress bar that moves 500 times instead of once.

Each part gets its own short-lived URL, and the client PUTs the bytes to that URL with no Infrai header attached:

UPLOAD_ID="1785044954303e159af244af42cb4359ac50d45d125cd6fcadebca3b228bbfafadd41760a1"

PART_URL=$(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 '{}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")

curl -sS -X PUT --data-binary @part-001.bin -D - -o /dev/null "${PART_URL}" | grep -i '^etag:'

Keep that ETag. It’s the only thing you need to carry between parts.

The uploader, end to end

This is the whole client on Node 22 — read a slice, sign it, push it, record the ETag, assemble:

import { open, stat } from "node:fs/promises";

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-coursevid-0726";
const OBJECT_KEY = "courses/c-101/lesson-02.mp4";
const SOURCE = process.argv[2];
const PART_SIZE = 8 * 1024 * 1024;
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

async function unwrap(res, label) {
  const body = await res.json();
  if (!res.ok || body.ok === false) {
    throw new Error(`${label} -> HTTP ${res.status} ${JSON.stringify(body.error ?? body)}`);
  }
  return body.data;
}

const created = await unwrap(
  await fetch(`${API}/v1/storage/multipart/create/${BUCKET}`, {
    method: "POST",
    headers,
    body: JSON.stringify({ key: OBJECT_KEY, content_type: "video/mp4" }),
  }),
  "multipart.create",
);

const total = (await stat(SOURCE)).size;
const partCount = Math.ceil(total / PART_SIZE);
if (partCount > created.part_count_max) {
  throw new Error(`${partCount} parts exceeds the ${created.part_count_max} ceiling`);
}

const fh = await open(SOURCE, "r");
const parts = [];
try {
  for (let n = 1; n <= partCount; n++) {
    const size = Math.min(PART_SIZE, total - (n - 1) * PART_SIZE);
    const buf = Buffer.allocUnsafe(size);
    await fh.read(buf, 0, size, (n - 1) * PART_SIZE);

    const signed = await unwrap(
      await fetch(`${API}/v1/storage/multipart/presign_part/${created.upload_id}/${n}`, {
        method: "POST",
        headers,
        body: "{}",
      }),
      `presign_part ${n}`,
    );

    let etag = null;
    for (let attempt = 1; attempt <= 3 && etag === null; attempt++) {
      const put = await fetch(signed.url, { method: signed.method, body: buf });
      if (put.ok) etag = put.headers.get("etag").replaceAll('"', "");
      else if (attempt === 3) throw new Error(`part ${n} failed after 3 tries: HTTP ${put.status}`);
    }
    parts.push({ part_number: n, etag });
    process.stdout.write(`part ${n}/${partCount} ok\n`);
  }
} finally {
  await fh.close();
}

const object = await unwrap(
  await fetch(`${API}/v1/storage/multipart/complete/${created.upload_id}`, {
    method: "POST",
    headers,
    body: JSON.stringify({ parts }),
  }),
  "multipart.complete",
);

if (object.size_bytes !== total) {
  throw new Error(`assembled ${object.size_bytes} bytes, expected ${total}`);
}
console.log(`stored ${object.key} — ${object.size_bytes} bytes, etag ${object.etag}`);

That size assertion at the end isn’t defensive padding. It’s the fix for the sharpest edge in the whole flow.

The assemble call trusts your list

complete takes the parts you send it and ignores the parts you don’t:

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

We uploaded two parts totalling 6 MiB, then sent a list naming only the first. The response was 200 and the object was 5,242,880 bytes — the second part silently discarded, no warning, no STORAGE_MULTIPART_INCONSISTENT. A student would have watched a lecture that ends mid-sentence. Compare the returned size_bytes against the file you read, every time.

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_2f84e8db14b14c58ba4a94",
    "key": "courses/c-101/lesson-02.mp4",
    "size_bytes": 6291456,
    "etag": "70d0257b36ab04da4e60d7e9e1f092fc-2",
    "content_type": "video/mp4"
  }
}

The -2 suffix on the ETag is the part count. A multipart ETag is not an MD5 of the object, so don’t build integrity checks on it — hash the source yourself and store the digest as object metadata (hyphenated keys only; an underscore in a metadata key breaks the vendor’s signing and comes back as a 503).

Verify independently rather than trusting the write path:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-coursevid-0726/courses/c-101/lesson-02.mp4" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-coursevid-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Where the upload can actually run

Not in a browser tab, today. The bucket answers a cross-origin preflight with 403 and no Access-Control-Allow-Origin, and there’s no route to set CORS rules, so a web page can’t PUT parts directly. That’s a real limitation and it shapes the product: instructors upload from a desktop helper, a CLI, a native app, or through your own server. Cloudflare R2 and S3 both let you configure bucket CORS and would carry a browser-only uploader that Infrai can’t.

Two more things we’d rather you heard here. PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} — the route that proxies part bytes as base64 through the API — returned 503 VENDOR_DOWN on every attempt in our testing; presigned parts are the working path. And a bucket’s region is accepted at creation but the signed URLs come back on one backend region regardless, so don’t sell EU-resident storage on the strength of that field.

What it costs, and what else the key already does

Verified 2026-07-26: opening an upload and signing a part are free (rate-limited, and they don’t touch the new-account trial); complete is $0.0002 per call and the base64 put is $0.0001. A 4 GB lecture in 8 MiB parts therefore costs one metered call, not 500. New accounts start with $2 of free credit. Rates on this platform move down over time and discount campaigns run, so read today’s numbers rather than these:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; print([{'path': c['path'], 'billing': c['billing']} for c in json.load(sys.stdin)['capabilities'] if c['path'].startswith('/v1/storage/multipart')])"

The structure matters more than the figure: control-plane calls are free, only the write that produces an object is metered, and storage is then billed on bytes held. If video is the only thing you’re buying, a specialist like Mux or Cloudinary gives you transcoding, adaptive bitrate and a player, and you’d be better off there. The argument for Infrai is the next question after the upload — queue the transcode job, email the instructor when it’s ready, capture the failure, attribute the storage to that tenant — all on the credential you already hold, on one invoice.

References

Browse more pdf developer guides