Private document uploads: presign direct, or relay through your API?

A decision guide with measured numbers: what each path costs you in bytes, validation and blast radius, and why one of them is closed on Infrai buckets today.

For contracts, invoices and anything else a tenant would be upset to see leak, relay the upload through your own API. That’s not a general verdict on presigned uploads — it’s the specific answer for Infrai buckets, where no route sets CORS rules, so a browser PUT straight at the bucket never gets past its preflight. Every bucket reports cors_rules: [] and there’s no verb that changes it.

If browser-to-bucket is non-negotiable for you, that’s a legitimate requirement and the honest recommendation is Cloudflare R2 or S3, both of which let you write an allowed-origin list. What follows is the relay, and the case for it on merits rather than on the wall.

What each path actually costs you

Relay through your APIPresigned direct to bucket
Bytes crossing your serverall of them, plus a third for base64none
Where the type check happensyour code, before the writenowhere, unless the vendor does it
What a stolen artefact grantsone authenticated sessionwrite access to one key until expiry
Tenant prefix enforcementserver-side, unfakeablewhatever the signed key said
Works on an Infrai bucket todayyesno — preflight fails

The middle row is the one people underrate. A presigned URL is a capability handed to code you don’t control, and the client decides what to do with it — including uploading something else entirely under the key you signed.

The relay path, one call

The document goes up base64-encoded in a JSON body. Keys carry the tenant, so authorisation is a prefix check rather than a lookup:

export INFRAI_API_KEY=your_infrai_api_key

node -e 'const fs=require("fs");fs.writeFileSync("doc.json",JSON.stringify({data_base64:fs.readFileSync("msa.pdf").toString("base64"),content_type:"application/pdf",metadata:{"tenant-id":"acct-1042","uploaded-by":"u-7731"}}))'

curl -s -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kb-docflow-0726/tenants/acct-1042/contracts/2026-07/msa-3f81.pdf" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @doc.json
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_bdbf0a2cd5484507902a7d",
    "key": "tenants/acct-1042/contracts/2026-07/msa-3f81.pdf",
    "size_bytes": 69,
    "etag": "1bbc324fd49ac1b6e692f4520dcc42d7",
    "content_type": "application/pdf",
    "metadata": { "tenant-id": "acct-1042", "uploaded-by": "u-7731" }
  }
}

Base64 inflates the request by a third, so a 10 MB PDF arrives at the API as roughly 13.3 MB of JSON. In our testing a 941 KB payload took 4.9 seconds wall-clock from a laptop, most of it upstream bandwidth rather than API time. Above a few tens of megabytes, switch that leg to multipart via POST /v1/storage/multipart/create/{bucket} instead of growing the body.

The check you only get on this path

Executables are refused at the API, not silently stored:

{
  "ok": false,
  "error": {
    "code": "STORAGE_CONTENT_TYPE_BLOCKED",
    "http_status": 415,
    "message": "content-type 'application/x-msdownload' is not allowed (active/executable)"
  }
}

That’s a floor, not a policy. Your own allowlist still belongs in the handler, because “not an executable” is a long way from “a PDF this tenant is allowed to upload”.

import { readFile } from "node:fs/promises";
import process from "node:process";

const BASE = "https://api.infrai.cc";
const BUCKET = "kb-docflow-0726";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");

const ALLOWED = new Map([
  ["application/pdf", "pdf"],
  ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"],
  ["text/csv", "csv"],
]);
const MAX_BYTES = 25 * 1024 * 1024;

async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
  });
  const payload = await res.json();
  if (!payload.ok) throw new Error(`${payload.error?.code}: ${payload.error?.message}`);
  return payload.data;
}

export async function receiveDocument({ tenantId, userId, contentType, filePath, docId }) {
  const ext = ALLOWED.get(contentType);
  if (!ext) throw new Error(`content type ${contentType} is not accepted`);
  const bytes = await readFile(filePath);
  if (bytes.length > MAX_BYTES) throw new Error(`${bytes.length} bytes exceeds the ${MAX_BYTES} limit`);

  const key = `tenants/${tenantId}/contracts/2026-07/${docId}.${ext}`;
  const stored = await call(`/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    body: JSON.stringify({
      data_base64: bytes.toString("base64"),
      content_type: contentType,
      metadata: { "tenant-id": tenantId, "uploaded-by": userId },
    }),
  });

  const link = await call(`/v1/storage/object/presign/${BUCKET}/${key}`, {
    method: "POST",
    body: JSON.stringify({ op: "get", expires_seconds: 900 }),
  });
  return { key: stored.key, size: stored.size_bytes, etag: stored.etag, url: link.url, expiresAt: link.expires_at };
}

Note the metadata keys use hyphens. An underscore in a metadata key fails with a 503 from the storage backend rather than a clean validation error, which is a miserable half-hour if you don’t know it.

Handing the document back

curl -s -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-docflow-0726/tenants/acct-1042/contracts/2026-07/msa-3f81.pdf" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":900}'

Expiry is capped at [1..604800] seconds — anything outside that returns STORAGE_INVALID_TTL. An expired link answers 403.

There’s a limitation here that matters for documents specifically: the signature controls the lifetime of the link, not who may read the object. Strip the query string and the vendor path still serves the bytes. Long random keys are doing the real work, so never log the raw vendor URL and never treat a bucket path as authorisation.

Where the bytes actually sit

POST /v1/storage/bucket/create accepts a region, and GET /v1/storage/bucket/get/{bucket} will read it back to you faithfully. We created a bucket with eu-central-1 and the presigned URL that came back pointed at an ap-singapore host. If EU residency is a contractual promise you’ve made to a customer, verify the URL host before you rely on the field — or keep those documents on a vendor bucket where you control placement directly.

What the calls cost

Writes are $0.0001 per object and reads through GET /v1/storage/object/get/{bucket}/{key} are $0.0002; presign, head, list and bucket usage are free — verified 2026-07-26. Rates move down over time, and campaigns run, so read them rather than trusting this line:

curl -s "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -o discovery.json

node -e 'const d=require("./discovery.json");for(const c of d.capabilities){if(!c.id.startsWith("storage."))continue;const b=c.billing||{};console.log(c.id.padEnd(34), b.is_billable ? "$"+b.price_usd : "free")}'

Per-tenant attribution comes from the same prefix scheme:

curl -s -X GET "https://api.infrai.cc/v1/storage/bucket/usage/kb-docflow-0726" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

That returns byte_count, object_count and an as_of stamp for the whole bucket; for one tenant, page GET /v1/storage/object/list/{bucket} with their prefix and sum size_bytes. Watch out for one quirk — the list response returns content_type: null even for objects that have one, so use head when the type matters.

When direct-to-bucket wins anyway

Video, design files, anything where a 500 MB body through your API is absurd. Supabase Storage is a reasonable pick if you want row-level policies alongside the bytes; R2 if egress is your dominant line item. Both mean another account, another key to rotate and another invoice, which is the trade you’re making.

References

Browse more storage developer guides