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.
| Shape | Call | Measured | Where it stops working |
|---|---|---|---|
| Base64 inside a JSON body | PUT /v1/storage/object/put/{bucket}/{key} | 2 MiB stored in 7.4 s | encoding inflates the payload by a third; a 10 MiB body hadn’t returned after 2 minutes |
One presigned PUT | POST /v1/storage/object/presign/{bucket}/{key} | signing takes ~60 ms | one connection, no resume, no progress reporting |
| Multipart parts | POST /v1/storage/multipart/create/{bucket} and the three routes that follow | 5 MiB part in 2.2 s | you 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 checks your list
complete compares the manifest you send against the parts the upload actually received, and refuses if they disagree:
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":"03cabd4254d38fd00799d3b8da10dace"}]}'
We uploaded two parts, then named only the first. On 2026-07-27 that came back as a 409, not a truncated lecture:
{
"ok": false,
"error": {
"code": "STORAGE_MULTIPART_INCONSISTENT",
"http_status": 409,
"message": "multipart manifest must contain exactly uploaded parts [1, 2]",
"retryable": false
}
}
That’s the failure mode you want: a bookkeeping bug in your uploader surfaces as an error at assemble time rather than as a student watching a video that ends mid-sentence. Keep the size assertion anyway — it costs nothing and it also catches the case where you read the wrong file.
On the object you do get back, the ETag carries a -N suffix where N is the part count. A multipart ETag is not an MD5 of the whole object, so don’t build integrity checks on it — hash the source yourself and store the digest in object metadata. Underscored keys are fine there, incidentally: send content_sha256 and it’s accepted, stored, and read back hyphenated as content-sha256.
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. POST /v1/storage/bucket/set_cors/{bucket} does accept a rule set and GET /v1/storage/bucket/get/{bucket} reads it straight back, but the storage host still answers a real cross-origin preflight with 403 and no Access-Control-Allow-Origin, so a web page can’t PUT parts directly. That shapes the product rather than blocking it: instructors upload from a desktop helper, a CLI, a native app, or through your own server — which for multi-GB video is where serious uploaders live anyway, because that’s where you get pause, resume and a parts ledger that survives a closed tab. If a browser-only uploader is non-negotiable, R2 and S3 will carry it and this won’t.
Two more things we’d rather you heard here. PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} proxies part bytes as base64 through the API and does work — it’s the fallback when a client can’t reach the storage host directly — but presigned parts stay the right default, because they keep the bytes off your API path and skip the base64 inflation. And there is one storage region: a bucket created with "region": "eu-central-1" is refused with a 400 naming the region that exists, so what you can promise about placement is knowable up front rather than after an audit.
What it costs, and what else the key already does
Verified 2026-07-27: 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.
Playback is the other half of the bill, and it isn’t per call: reading bytes back through object/get meters egress at $0.104 per GB. For video that number decides the architecture — serve lessons through signed links and, if you can, a CDN, rather than streaming multi-GB files back through your own API. 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: POST /v1/queue/publish for the transcode job, POST /v1/email/send to tell the instructor it’s ready, POST /v1/errors/capture when it isn’t, GET /v1/account/usage to attribute the storage to that tenant — every one of them already on the same key that took the upload, with no second account to open and one invoice at the end.