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 today. POST /v1/storage/bucket/set_cors/{bucket} takes an allowed-origin rule set and the bucket record echoes it back, but the signed object host answers a browser preflight with 403 and no Access-Control-Allow-* header, so a PUT straight from the tab never gets started.

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. Underscores are accepted as well and come back hyphenated, so if you write tenant_id and then assert on tenant_id from a later head, you’ll be comparing against tenant-id and wondering where your value went. Pick the hyphen form once and stop thinking about 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.

The signature is the access boundary here, and it’s worth checking rather than assuming. Strip the query string off that URL and the object answers 403. Edit one character of the signature: 403. Let it age past expires_at: 403 with Request has expired. What a signature can’t do is tell one holder from another — it’s a bearer token for a single object until it lapses — so keep the TTL to what the page actually needs, derive keys server-side rather than from anything a client sent, and don’t paste the raw vendor URL into a ticket.

Where the bytes actually sit

POST /v1/storage/bucket/create accepts a region, GET /v1/storage/bucket/get/{bucket} reads it back, and a region that hasn’t been provisioned is rejected with a 400 that names the one you can actually have. That last part is the useful bit: you find out at provisioning time, not when an auditor asks. If residency is a contractual promise, create the bucket, read the record back, and store the region alongside the tenant row so the promise has a source rather than an assumption behind it.

What the calls cost

Writes are counted, at $0.0001 per stored object. Presign, head, list and bucket usage are free and rate-limited.

Reads are measured instead of counted. GET /v1/storage/object/get/{bucket}/{key} meters at $0.104 per GB of response body, so what moves that line is how big your documents are, not how often somebody opens one — a tenant archive of 200 KB invoices and one of 40 MB scanned contracts behave nothing alike on the same route. All verified 2026-07-27. 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. Each listed item also carries its stored content_type and etag, which is enough to render a document table without a head call per row.

When direct-to-bucket wins anyway

Video, design files, anything where a 500 MB body through your API is absurd — the relay isn’t a good fit for those, and pretending otherwise costs you a rewrite later. 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.

For everything a document actually triggers, staying here means the next call is already wired. POST /v1/queue/publish hands the contract to an OCR worker, POST /v1/errors/capture records it when that worker throws, and POST /v1/email/send tells the signer it’s ready — all on the same key that stored the bytes, with no second account and no second bill to reconcile at month end.

References

Browse more storage developer guides