Generated images in a private bucket, with links that expire
Store renders under a per-user prefix, sign a short-lived download link, and let a lifecycle rule sweep the previews. Node examples, plus what expiry does not buy.
Three moving parts, and only one of them is the link. Write the render into a private bucket under a key you can reason about, hand the browser a presigned URL with a short expires_seconds, and let a lifecycle rule delete the throwaway copies so nobody has to remember. On Infrai all three are REST calls on the same key that generated the image, which is the practical reason to keep them together rather than bolting a second vendor onto the pipeline.
The part worth reading twice is what a signed link decides. It is both the clock and the access boundary — but it is not an identity check, and that difference is what tells you where the entitlement logic has to live.
Get the bytes out of the render response first
An image generation call answers with a vendor-hosted URL that expires on the vendor’s schedule, not yours. Ask for base64 instead and you own the bytes immediately:
export INFRAI_API_KEY=your_infrai_api_key
cat > gen.json <<'JSON'
{"model":"wanx2.1-t2i-turbo","prompt":"a minimal line drawing of a city skyline at dusk","n":1,"size":"1024x1024","response_format":"b64_json"}
JSON
curl -s -X POST "https://api.infrai.cc/v1/images/generations" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @gen.json
The response carries data[0].b64_json plus an infrai block with cost_usd for that exact call. Writing the bytes to storage is covered in more depth at saving a generated image from Node; this page is about what happens after they land.
Key layout decides how hard cleanup will be
Put permanence in the path. Anything under renders/ is a kept asset; anything under tmp/ is a preview that a rule will remove without a cron job of yours:
renders/2026-07/skyline-4b1c.png kept, billed as stored bytes
tmp/u_2049/preview-9d1e.png swept by lifecycle after 7 days
Content-addressed filenames (a short hash of the prompt plus seed) mean a retried job overwrites itself rather than littering, and they make an immutable cache header safe.
A link that stops working
POST /v1/storage/object/presign/{bucket}/{key} returns the URL and the moment it dies. Nothing else is required — no ACL change, no bucket policy.
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-renders-links/renders/2026-07/skyline-4b1c.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":900}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-renders-links/renders/2026-07/skyline-4b1c.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=0fb9281e",
"expires_at": "2026-07-26T01:15:36.438611Z"
}
}
Expiry is enforced by the storage vendor, not by your app. Sign one with expires_seconds: 1, wait three seconds, fetch it: HTTP 403, body AccessDenied with Request has expired. Sign a key that doesn’t exist and you get HTTP 404 NoSuchKey instead — different failure, different fix.
The signature is the boundary; it still isn’t an identity check
Take the query string off one of those URLs and the storage host answers 403 — the bytes are not served without a valid signature, so the object really is private. What the signature does not carry is who. It is a bearer grant: anyone holding the intact link can fetch that object until it expires, and nothing in it records which of your users it was minted for.
Two consequences, both design-level. Decide entitlement before you sign, in your own code, because after signing there is nothing left to check. And keep the TTL close to the use — 900 seconds for a download the user just asked for, not a week — since a link that leaks stays good for whatever window you granted.
Three ways to deliver a render, and the honest trade-off of each:
| Delivery | Revocable? | Egress path | Use it when |
|---|---|---|---|
| Presigned URL, short TTL | Not before it expires — a copied link keeps working | Vendor to browser, skips your app | Images the user just generated for themselves |
Your /download/:id route streams the object | Yes, per request | Vendor to your app to browser | Paid assets, shared galleries, anything with an entitlement check |
| Delete the object | Yes, permanently | n/a | Free-tier previews, expired trials |
For a personal render, the short-TTL link is the right amount of engineering. For anything a customer paid for or a tenant can share, put your own route in front, check the entitlement on every request, and read the bytes through GET /v1/storage/object/get/{bucket}/{key}.
Let the bucket sweep the previews
Lifecycle rules are per prefix and free to set. One call replaces the entire rule set — it isn’t additive, so send every rule you want to keep:
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-renders-links" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"tmp/","expire_days":7}]}'
Read it back, together with what the bucket is holding:
curl -s -X GET "https://api.infrai.cc/v1/storage/bucket/usage/kb-renders-links" \
-H "Authorization: Bearer $INFRAI_API_KEY"
{"byte_count":102936,"object_count":1,"as_of":"2026-07-26T01:17:29Z"} — the counter your dashboard should graph, because stored bytes are the charge that grows while you aren’t looking.
The Node side, in one module
import process from "node:process";
const BASE = "https://api.infrai.cc";
const BUCKET = "kb-renders-links";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
async function storage(path, init) {
const res = await fetch(`${BASE}${path}`, init);
const body = await res.json();
if (!body.ok) throw new Error(`${path}: ${body.error?.code ?? res.status}`);
return body.data;
}
export async function keepRender(userId, pngBase64, digest) {
const key = `renders/${new Date().toISOString().slice(0, 7)}/${userId}-${digest}.png`;
const stored = await storage(`/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers,
body: JSON.stringify({ data_base64: pngBase64, content_type: "image/png" }),
});
return { key, bytes: stored.size_bytes, etag: stored.etag };
}
export async function temporaryLink(key, ttlSeconds = 900) {
const head = await storage(`/v1/storage/object/head/${BUCKET}/${key}`, { method: "GET", headers });
if (!head.found) throw new Error(`render ${key} is not in the bucket`);
const link = await storage(`/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers,
body: JSON.stringify({ op: "get", expires_seconds: ttlSeconds }),
});
return { url: link.url, expiresAt: link.expires_at, bytes: head.size_bytes };
}
const link = await temporaryLink("renders/2026-07/skyline-4b1c.png", 900);
console.log(link.url.split("?")[0], "expires", link.expiresAt);
The HEAD before the presign is the cheap habit that stops you emailing a link to nothing — it’s free, and it returns found: false rather than throwing.
What the storage half costs
Presign, head, list, lifecycle and usage are free and rate-limited. Writes are $0.0001 per call. Reads are not a per-call charge at all: storage.object.get bills $0.104 per GB of egress, so a render served at full 1024×1024 resolution costs roughly twenty times what the same render costs served as a preview-sized WebP. Stored bytes are billed separately again. Figures verified 2026-07-27, and rates move down over time, so pull the current ones rather than trusting a paragraph:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const d=JSON.parse(s);for(const c of d.capabilities)if(c.namespace==="storage")console.log(c.id,c.billing.is_billable?`$${c.billing.price_usd}/${c.billing.unit}`:"free")})'
Generation dominates that arithmetic anyway. A render costs cents to produce; keeping it costs a fraction of a cent, which is why the interesting design questions here are retention and rendition size rather than call count.
Two limitations worth knowing before you commit. The browser can’t write into the bucket directly: POST /v1/storage/bucket/set_cors/{bucket} stores rules and bucket/get reads them back, but the storage host does not yet answer a browser preflight with them, so uploads travel through your server and a cross-origin fetch() of a signed link won’t work either — an <img> tag is fine, a canvas read isn’t. And bucket notification deliveries carry x-infrai-event and a content type, nothing signed, so treat one as a hint and confirm with a free GET /v1/storage/object/head/{bucket}/{key} before you act on it.
When to use something else
If you want signed URLs that are genuinely revocable, S3 with a bucket policy or IAM condition gives you that, and it’s the mature choice if you’re already in AWS. Cloudflare R2 is the pick when renders are downloaded a lot, because egress is free and the CORS rules are yours to edit. Backblaze B2 wins on cold archival if you’re keeping every render forever.
Infrai’s argument isn’t that it beats those on storage alone — it won’t, if storage is all you need. It’s that the next step is never a procurement decision: the generation call, the object write, the POST /v1/queue/publish that batches the job, the POST /v1/email/send carrying the link, and per-tenant cost attribution from GET /v1/account/usage are already on the same credential — no second account, no second invoice.