The smallest private document store that still works: four calls
No IAM policy, no SDK, no second account. What you actually have to create before the first upload, and the limits you accept in exchange for that.
Create a bucket, put an object, sign a link, check it landed. Four HTTP calls against Infrai with the key you already have, no dependency to install and no policy document to write — that’s the whole thing for a startup app that needs somewhere private to keep user PDFs. Whether that’s the right store depends on a couple of limits further down, and they’re worth reading before you commit.
The reason this question keeps getting asked is that the usual answer isn’t four calls. On S3 you open an account, mint an IAM user or role, write a bucket policy, decide on a region, install a client, and only then upload something.
What you have to create before the first upload
| Accounts | Credentials to manage | Config artefacts | |
|---|---|---|---|
| Infrai storage | the one you have | the API key you already use | none |
| Amazon S3 | AWS account | IAM user or role + access keys | bucket policy, optional CORS JSON |
| Cloudflare R2 | Cloudflare account | R2 API token | S3 client config, endpoint URL |
| Supabase Storage | Supabase project | service + anon keys | RLS policies per bucket |
| MinIO | none — a server you run | root credentials | deployment, TLS, backups |
MinIO’s column is honest, not snide: running it yourself is genuinely the right answer when documents can’t leave your own hardware, and no hosted option changes that.
Call one and two
export INFRAI_API_KEY=your_infrai_api_key
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"kb-startupdocs-0726","acl":"private"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_adaab4beb7f348d5bd1409",
"name": "kb-startupdocs-0726",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
Re-running that with a name someone already took returns STORAGE_BUCKET_EXISTS with a 409, which makes bucket creation safe to leave in a bootstrap script.
Then the document. Bytes travel base64-encoded inside the JSON body, and the object key is the path — slashes and all, so a per-user prefix is free:
node -e 'const fs=require("fs");fs.writeFileSync("body.json",JSON.stringify({data_base64:fs.readFileSync("w9.pdf").toString("base64"),content_type:"application/pdf",metadata:{"owner-id":"u-2261"}}))'
curl -s -X PUT \
"https://api.infrai.cc/v1/storage/object/put/kb-startupdocs-0726/docs/u-2261/2026-07/w9-4b7a.pdf" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @body.json
Use --data-binary @file rather than -d. A real PDF becomes a base64 string far longer than your shell will accept as an argument.
Call three and four
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-startupdocs-0726/docs/u-2261/2026-07/w9-4b7a.pdf" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
curl -s -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-startupdocs-0726/docs/u-2261/2026-07/w9-4b7a.pdf" \
-H "Authorization: Bearer $INFRAI_API_KEY"
head answers found: true with size_bytes, etag and your metadata, and it’s free — which makes it the right call for “does this exist” checks in a loop, rather than fetching the object and throwing the bytes away.
Wrapped up as something you can actually run:
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import process from "node:process";
const [filePath, ownerId] = process.argv.slice(2);
if (!filePath || !ownerId) throw new Error("usage: node store.mjs <file.pdf> <owner-id>");
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const BASE = "https://api.infrai.cc";
const BUCKET = "kb-startupdocs-0726";
const headers = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
const bytes = await readFile(filePath);
const key = `docs/${ownerId}/2026-07/${basename(filePath)}`;
const put = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers,
body: JSON.stringify({
data_base64: bytes.toString("base64"),
content_type: "application/pdf",
metadata: { "owner-id": ownerId },
}),
});
const stored = await put.json();
if (!stored.ok) throw new Error(`${stored.error?.code}: ${stored.error?.message}`);
const signed = await fetch(`${BASE}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers,
body: JSON.stringify({ op: "get", expires_seconds: 600 }),
});
const link = await signed.json();
if (!link.ok) throw new Error(`${link.error?.code}: ${link.error?.message}`);
console.log(`${stored.data.size_bytes} bytes at ${stored.data.key}`);
console.log(`link valid until ${link.data.expires_at}`);
The cron job you don’t have to write
Scratch uploads and abandoned drafts pile up. One call hands the cleanup to the bucket:
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-startupdocs-0726" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"tmp/","expire_days":1}]}'
Rules come back on GET /v1/storage/bucket/get/{bucket}, so you can assert on them in a test.
The limits you’re accepting
Three of them, and none is subtle once you’ve hit it.
A signed URL is a timer, not a lock. Strip the query string and the object still answers 200, even when its ACL is signed-only, so the long random key is what actually keeps strangers out. Second, public-read isn’t available at all — the API rejects it with STORAGE_ACL_INVALID, and public buckets simply aren’t a shape this supports. Third, nothing sets bucket CORS: cors_rules stays [], so a browser can’t upload straight to the bucket and the bytes have to pass through your server.
There’s a fourth worth flagging if you sell into the EU. bucket/create accepts a region and reads it back to you unchanged, but we created a bucket as eu-central-1 and the presigned URL pointed at an ap-singapore host. If residency is contractual, verify the host before you promise anything.
What it costs
Buckets, presigns, heads, lists and lifecycle rules are free. Writing an object is $0.0001 per call and reading one through the API is $0.0002, plus stored bytes and egress — verified 2026-07-26. Storage rates trend downward and discounts run, so read today’s numbers rather than this paragraph:
curl -s "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| jq -r '.capabilities[] | select(.id | startswith("storage.")) | "\(.id) \(.billing.price_usd // "free")"'
curl -s "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer $INFRAI_API_KEY"
The honest recommendation
If storage is the only thing you need and you’re already fluent in AWS, S3 is fine and nobody was ever fired for it. If your bill is dominated by downloads, R2’s egress pricing is a stronger argument than anything here. Where this wins is that a document store is rarely the only thing an app needs: the same key sends the confirmation email, queues the virus scan, runs the nightly export and records the error when one of those fails — which is one account, one key rotation and one invoice instead of five.