Presigned POST, PUT or multipart: choosing by file size
Four upload shapes, one decision. Measured part limits, the presigned POST that does not exist on Infrai, and where the crossover to multipart really sits.
Size decides, and the honest answer has one asterisk on it. Under a few megabytes, put the bytes in a JSON body and let your server make one authenticated call. Past that, hand out a presigned PUT. Past roughly 100 MB — or whenever a flaky uplink means a failed transfer has to resume rather than restart — switch to multipart. What Infrai’s storage API doesn’t support is the fourth shape people expect: there’s no presigned POST policy, so an HTML <form> that posts straight at the bucket isn’t an option here.
That asterisk matters more than it sounds, because the presigned POST form is what most browser-upload tutorials teach. Infrai’s presign response does carry a fields key, and it comes back null — no policy document, no signature, nothing to spread into a form’s hidden inputs.
The four shapes, and which one survives contact
| Mechanism | Where bytes flow | Max practical size | Available on Infrai |
|---|---|---|---|
| JSON body, base64 | client to your server to API | a few MB (base64 adds 33%) | Yes — PUT /v1/storage/object/put/{bucket}/{key} |
| Presigned single PUT | client straight to vendor | vendor single-object limit | Yes — but read the op note below |
| Presigned POST policy form | browser form to vendor | n/a | No route emits one |
| Multipart, presigned parts | client to vendor, part by part | 5 MB parts x 10,000 | Yes — three routes |
The base64 route deserves less scorn than it gets. It’s one call, it’s atomic, it needs no second round trip, and for a 200 KB avatar or a 2 MB PDF the 33% encoding overhead is noise. Ship it and move on.
Small: one call, bytes in the body
export INFRAI_API_KEY=your_infrai_api_key
node -e 'const fs=require("fs");fs.writeFileSync("body.json",JSON.stringify({data_base64:fs.readFileSync("notes.txt").toString("base64"),content_type:"text/plain"}))'
curl -s -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kb-uploads-sizing/uploads/demo/notes-7c2.txt" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @body.json
Two things to know about this call before it bites. An empty payload is rejected outright with INVALID_ARGUMENT and the message storage.object.put needs 'data' (base64 body) — zero-byte objects aren’t a thing, so a truncated buffer fails loudly rather than writing a placeholder. And if you pass an idempotency_key, a repeat with the same key returns the first result and silently drops the new bytes; we sent “version A”, then sent “version B” under the same key, and the stored object stayed at 10 bytes of “version A”. Handy for retries, dangerous for a loop that reuses one key across different files.
Medium: a presigned PUT
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-uploads-sizing/uploads/demo/notes-7c2.txt" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"text/plain","max_bytes":5242880}'
Use op: "put". The value upload returns a URL signed for GET, and a PUT to it fails with SignatureDoesNotMatch — the trap is documented with a full verb matrix in the avatar upload walkthrough. A correct put presign answers with the verb and the headers you owe it:
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-uploads-sizing/uploads/demo/notes-7c2.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=c027fa3d",
"method": "PUT",
"headers": { "Content-Type": "text/plain" },
"fields": null,
"expires_at": "2026-07-26T05:36:42.140063Z",
"max_bytes": 5242880
}
}
That Content-Type isn’t advisory. It’s inside the signature, so a client that sends application/octet-stream against a URL signed for text/plain gets a 403 rather than a mismatched object. And from a CLI, a mobile app or another server the URL works — we pushed a 100 KB PNG through one and the object landed with the right type. From a browser it doesn’t, because a PUT is never a simple request and the preflight gets 403 with no Access-Control-Allow-Origin; nothing in the API sets bucket CORS rules.
Large: multipart, with the real numbers
Creating a multipart upload tells you the constraints instead of making you guess:
curl -s -X POST "https://api.infrai.cc/v1/storage/multipart/create/kb-uploads-sizing" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"key":"uploads/demo/big-video.mp4","content_type":"video/mp4"}'
{
"ok": true,
"data": {
"upload_id": "1785043618b44416a46990db4fa8939a048f23628f",
"bucket_id": "bkt_7e64de2631cd4c9cb243eb",
"key": "uploads/demo/big-video.mp4",
"started_at": "2026-07-26T05:26:58.871138Z",
"part_size_min": 5242880,
"part_count_max": 10000
}
}
A 5 MB floor and a 10,000-part ceiling. At the minimum part size that’s a 48.8 GB object; pick 16 MB parts and the same ceiling buys you 156 GB, at the cost of re-sending 16 MB whenever a part fails. On a mobile connection we’d stay near 8 MB.
Each part gets its own presigned URL, valid for an hour and carrying the same method field:
import { readFileSync } from "node:fs";
import process from "node:process";
const BASE = "https://api.infrai.cc";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
async function api(path, init) {
const res = await fetch(`${BASE}${path}`, init);
const body = await res.json();
if (!body.ok) throw new Error(`${path}: ${body.error?.code ?? res.status} ${body.error?.message ?? ""}`);
return body.data;
}
export async function uploadLargeFile(bucket, key, filePath, partBytes = 8 * 1024 * 1024) {
const file = readFileSync(filePath);
const created = await api(`/v1/storage/multipart/create/${bucket}`, {
method: "POST",
headers,
body: JSON.stringify({ key, content_type: "video/mp4" }),
});
if (partBytes < created.part_size_min) throw new Error(`parts must be >= ${created.part_size_min} bytes`);
const parts = [];
try {
for (let offset = 0, number = 1; offset < file.length; offset += partBytes, number++) {
const slice = file.subarray(offset, Math.min(offset + partBytes, file.length));
const signed = await api(`/v1/storage/multipart/presign_part/${created.upload_id}/${number}`, { method: "POST", headers });
const put = await fetch(signed.url, { method: signed.method, body: slice });
if (!put.ok) throw new Error(`part ${number} failed with ${put.status}`);
parts.push({ part_number: number, etag: put.headers.get("etag")?.replaceAll('"', "") });
}
return await api(`/v1/storage/multipart/complete/${created.upload_id}`, {
method: "POST",
headers,
body: JSON.stringify({ parts }),
});
} catch (err) {
await api(`/v1/storage/multipart/abort/${created.upload_id}`, { method: "DELETE", headers });
throw err;
}
}
const done = await uploadLargeFile("kb-uploads-sizing", "uploads/demo/big-video.mp4", "./big-video.mp4");
console.log(done.key, done.size_bytes, done.etag);
The abort in the catch block isn’t decoration. An abandoned multipart upload holds parts you can’t see through GET /v1/storage/object/list/{bucket}, and nothing expires it for you.
Confirm, whichever path you took
curl -s -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-uploads-sizing/uploads/demo/notes-7c2.txt" \
-H "Authorization: Bearer $INFRAI_API_KEY"
A committed object answers found: true with size_bytes and etag. Compare that size against what you meant to send — a silent truncation is the failure mode that survives every other check.
What each shape costs
Writes are metered per call: storage.object.put at $0.0001 and storage.multipart.upload_part at $0.0001 per part, with storage.multipart.complete at $0.0002 — verified 2026-07-26. Presign, create, abort, head and list are free. Rates drift downward, so read them live:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const d=JSON.parse(s);for(const c of d.capabilities)if(c.id.startsWith("storage.multipart")||c.id==="storage.object.put")console.log(c.id,c.billing.is_billable?`$${c.billing.price_usd}`:"free")})'
The pricing consequence of part size is direct: a 1 GB file in 5 MB parts is 205 billed calls, the same file in 64 MB parts is 17. That’s still a rounding error against the stored bytes, which is the honest reason not to over-tune it.
Where a different store wins
If the upload has to start in a browser without your server touching the bytes, use something whose CORS you control — Cloudflare R2 or S3, both of which also emit real presigned POST policies with per-field conditions. MinIO is the answer when the files can’t leave your building. Backblaze B2 is worth a look if you’re storing far more than you serve.
The reason to keep uploads here is the work on either side of them: the same key runs the queue that processes the file, the image pipeline that derives thumbnails, the notification when it’s ready, and one usage view that tells you which tenant filled the bucket.