Scan-then-promote: bucket events as the trigger for upload processing
Two buckets, one notification, one promotion step. Which upload paths on Infrai storage actually emit an event, and how to build the scan gate around the ones that don't.
Put uploads in a quarantine bucket, subscribe a notification to it, and let your scanner promote clean objects into the bucket your app actually reads from. On Infrai that promotion is a POST /v1/storage/object/copy followed by a delete, and the whole gate holds together because the reading side never has permission to see anything that hasn’t been copied. The event is a wake-up call, not the gate itself.
That distinction matters more than it sounds, because Infrai’s storage notifications don’t fire on every path bytes can take into a bucket. They fire on server-side writes. A presigned direct upload — the exact pattern most browser-upload tutorials teach — completes at the vendor edge and your subscription never hears about it, and neither does a multipart assembly. Design around that first, then wire the webhook.
Two buckets, because the event is advisory
The topology is boring on purpose:
export INFRAI_API_KEY=your_infrai_api_key
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/create \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kbg-events-0726","region":"eu-central-1","acl":"private"}'
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/create \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kbg-clean-0726","region":"eu-central-1","acl":"private"}'
Nothing your users can reach ever points at the quarantine bucket. Your render path signs links only against kbg-clean-0726, so a file that arrives infected, oversized or simply unprocessed is invisible to the product until something deliberately moves it. If your scanner dies for six hours, the failure mode is “avatars pending”, not “malware served”.
A note on the region field, since it looks load-bearing and isn’t: we passed eu-central-1 above and the presigned URLs that came back pointed at an ap-singapore host. The value is accepted and echoed in GET /v1/storage/bucket/get/{bucket}, but placement isn’t guaranteed from it. If data residency is a contractual requirement rather than a preference, that’s a real limitation and you should verify placement per bucket before you promise anything.
Subscribing, and the blast radius nobody mentions
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_notification/kbg-events-0726 \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["object.created","object.deleted"],"target":{"url":"https://hooks.your-app.example/storage"}}'
Three things about that call are worth knowing before you run it. The path takes a bucket, but the registration behaves account-wide: your endpoint will receive events for objects in other buckets on the same account too, so the handler has to filter on the bucket itself rather than trusting that only quarantine traffic arrives. Registrations are additive — call it twice with two URLs and both get deliveries. And there is no list route and no delete route, so a subscription you create is effectively permanent. Point it at a domain you own, never at a request-catcher.
Deliveries arrive as a plain JSON POST with an x-infrai-event header naming the event. There’s no HMAC signature header, which means the callback URL is the only secret protecting it — put a long random path segment on it, and treat the body as a hint rather than as evidence.
What actually fires
| Write path | Notification | Why it matters for a scan gate |
|---|---|---|
PUT /v1/storage/object/put/{bucket}/{key} | fires, ~2s | Server-side writes are the reliable trigger |
DELETE /v1/storage/object/delete/{bucket}/{key} | fires | Useful for cache invalidation, not for scanning |
Presigned PUT from a browser or CLI | does not fire | The gate needs a client callback or a sweeper |
POST /v1/storage/multipart/complete/{upload_id} | does not fire | Large-file assembly is silent |
The second half of that table is why “browser uploads straight to the bucket, webhook does the rest” doesn’t work as advertised here. You need one of two fallbacks: have the client call your API after its PUT succeeds and verify the claim with a free head call, or run a periodic sweep with GET /v1/storage/object/list/{bucket} over the quarantine prefix and process anything with no matching row in your database. We’d run both — the callback for latency, the sweep for correctness.
Worth flagging: there’s also no CORS configuration route on this API, so a browser fetch to a presigned upload URL fails preflight anyway. A real preflight against one returned 403 in our testing. If your requirement is genuinely browser-direct, Cloudflare R2 or S3 with a CORS rule is the right backend and you should use it.
The receiver
import { createServer } from "node:http";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function readJson(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return {}; }
}
createServer(async (req, res) => {
if (req.method !== "POST") { res.writeHead(405).end(); return; }
const event = req.headers["x-infrai-event"] ?? "unknown";
const body = await readJson(req);
const bucket = body.bucket ?? body.bucket_name;
const key = body.key ?? body.object_key;
// Ack immediately; the vendor will not wait for a scan to finish.
res.writeHead(202, { "content-type": "application/json" });
res.end(JSON.stringify({ accepted: true }));
if (event !== "object.created" || bucket !== "kbg-events-0726" || !key) return;
try {
const head = await fetch(`${API}/v1/storage/object/head/${bucket}/${key}`, {
headers: { authorization: `Bearer ${KEY}` },
});
const meta = await head.json();
if (!meta?.data?.found) return; // deleted between event and read
if (meta.data.size_bytes > 25 * 1024 * 1024) return;
const job = await fetch(`${API}/v1/queue/publish`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({ queue: "kbg-scan-jobs", payload: { bucket, key, etag: meta.data.etag } }),
});
if (!job.ok) console.error("enqueue failed", job.status, await job.text());
} catch (err) {
console.error("receiver error", err);
}
}).listen(8080);
The head call is free and it’s the only authoritative statement about the object — size, etag, stored content type. Everything the webhook told you is unverified input.
Promotion is a copy, then a delete
curl -sS -X POST https://api.infrai.cc/v1/storage/object/copy \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"src_bucket":"kbg-events-0726","src_key":"incoming/2026/07/26/scan-sample.txt","dst_bucket":"kbg-clean-0726","dst_key":"media/2026/07/26/scan-sample.txt"}'
curl -sS -X DELETE \
"https://api.infrai.cc/v1/storage/object/delete/kbg-events-0726/incoming/2026/07/26/scan-sample.txt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Copy first, delete second, and make the delete idempotent in your worker — it already is on the API side, so a retried job is harmless. If the scan says the file is dirty, skip the copy and delete straight away; the quarantine bucket should also carry a lifecycle rule so anything your worker never reached expires on its own.
Verify a promotion the same way the worker does:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-events-0726/incoming/2026/07/26/scan-sample.txt" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "incoming/2026/07/26/scan-sample.txt",
"size_bytes": 37,
"etag": "4e0e5288673bc16d4a2e0d1095e1f1b8",
"content_type": "text/plain",
"last_modified": "2026-07-26T05:05:32Z"
}
}
A missing key answers with found: false and HTTP 200, not a 404 — check the field, not the status.
What the moving parts cost
Reads and writes are the only billed calls in this chain. A server-side object/put is $0.0001 per call and object/copy the same; object/get is $0.0002; a queue publish is $0.00002. Subscribing, heading, listing and lifecycle rules are free and rate-limited. New accounts start with $2 free credit, which is thousands of promotions before you pay anything. Those figures were verified 2026-07-26 and rates here drift downward, so read today’s numbers rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('storage.')])"
The durable argument isn’t the per-call rate, it’s that the scanner’s queue, the error capture when a scan crashes, and the notification email to the uploader all sit behind the same key and the same invoice. S3 plus Lambda plus SQS is a stronger event system in isolation — retries, DLQs and filtering are far more configurable there — and if event fan-out is the hard part of your product, stick with it. If the hard part is shipping a scan gate this quarter without adding three accounts, the consolidation is worth more than the configurability.