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

AccountsCredentials to manageConfig artefacts
Infrai storagethe one you havethe API key you already usenone
Amazon S3AWS accountIAM user or role + access keysbucket policy, optional CORS JSON
Cloudflare R2Cloudflare accountR2 API tokenS3 client config, endpoint URL
Supabase StorageSupabase projectservice + anon keysRLS policies per bucket
MinIOnone — a server you runroot credentialsdeployment, 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 link is a bearer grant. The signature is the access boundary — take the query string off and the storage host answers 403 — but whoever holds the intact link holds the grant until it expires, which is why 600 seconds is a better default than a day and why the key should come from your database rather than from a user-supplied filename. Second, public-read isn’t available at all: the API rejects it with STORAGE_ACL_INVALID, so a public bucket simply isn’t a shape this supports. Third, the browser doesn’t write to the bucket itself. POST /v1/storage/bucket/set_cors/{bucket} stores a rule set and bucket/get echoes it back, but the storage host does not yet answer a browser preflight with those headers, so uploads travel through your server — which for a PDF is the path you probably wanted anyway, since that’s where you’d scan and rename it.

A fourth is worth flagging if you sell into the EU. bucket/create takes a region, honours it, and rejects one that isn’t provisioned with a 400 naming the region that is — so assert on data.region in your bootstrap script instead of assuming the string you sent was the string you got.

What it costs

Buckets, presigns, heads, lists and lifecycle rules are free. Writing an object is $0.0001 per call, verified 2026-07-27.

Reading is metered by volume rather than per call: $0.104 per GB of egress, plus the bytes you keep at rest. For a document store that is the more useful shape anyway — the bill follows how much gets downloaded, so a rarely-opened archive of scanned W-9s is nearly free to hold and a report everyone re-downloads twice a day is the line item to watch. 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 POST /v1/email/send that confirms the upload, the POST /v1/queue/publish that hands the file to a virus scan, the POST /v1/cron/create that runs the nightly export and the POST /v1/errors/capture that records it when one of those fails are already on the same account as the bucket — no second vendor, no second key to rotate, one invoice instead of five.

References

Browse more storage developer guides