Scan-then-promote: bucket events as the trigger for upload processing

Two buckets, one notification subscription, one promotion step. Which write paths emit a storage event, why the delivery is a claim rather than proof, and the head call that settles it.

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 the subscription is one call, the promotion is POST /v1/storage/object/copy followed by a delete, and the gate holds because the reading side never signs a URL against anything that hasn’t been copied.

The design rule that matters: a webhook delivery is a claim, not proof. It tells you something probably happened; GET /v1/storage/object/head/{bucket}/{key} tells you what is actually there, for free, and that’s the call your worker should branch on. Everything below was run against api.infrai.cc on 27 July 2026.

Two buckets, one direction of travel

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-quarantine","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","acl":"private"}'

Nothing a user can reach points at the quarantine bucket. Your render path signs links only against kbg-clean, so a file that arrives infected, oversized or simply unprocessed is invisible to the product until something deliberately moves it. If the scanner is down for six hours the failure mode is “uploads pending”, not “malware served”.

Subscribe, list, unsubscribe

Subscriptions have a full lifecycle, which matters more than it sounds — a webhook you can’t enumerate is a webhook you’ll forget you registered.

curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_notification/kbg-quarantine \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"events":["object.created","object.deleted","multipart.completed"],"target":{"url":"https://hooks.yourapp.com/storage/8f2c1d"}}'
{ "ok": true, "data": { "subscription_id": "stnf_945bbe229e8a138f37cccc59" } }
curl -sS "https://api.infrai.cc/v1/storage/bucket/notifications/kbg-quarantine" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "subscription_id": "stnf_945bbe229e8a138f37cccc59",
        "bucket_id": "bkt_023687386d6c43ad9b2e65",
        "events": ["object.created", "object.deleted", "multipart.completed"],
        "webhook_id": null,
        "url": "https://hooks.yourapp.com/storage/8f2c1d"
      }
    ],
    "next_cursor": null
  }
}

DELETE /v1/storage/bucket/notification/delete/{bucket}/{subscription_id} retires one, answering {"subscription_id": "…", "deleted": true}. Subscriptions are scoped to the bucket in the path — a write to another bucket on the same account doesn’t reach this endpoint — and registrations are additive, so two URLs both get deliveries.

The target URL is validated when you register it: it has to be https:// and it has to resolve, so a typo or a loopback address is rejected at subscribe time rather than discovered later as a silence. Put a long random segment in the path (/storage/8f2c1d above) and treat that path as a shared secret.

What a delivery looks like

Deliveries arrive within a second or two as a JSON POST carrying an x-infrai-event header:

{
  "type": "object.created",
  "event": "object.created",
  "account_id": "acct_email_77c768e42148275b",
  "bucket_id": "bkt_023687386d6c43ad9b2e65",
  "bucket": "kbg-quarantine",
  "key": "incoming/2026/07/27/scan-sample.txt",
  "timestamp": "2026-07-27T12:02:36.066379+00:00",
  "object": {
    "key": "incoming/2026/07/27/scan-sample.txt",
    "size_bytes": 12,
    "etag": "464112632546a9e393a2fd8dde8000e7",
    "content_type": "text/plain",
    "last_modified": "2026-07-27T12:02:36Z"
  }
}

The object block is convenient and it’s still input from the network. Verify before you act on it.

Which write path emits which event

Write pathEventWhat it’s good for
PUT /v1/storage/object/put/{bucket}/{key}object.createdserver-side writes: relayed uploads, generated artefacts
POST /v1/storage/multipart/complete/{upload_id}multipart.completedlarge-file assembly, where the object only exists at the end
DELETE /v1/storage/object/delete/{bucket}/{key}object.deletedcache and index invalidation

Every row there is a write that went through the storage API, and that’s the useful pattern: uploads reach the quarantine bucket through your own route, which relays the bytes with PUT /v1/storage/object/put/{bucket}/{key}, and the relay is what raises object.created. Your handler gets to reject an over-sized or wrong-typed file before it ever becomes an object, and the event lane then covers the whole intake path rather than half of it.

