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 URL is for. Expiry it delivers exactly. Privacy it does not — we tested that below, and it changes the design.
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":"download","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.
Expiry isn’t privacy, and that changes the design
Here’s the finding that surprised us. Strip the query string off one of those URLs and the object still comes back, HTTP 200, all 102,936 bytes of it — even after calling POST /v1/storage/object/set_acl/{bucket}/{key} with signed-only. The signature governs when the link stops working; it doesn’t govern who may read the underlying URL. The path segment is long and random, so it isn’t guessable, but it behaves like a bearer token that never expires once someone has it.
Three ways to deliver a render, and the honest trade-off of each:
| Delivery | Revocable? | Egress path | Use it when |
|---|---|---|---|
| Presigned URL, short TTL | No — leaked URL 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: "download", 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. The metered pair is writes at $0.0001 per call and reads at $0.0002 per call, plus stored bytes — figures verified 2026-07-26, 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; the storage call to keep it costs a hundredth of a cent, which is why the interesting design question is retention, not per-call price.
Two limitations worth knowing before you commit: there’s no bucket CORS route, so a browser can’t fetch() a signed URL cross-origin — an <img> tag is fine, a canvas read isn’t — and an upload made through a presigned URL raises no object.created notification, because those bytes never pass through the API.
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 generation call, the object write, the queue that batches the job, the email with the link, and the per-tenant cost attribution all sit behind one credential and one invoice.