Kick off transcoding when a video lands, without polling the bucket
Subscribe a worker to Infrai storage events, learn which upload paths actually emit one, and handle the callback safely when it carries no signature.
Register a callback once and stop listing the bucket. On Infrai that’s POST /v1/storage/bucket/set_notification/{bucket}, which takes the event types you care about and a target URL; when a matching event fires, your endpoint receives a JSON POST with an X-Infrai-Event header and the object’s key in the body. For a video pipeline the event you want is multipart.completed, and the reason why is the part of this that trips people up.
Not every upload emits an event. We tested each path against a live Infrai bucket in July 2026, and the result is worth internalising before you design around it: a single-shot presigned PUT straight to the storage origin produced no object.created at all, because those bytes never traverse the Infrai API and nothing is there to observe them. Multipart uploads are different — the client uploads parts directly, but the completion is an API call, and completion is what fires.
Which paths emit what
| Upload path | Event emitted | Good for |
|---|---|---|
PUT /v1/storage/object/put/{bucket}/{key} (base64 JSON) | object.created | small files, under 1 MB |
Presigned single PUT to the storage origin | none — verified silent | avatars where your API already knows |
| Multipart: create → presigned parts → complete | multipart.completed | video, the case at hand |
DELETE /v1/storage/object/delete/{bucket}/{key} | object.deleted | cleanup auditing |
For a 500 MB phone recording you were going to use multipart anyway, so the pipeline you want falls out naturally: the client streams parts to presigned URLs, your API completes the upload, and the completion notifies your transcoder.
Subscribe once
Do this from a migration or a one-off script, never from application boot — more on why in a moment.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/bucket/set_notification/video-ingest" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["multipart.completed","object.created","object.deleted"],"target":{"url":"https://worker.example.com/hooks/storage"}}'
{
"ok": true,
"data": { "subscription_id": "stnf_324394ea6204f09487725fdf" }
}
Here is a real delivery, copied off the wire rather than reconstructed from the reference:
{
"type": "multipart.completed",
"event": "multipart.completed",
"account_id": "acct_email_77c768e42148275b",
"bucket_id": "bkt_d62401165b6d40c9896623",
"bucket": "video-ingest",
"key": "videos/raw/clip-77.mp4",
"timestamp": "2026-07-26T00:42:05.481000+00:00",
"object": {
"key": "videos/raw/clip-77.mp4",
"size_bytes": 118293504,
"etag": "69ac8e290d7497c2d6478a5eb17584b3-1",
"content_type": "video/mp4"
},
"subscription_id": "stnf_324394ea6204f09487725fdf"
}
The -1 suffix on the etag is the multipart part count, not a checksum of the whole file. Don’t compare it against a local md5 and expect a match.
The upload side that produces the event
Three API calls bracket the transfer. POST /v1/storage/multipart/create/{bucket} hands back an upload_id plus part_size_min (5 MiB) and part_count_max (10000); each part gets a presigned URL from POST /v1/storage/multipart/presign_part/{upload_id}/{part_number}; and POST /v1/storage/multipart/complete/{upload_id} assembles them.
// ingest.mjs — Node 22 ESM. Runs on your server or in a native client.
import { readFile } from "node:fs/promises";
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 auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const PART = 8 * 1024 * 1024;
async function call(path, method, body) {
const res = await fetch(`${API}${path}`, { method, headers: auth, body: body ? JSON.stringify(body) : "{}" });
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(`${method} ${path} -> ${res.status} ${JSON.stringify(json.error ?? json)}`);
return json.data;
}
export async function ingestVideo(bucket, objectKey, filePath) {
const bytes = await readFile(filePath);
const start = await call(`/v1/storage/multipart/create/${bucket}`, "POST", { key: objectKey, content_type: "video/mp4" });
const uploadId = start.upload_id;
const parts = [];
try {
for (let i = 0, n = 1; i < bytes.length; i += PART, n++) {
const slot = await call(`/v1/storage/multipart/presign_part/${uploadId}/${n}`, "POST", {});
const put = await fetch(slot.url, { method: slot.method ?? "PUT", body: bytes.subarray(i, i + PART) });
if (!put.ok) throw new Error(`part ${n} failed: HTTP ${put.status}`);
parts.push({ part_number: n, etag: (put.headers.get("etag") ?? "").replaceAll('"', "") });
}
return await call(`/v1/storage/multipart/complete/${uploadId}`, "POST", { parts });
} catch (err) {
await fetch(`${API}/v1/storage/multipart/abort/${uploadId}`, { method: "DELETE", headers: auth });
throw err;
}
}
The abort in the catch block matters more than it looks: an abandoned upload leaves parts sitting in storage that you’re still paying rent on, and there’s no route to enumerate stale upload ids afterwards.
One constraint on who can run that loop. Those presigned part URLs live on the storage origin, and Infrai has no route to set bucket CORS rules — cors_rules passed to bucket create is silently dropped, and a preflight against the origin comes back 403. Native mobile clients and your own backend don’t care, since CORS is a browser rule. A browser tab does care, and can’t do it. If your uploader is a web page, either proxy the parts through your API or use R2 or S3 for that leg, where you can configure CORS yourself.
The worker on the other end
// worker.mjs — Node 22 ESM, express@4
import express from "express";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const BUCKET = "video-ingest";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const app = express();
app.use(express.json({ limit: "256kb" }));
const seen = new Set();
app.post("/hooks/storage", async (req, res) => {
const evt = req.body ?? {};
res.status(202).end();
if (evt.bucket !== BUCKET) return;
if (evt.type !== "multipart.completed" && evt.type !== "object.created") return;
if (!evt.key?.startsWith("videos/raw/")) return;
const head = await fetch(`${API}/v1/storage/object/head/${evt.bucket}/${evt.key}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
if (!head.ok) return console.error("head failed", evt.key, head.status);
const { data } = await head.json();
if (!data.found) return console.error("event for a missing object", evt.key);
const fingerprint = `${evt.key}:${data.etag}`;
if (seen.has(fingerprint)) return;
seen.add(fingerprint);
const job = { queue: "transcode", body: { key: evt.key, bytes: data.size_bytes, etag: data.etag } };
const queued = await fetch(`${API}/v1/queue/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(job),
});
console.log("queued", evt.key, queued.status);
});
app.listen(3000, () => console.log("listening on :3000"));
Answer 202 first, work second. A callback handler that blocks on ffmpeg is a callback handler that times out.
Three caveats we confirmed by hand, all of which shape that code. The callback carries no HMAC signature, so an event is a hint, not proof — the free GET /v1/storage/object/head/{bucket}/{key} re-read is what makes it trustworthy. Subscriptions are account-scoped rather than bucket-scoped: a subscription registered on one bucket received events for every other bucket on the account, which is why the handler filters on evt.bucket itself. And there’s no route to list or delete a subscription, so calling set_notification on every deploy accumulates duplicate deliveries with no way to clean them up.
What this costs, and the honest comparison
Verified 26 July 2026: the notification surface is free. set_notification, multipart/create, presign_part, abort, object/head and object/list are all free (rate-limited). You pay on the byte-moving calls — PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} at $0.0001 per part when parts go through the API, POST /v1/storage/multipart/complete/{upload_id} at $0.0002 per call, and GET /v1/storage/object/get/{bucket}/{key} at $0.0002. Rates here move downward over time, so read the live figures:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.multipart')]"
A quick check that the ingest prefix is filling up, with a fully concrete path:
curl -sS "https://api.infrai.cc/v1/storage/object/list/video-ingest?prefix=videos/raw/&limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
If your stack already lives in AWS, S3 event notifications into SQS or EventBridge are more mature than this — retry policy, dead-letter behaviour and delivery guarantees are all documented, and Infrai’s callback contract isn’t. Self-hosting MinIO gives you bucket notifications to a broker you own, which is the better pick when the video files can’t leave your network. What you get here instead is that the transcode queue, the cron sweep for abandoned uploads and the error capture around the worker are all on the same key and the same bill — no second vendor for the plumbing between them.