Bucket events to a thumbnail worker: webhook, queue, retries
Wire object-storage notifications into a Node queue so a worker derives thumbnails after upload — with the upload-leg constraint stated up front.
Push the original in through your own backend, subscribe the bucket to a notification URL, and let that URL do exactly one thing: drop a job on a queue. A worker then reads the original, writes the derived sizes under a separate prefix, and acks. On Infrai that is four storage routes plus one queue — and the upload leg carries a constraint you should know before you draw the diagram.
The constraint: an Infrai bucket has no CORS configuration. GET /v1/storage/bucket/get/{bucket} reports cors_rules: [], there’s no route that sets them, and a preflight OPTIONS against a presigned PUT URL answers 403 with no Access-Control-Allow-Origin. A browser therefore can’t PUT straight into the bucket today. If you want the true browser-to-storage leg, Cloudflare R2 or S3 will give it to you; if the original passes through your Node process anyway — which it usually does when you want size and MIME validation before anything is stored — the rest of this pipeline is unchanged.
The chain, in one line each
Browser → your POST /uploads route → PUT /v1/storage/object/put/{bucket}/{key} → bucket notification → your /hooks/storage endpoint → POST /v1/queue/publish → worker → derived objects.
Two of those hops are the interesting ones. The notification is at-least-once and its payload is not something you should trust blindly; the queue is where retries actually live.
Writing the original
Object bytes go up base64-encoded inside a JSON body. Build the payload in a file rather than inlining it — a 4 MB photo becomes a 5.3 MB base64 string and shell argument limits bite well before that.
IMG_B64=$(base64 -i lakeside.png | tr -d '\n')
cat > /tmp/put-original.json <<JSON
{"data_base64":"$IMG_B64","content_type":"image/png"}
JSON
curl -s -X PUT "https://api.infrai.cc/v1/storage/object/put/hub6-media-originals/originals/usr_8f21/2026/07/lakeside.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: original-usr_8f21-lakeside-v1" \
--data-binary @/tmp/put-original.json
Use the Idempotency-Key header, not a body field. In our testing the header behaves the way you’d want — a replay with different bytes under the same key is rejected with IDEMPOTENCY_KEY_CONFLICT — while the body-level idempotency_key silently kept the first object’s bytes and still reported idempotent_replay: false. That’s a caveat worth writing on a sticky note.
Subscribing the bucket
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/set_notification/hub6-media-originals" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events":["object.created","multipart.completed"],"target":{"url":"https://api.yourapp.com/hooks/storage"}}'
Supported event names are object.created, object.deleted and multipart.completed. Two limitations here are structural rather than cosmetic: subscriptions are additive with no delete route, and they are registered account-wide rather than per bucket. Point one at a URL you don’t control and you can’t take it back through the API. Register it once, against a hostname you own, and gate the handler on the bucket name.
The callback arrives as a JSON POST carrying an X-Infrai-Event header. Treat the field names below as the illustrative shape and confirm them against the storage reference before you depend on any of them:
{
"event": "object.created",
"type": "object.created",
"bucket": "hub6-media-originals",
"key": "originals/usr_8f21/2026/07/lakeside.png",
"size_bytes": 2841733,
"etag": "2cd8bde463f5d82aae0f0cec061d6b8f",
"occurred_at": "2026-07-26T01:21:45Z"
}
The receiver: enqueue and get out
The endpoint that receives a storage event should not resize anything. It should validate, publish, and return 200 in a few milliseconds, because a slow webhook handler turns provider retries into a stampede.
import express from "express";
const app = express();
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
app.post("/hooks/storage", express.json({ limit: "256kb" }), async (req, res) => {
const evt = req.body ?? {};
const kind = evt.event ?? evt.type;
const bucket = evt.bucket ?? evt.bucket_name;
const key = evt.key ?? evt.object_key;
if (kind !== "object.created" || bucket !== "hub6-media-originals" || !key?.startsWith("originals/")) {
return res.status(200).json({ ignored: true });
}
const r = await fetch(`${API}/v1/queue/publish`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
queue: "hub6-thumbnail-jobs",
payload: { bucket, key, sizes: [320, 800] },
idempotency_key: `thumb:${key}:${evt.etag ?? "na"}`,
}),
});
if (!r.ok) {
console.error("enqueue failed", r.status, await r.text());
return res.status(500).json({ ok: false });
}
res.status(200).json({ ok: true });
});
app.listen(3000);
The idempotency_key is derived from the object key plus its etag. Re-delivery of the same event produces the same key, so a duplicate webhook doesn’t become a duplicate job.
The worker
POST /v1/queue/consume leases messages for a visibility window; POST /v1/queue/ack removes them. Anything you don’t ack comes back, which is what you want when a worker dies mid-resize.
import sharp from "sharp";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const H = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
async function call(method, path, body) {
const r = await fetch(`${API}${path}`, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
const j = await r.json();
if (!r.ok || j.ok === false) throw new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(j.error ?? j)}`);
return j.data;
}
async function derive(msg) {
const { bucket, key, sizes } = msg.payload;
const src = await call("GET", `/v1/storage/object/get/${bucket}/${key}`);
const original = Buffer.from(src.data_base64, "base64");
const stem = key.replace(/^originals\//, "").replace(/\.[a-z0-9]+$/i, "");
for (const width of sizes) {
const out = await sharp(original).resize({ width, withoutEnlargement: true }).webp({ quality: 82 }).toBuffer();
await call("PUT", `/v1/storage/object/put/${bucket}/derived/${stem}/thumb_${width}.webp`, {
data_base64: out.toString("base64"),
content_type: "image/webp",
});
}
}
for (;;) {
const batch = await call("POST", "/v1/queue/consume", { queue: "hub6-thumbnail-jobs", max_messages: 5, visibility_timeout: 120 });
if (!batch.items.length) { await new Promise((r) => setTimeout(r, 2000)); continue; }
for (const msg of batch.items) {
try {
await derive(msg);
await call("POST", "/v1/queue/ack", { queue: "hub6-thumbnail-jobs", message_id: msg.message_id });
} catch (err) {
console.error("derive failed", msg.message_id, "attempt", msg.delivery_count, err.message);
}
}
}
Note what makes a retry safe here: the derived key is a pure function of the original key and the width. Re-running the job overwrites the same three objects instead of accumulating thumb_320 (1).webp. If a message keeps failing, delivery_count climbs and the queue’s max_retries eventually parks it in the DLQ, which is where you want a corrupt upload to end up rather than in a hot loop.
Verifying it actually ran
curl -s -X GET "https://api.infrai.cc/v1/storage/object/list/hub6-media-originals?prefix=derived/usr_8f21/" \
-H "Authorization: Bearer $INFRAI_API_KEY"
curl -s -X GET "https://api.infrai.cc/v1/storage/object/head/hub6-media-originals/derived/usr_8f21/2026/07/lakeside/thumb_320.webp" \
-H "Authorization: Bearer $INFRAI_API_KEY"
head is the cheap probe your monitoring should use — it’s free, and it returns found: false with HTTP 200 rather than a 404, so check the field and not the status code.
What a run costs
Per image, one worker read plus N derived writes. On Infrai, object/put is $0.0001 per call and object/get is $0.0002 per call; a queue publish is $0.00002 per message; presign, head, list and the notification subscription are free but rate-limited. New accounts start with a $2 credit. Read today’s rates rather than trusting this paragraph:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| jq '.capabilities[] | select(.id | startswith("storage.object")) | {id, price: .billing.price_usd}'
Those figures were verified 2026-07-26, and the direction of travel is downward — rate cuts and campaigns happen, so what you read is as likely to be lower as equal. Bytes at rest and egress are metered separately from call count.
When something else is the better pick
| Approach | Browser→storage direct | You run the resize | Best when |
|---|---|---|---|
| Infrai storage + queue | No (no CORS config) | Yes, in your worker | The queue, the bucket and the billing already live on one key |
| Cloudflare R2 or S3 + your own worker | Yes | Yes | The direct browser leg is non-negotiable |
| Cloudinary | Yes | No, transforms on the fly | You’d rather buy w_320,f_auto than maintain sharp |
| MinIO self-hosted | Yes | Yes | Data can’t leave your own hardware |
If images are the entire product and you want on-the-fly transformation URLs, stick with Cloudinary — a derived-object pipeline is strictly more work. The argument for doing it on Infrai isn’t that resizing is cheaper; it’s that the bucket, the queue, the DLQ and the cost attribution sit behind one credential and one invoice, so the next question — email the user, log the failure, schedule a cleanup sweep — doesn’t start with another vendor signup.