Where AI-generated images should live, and how to expire the link
Three homes for generated image output compared, plus a download link that expires because your own route says so rather than because a CDN signature did.
Put the durable copy in your own object storage, keep the generator’s output out of the request path, and hand the user a link your own service signs and can revoke. On Infrai the transform routes return the produced bytes inline and keep nothing unless you ask; setting store: true gives you an image_id with a retention window instead of a public URL. There is no CDN link to leak, which changes what “temporary” has to mean.
That last part surprises people, so let’s be concrete about it. Infrai’s POST /v1/image/process answers with a data: URI in the url field — the bytes themselves, base64, in the JSON body — whether or not you stored the asset. A stored asset is readable afterwards through GET /v1/image/get/{id}, and that call needs your account key. Your users never hold a credential that reaches it.
Three homes, and what each one costs you
Inline only (store omitted) | Stored on Infrai (store: true) | Your bucket + CDN | |
|---|---|---|---|
| Lives for | the length of one HTTP response | a retention window, then it’s swept | until you delete it |
| Read path | you already have the bytes | GET /v1/image/get/{id} with the account key | public or signed URL |
| A leaked link gives | nothing — there is no link | nothing — the id needs your key | the file, until expiry |
| Cleanup work | none | DELETE /v1/image/delete/{id}, or let the TTL run | lifecycle rules |
| Bad at | anything the user revisits | serving traffic; every read is an API call | latency to first byte on cold objects |
The first column is underrated. A generated preview that the user either saves or discards in the next thirty seconds does not need a home at all — return it in the same response and let the browser hold it. Fewer objects, no sweeper, nothing to bill.
The middle column is a staging area, not a delivery tier. It’s where a generated image sits between the model finishing and your worker copying it into the bucket you actually serve from.
Prove the retention boundary yourself
Two calls, one difference. Without store, the asset does not exist afterwards:
export INFRAI_API_KEY="your_infrai_api_key"
SRC=$(base64 < ./render.jpg | tr -d '\n')
curl -sS -X POST "https://api.infrai.cc/v1/image/process" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"image\":{\"base64\":\"${SRC}\"},\"ops\":[{\"op\":\"resize\",\"params\":{\"width\":1024,\"height\":768,\"fit\":\"inside\"}}],\"format\":\"jpeg\",\"store\":true}"
{
"ok": true,
"data": {
"image_id": "pim_ee2e4a1c5a3f42a1e26c8293",
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
"format": "jpeg",
"width": 1024,
"height": 768,
"size_bytes": 4895,
"sha256": "ae30ee1b729432eedce5af9a062194b0a8acb9454aec55fde324cc4e2dba3880",
"ops_applied": ["resize(1024x768,fit=inside)", "format_convert(jpeg,q=90)"],
"cost_usd": 0.000393
}
}
Read it back later with a concrete id — this one is a real asset on our test account:
curl -sS "https://api.infrai.cc/v1/image/get/pim_ee2e4a1c5a3f42a1e26c8293" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data | {image_id, format, width, height, size_bytes, created_at}'
Run the same GET /v1/image/get/{id} against an id produced without store: true and you get the honest answer:
{
"ok": false,
"error": {
"code": "IMAGE_NOT_FOUND",
"http_status": 404,
"message": "no image with id 'pim_ec414f53900ee5580f188bef'",
"retryable": false
}
}
Worth flagging because it catches teams out during an incident: an image_id in your logs is not a promise that the bytes still exist. If the row in your database matters, copy the bytes to storage you control at generation time, not at first download.
The download link, minted by you
A presigned URL is a bearer token wearing a URL costume — once issued you can’t take it back, and it turns up in referer headers and support screenshots. Since Infrai hands out no public URL here, you’re free to do the better thing: a short opaque token bound to a user, an asset and a deadline, checked on every request.
// download-link.mjs — Node 22
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const KEY = process.env.INFRAI_API_KEY;
const SECRET = process.env.DOWNLOAD_SECRET;
if (!KEY || !SECRET) throw new Error("set INFRAI_API_KEY and DOWNLOAD_SECRET");
const TTL_MS = 10 * 60 * 1000;
const used = new Set();
function mint(imageId, userId) {
const claim = `${imageId}.${userId}.${Date.now() + TTL_MS}.${randomUUID()}`;
const mac = createHmac("sha256", SECRET).update(claim).digest("base64url");
return `${Buffer.from(claim).toString("base64url")}.${mac}`;
}
function open(token) {
const [payload, mac] = String(token).split(".");
if (!payload || !mac) return null;
const claim = Buffer.from(payload, "base64url").toString();
const want = createHmac("sha256", SECRET).update(claim).digest("base64url");
if (want.length !== mac.length || !timingSafeEqual(Buffer.from(want), Buffer.from(mac))) return null;
const [imageId, userId, expiresAt] = claim.split(".");
if (Number(expiresAt) < Date.now()) return null;
if (used.has(token)) return null;
return { imageId, userId };
}
createServer(async (req, res) => {
const token = new URL(req.url, "http://localhost").searchParams.get("t");
const claim = open(token);
if (!claim) { res.writeHead(403).end("link expired or already used"); return; }
used.add(token);
const upstream = await fetch(`https://api.infrai.cc/v1/image/get/${claim.imageId}`, {
headers: { authorization: `Bearer ${KEY}` },
});
const json = await upstream.json();
if (!upstream.ok || json.ok === false) {
res.writeHead(404).end(json?.error?.code ?? "gone");
return;
}
const [header, b64] = json.data.url.split(",");
res.writeHead(200, {
"content-type": header.slice(5).replace(";base64", ""),
"content-disposition": `attachment; filename="render-${claim.imageId}.${json.data.format}"`,
"cache-control": "private, no-store",
});
res.end(Buffer.from(b64, "base64"));
}).listen(8080);
console.log("try:", `http://localhost:8080/?t=${mint("pim_ee2e4a1c5a3f42a1e26c8293", "user_42")}`);
Ten minutes, one use, and a used set you’d back with Redis in anything real. Revocation is a delete from that set, which no signed CDN URL will ever give you.
When many images finish at once
A generation run that produces forty variants shouldn’t be forty round trips. POST /v1/image/batch/submit takes an items array and a webhook_url, and GET /v1/image/batch/status/{id} reports each item separately, so one bad frame doesn’t fail the job:
curl -sS "https://api.infrai.cc/v1/image/batch/status/imgjob_7b89c8453f35187857424ff2" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '{status: .data.status, items: [.data.items[] | {index, status, id: .result.image_id}]}'
In our testing a two-item job came back completed in about 265 ms, which means the webhook is a convenience rather than a necessity at small sizes. Poll it for a handful of items; wire the callback when a job is hundreds.
Cost shape, and where this isn’t the right tool
Verified 2026-07-26: POST /v1/image/process and the batch routes are free and rate-limited, POST /v1/image/compress is $0.003 per call, POST /v1/image/smart_crop $0.015, and POST /v1/image/background_remove $0.05. Retaining an asset with store: true adds a small storage line item that shows up in the response’s cost_usd. New accounts get $2 in credit. Prices here trend downward and campaigns run, so read them live:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.namespace == "image") | {id, price: .billing.price_usd, free: .billing.free}'
The drawback of the design above is real: every download is an API call plus a proxy hop, so you’re paying latency that a CDN edge would have absorbed. If your generated images are public, hot, and re-fetched thousands of times, Cloudinary or ImageKit will serve them better than a Node process ever will — both give you a delivery URL with signature-based expiry as a product feature, and that’s the correct thing to buy. This approach earns its keep when the images are private, the audience is one user each, and you’d rather have revocation than edge caching.
The other half of the argument is what sits beside the image routes on the same key. The queue that runs the generation, the object store that takes the durable copy, the error tracker that catches a 404 on download, and per-tenant cost attribution — all one account, one bill, no second integration to keep in sync. Point solutions each win their own column; the stack is where the arithmetic changes.