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].urlfetch → arrayBuffer() → Buffer → toString("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"
  }
}

One thing to know about metadata. Keys are normalised to hyphens on the way in, so render_id is stored and read back as render-id — the underscore you typed is not the key you’ll match on later. Write them hyphenated and the round trip is boring: render-id comes back on the object and on every later head, exactly as sent.

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}'

Strip the query string off that URL and request the bare vendor path: 403. The signature is what authorises the read, so a render sitting in a private bucket is not quietly served to anyone who guesses its path. public-read isn’t available as an escape hatch either — set_acl takes private and signed-only and rejects the rest with STORAGE_ACL_INVALID.

The catch is that the signature governs whether a request is authorised, not who made it. An intact link is a bearer token until expires_at, and there’s no route to kill one early, so mint short TTLs at click time rather than long ones you mail out, and keep the URL out of logs and referrer headers.

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 head, list and presign are free and rate-limited.

Reads are priced in a different unit, which is worth internalising before you design a gallery. GET /v1/storage/object/get/{bucket}/{key} meters at $0.104 per GB — on the bytes that come back, not on the number of calls — so what drives that line is the rendition you serve, not how often you serve it. A grid of 40 KB thumbnails and a grid of 4 MB originals are two orders of magnitude apart on the same page.

Both are noise next to the render itself: the 1024×1024 call we measured billed $0.04, verified 27 July 2026. Rates drift and campaigns run, so read today’s numbers — and the unit beside them — 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.

A note on that snippet: it prints the unit alongside the number on purpose. A rate can move without changing shape, but a unit moving from per-call to per-GB rewrites your cost model, and only the second column tells you which happened.

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, and all of it is already on the same key you just used to render and store. POST /v1/queue/publish hands the object key to the thumbnail worker, POST /v1/errors/capture records the render that came back empty, POST /v1/email/send sends the user their gallery link, and GET /v1/account/usage bills it back to a tenant — no second account, no second vendor, one invoice at the end of the month.

References

Browse more storage developer guides