For belt and braces, run a periodic GET /v1/storage/object/list/{bucket} sweep over the quarantine prefix and pick up anything with no row in your database. We’d run both — the webhook for latency, the sweep for correctness — because a receiver that was down for a deploy window is the ordinary case, not the exotic one.

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");
const QUARANTINE = "kbg-quarantine";

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);

  // Ack first: the sender is not going to wait for a scan to finish.
  res.writeHead(202, { "content-type": "application/json" });
  res.end(JSON.stringify({ accepted: true }));

  const wanted = event === "object.created" || event === "multipart.completed";
  if (!wanted || body.bucket !== QUARANTINE || !body.key) return;

  try {
    const head = await fetch(`${API}/v1/storage/object/head/${QUARANTINE}/${body.key}`, {
      headers: { authorization: `Bearer ${KEY}` },
    });
    const meta = await head.json();
    if (!meta.ok || !meta.data.found) return;              // gone between event and read
    if (meta.data.size_bytes > 25 * 1024 * 1024) return;   // out of policy: leave it to expire

    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: QUARANTINE, key: body.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);

Two things that keep this honest. The 202 goes out before any work starts, because a receiver that scans inline is a receiver that times out. And the etag from the head — not from the payload — travels with the job, so a worker that picks it up ten minutes later can tell whether the object it’s looking at is still the one that triggered the event.

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-quarantine","src_key":"incoming/2026/07/27/scan-sample.txt","dst_bucket":"kbg-clean","dst_key":"media/2026/07/27/scan-sample.txt"}'

curl -sS -X DELETE \
  "https://api.infrai.cc/v1/storage/object/delete/kbg-quarantine/incoming/2026/07/27/scan-sample.txt" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Copy first, delete second, and let the worker be re-runnable: a delete of a key that’s already gone is harmless, and STORAGE_OBJECT_NOT_FOUND in a batch response is information, not a failure. If the scan says dirty, skip the copy. Give the quarantine bucket a lifecycle rule as well, so anything the worker never reached expires on its own instead of accruing rent forever.

Verify a promotion the way the worker does:

curl -sS "https://api.infrai.cc/v1/storage/object/head/avatars-demo/u/usr_8412/avatar-512.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "u/usr_8412/avatar-512.png",
    "size_bytes": 70,
    "etag": "2cd8bde463f5d82aae0f0cec061d6b8f",
    "content_type": "image/png",
    "last_modified": "2026-07-27T12:00:28Z"
  }
}

A missing key answers found: false with HTTP 200 rather than a 404, so branch on the field, not the status code.

What the moving parts cost

Subscribing, listing, heading, unsubscribing and lifecycle rules are all free and rate-limited. PUT /v1/storage/object/put/{bucket}/{key} and POST /v1/storage/object/copy are $0.0001 per call, POST /v1/queue/publish is $0.00002 per message, and GET /v1/storage/object/get/{bucket}/{key} is billed by egress volume rather than per request — read on 27 July 2026. The scan gate itself is nearly all free calls; what you pay for is the promotion copy and the eventual reads. Rates drift downward and campaigns run:

curl -sS "https://api.infrai.cc/v1/discovery?namespace=storage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

S3 event notifications plus Lambda are a deeper event system in isolation — filtering, retries and dead-lettering are far more configurable there — and if event fan-out is the hard part of your product, stick with them; Cloudflare R2 with a Worker binding is the cheaper version of the same idea when egress dominates your bill. Those are also the right call if the upload leg genuinely has to be page-to-bucket with no server in the middle, because the intake here runs through your own route. The trade-off is the account count. Here the endpoint that receives the event, the queue it fans into with POST /v1/queue/publish, the crash report from POST /v1/errors/capture when a scan worker dies, and the per-tenant cost of all three in GET /v1/account/usage sit behind one credential — no second vendor to onboard for the queue, no third for the alerting. A file that arrives with a blocked content type answers STORAGE_CONTENT_BLOCKED at write time, which is one class of bad upload you never have to scan for at all.

References

Browse more storage developer guides