Node.js: moving a generated image into a private bucket

The b64_json field is already upload-ready; only the url form needs a Buffer. A measured Node 22 walkthrough — payload sizes, metadata keys, private reads.

Ask for response_format: "b64_json" and the render comes back as a base64 string that Infrai’s storage API accepts as-is: pass it as data_base64 to PUT /v1/storage/object/put/{bucket}/{key} and you’re done. No temp file, no SDK, no Buffer at all. The Buffer only shows up on the other branch — when the model hands you a url instead, and you have to fetch those bytes yourself before you can re-encode them.

That’s the whole shape of the job in Node. The rest of this is the parts that bite: how big the JSON payload really gets, which metadata keys are accepted, and what “private access” does and doesn’t mean once the object is sitting there.

Two response shapes, two amounts of work

response_formatWhat you get backNode work requiredExtra round trip
b64_jsonbase64 string in data[0].b64_jsonnone — forward itno
urltemporary vendor link in data[0].urlfetcharrayBuffer()BuffertoString("base64")yes, and the link expires

A 1024×1024 render we pulled on 2026-07-26 arrived as 1,255,004 base64 characters, which decodes to 941,252 bytes of PNG. Base64 costs you the usual third on the wire, so the JSON body you hand to storage is about 1.2 MB for a 941 KB image. That’s fine. It’s also the number to keep in mind before you decide to hold twenty of these in memory at once.

The upload call

Object keys carry slashes, and they belong in the path — renders/2026-07/hero-9f2.png is four path segments after the bucket, not a query parameter:

export INFRAI_API_KEY=your_infrai_api_key

node -e 'const fs=require("fs");const b64=fs.readFileSync("hero.png").toString("base64");fs.writeFileSync("put.json",JSON.stringify({data_base64:b64,content_type:"image/png",metadata:{"render-id":"r-9f2",tenant:"acct-1042"}}))'

curl -s -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kb-genimg-buffers/renders/2026-07/hero-9f2.png" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @put.json

The payload goes into a file first because a megabyte of base64 on a -d argument runs into your shell’s argument limit long before the API sees it.

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_3994634bfd3048b4a61391",
    "key": "renders/2026-07/hero-9f2.png",
    "size_bytes": 941252,
    "etag": "6abad79d4d79b82dcab617cb9321c156",
    "content_type": "image/png",
    "metadata": { "render-id": "r-9f2", "tenant": "acct-1042" },
    "created_at": "2026-07-26T05:32:02.245636Z"
  }
}

Two things we found the hard way with metadata. Hyphenated keys round-trip perfectly — render-id comes back on the object and on every later head. An underscore in a metadata key, though, fails as a 503 VENDOR_NOT_CONFIGURED carrying a SignatureDoesNotMatch from the backend — a spectacularly unhelpful error for what is really a typo. Hyphens only.

The module, end to end

import { Buffer } from "node:buffer";
import process from "node:process";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const jsonHeaders = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
// Held as a string on purpose: a render bills on every call, so nothing fires
// until renderPng() is invoked.
const generationsUrl = `${BASE}/v1/images/generations`;

async function renderPng(prompt) {
  const res = await fetch(generationsUrl, {
    method: "POST",
    headers: jsonHeaders,
    body: JSON.stringify({ model: "auto", prompt, n: 1, size: "1024x1024", response_format: "b64_json" }),
  });
  if (!res.ok) throw new Error(`generation failed: HTTP ${res.status} ${await res.text()}`);
  const first = (await res.json()).data?.[0];
  if (first?.b64_json) return first.b64_json;
  if (!first?.url) throw new Error("render returned neither b64_json nor url");
  const bytes = await fetch(first.url);
  if (!bytes.ok) throw new Error(`fetching the render failed: HTTP ${bytes.status}`);
  return Buffer.from(await bytes.arrayBuffer()).toString("base64");
}

async function storePng(bucket, key, base64, renderId) {
  const res = await fetch(`${BASE}/v1/storage/object/put/${bucket}/${key}`, {
    method: "PUT",
    headers: jsonHeaders,
    body: JSON.stringify({
      data_base64: base64,
      content_type: "image/png",
      metadata: { "render-id": renderId, tenant: "acct-1042" },
      cache_control: "private, max-age=300",
    }),
  });
  const body = await res.json();
  if (!body.ok) throw new Error(`${body.error?.code}: ${body.error?.message}`);
  return body.data;
}

const base64 = await renderPng("a flat grey square on white");
const stored = await storePng("kb-genimg-buffers", "renders/2026-07/hero-9f2.png", base64, "r-9f2");
console.log(stored.key, stored.size_bytes, stored.etag);

One call renders, one call stores, and both use the same Authorization header — that’s the part a stack of point solutions doesn’t give you, because there the render lives behind one vendor’s key and the bucket behind another’s. model: "auto" lets the router pick; pin a concrete id when you care which one drew the picture. The render we measured came back from qwen-image-2.0-pro, and the response carries an infrai block naming the vendor and the exact cost of that call.

Uploading the 941 KB PNG took 4,907 ms end to end from a laptop.

What “private” actually gets you

Set the object to signed-only and generate a link with op: "get":

curl -s -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-genimg-buffers/renders/2026-07/hero-9f2.png" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}'

The catch is that the signature governs when a link dies, not who may read the object. Strip the query string off that URL and the underlying vendor path still answers 200 — an unguessable path behaves like a bearer token that never expires. So treat the key itself as the secret, never log the vendor URL, and don’t put a render behind signed-only and call it access control. public-read isn’t an escape hatch either; the API rejects it with STORAGE_ACL_INVALID.

Confirm what landed before you write a database row pointing at it:

curl -s -X GET \
  "https://api.infrai.cc/v1/storage/object/head/kb-genimg-buffers/renders/2026-07/hero-9f2.png" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

head is free, returns found, size_bytes, etag and your metadata, and answers found: false rather than erroring when the key is absent.

What it costs

A write is $0.0001 per call and a read through GET /v1/storage/object/get/{bucket}/{key} is $0.0002; head, list and presign are free. The render itself dominates everything — the 1024×1024 call we measured billed $0.04 — verified 2026-07-26. Rates drift downward and discount campaigns run, so read today’s numbers rather than trusting a page:

curl -s "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  | python3 -c 'import json,sys
d = json.load(sys.stdin)
for c in d["capabilities"]:
    if c["id"].startswith("storage.object"):
        b = c.get("billing") or {}
        print(c["id"], b.get("price_usd", "free"), b.get("unit"))'

Storage is a rounding error next to generation, which is the honest reason not to over-engineer this half. GET /v1/account/usage is where per-tenant attribution lives once you’re tagging objects with a tenant id.

When to use something else

If your images are public marketing assets that want a CDN and on-the-fly resizing, Cloudinary earns its keep and this doesn’t compete. If you already run the AWS SDK for JavaScript v3 and your app is deep in the S3 ecosystem, PutObjectCommand streams a file without ever holding it in memory, which matters above a few hundred megabytes — base64-in-JSON doesn’t support streaming at all. Cloudflare R2 is the pick when egress is your dominant cost and you want zero-rated bandwidth.

What keeps generated images here is the next step rather than this one: the same key queues the thumbnail job, emails the user their gallery link, and reports one bill.

References

Browse more storage developer guides