Trigger transcoding the moment a video upload completes
Subscribe a worker to Infrai storage events instead of polling a prefix: which upload path emits which event, how to manage the subscription, and what it costs.
Stop listing the prefix and subscribe to it. One call — POST /v1/storage/bucket/set_notification/{bucket} with the events you want and an HTTPS target — and Infrai posts your worker a JSON body carrying the bucket, the key and an X-Infrai-Event header the moment something lands. For video the event you want is multipart.completed, because a 500 MB phone recording arrives in parts and the assemble step is the API call worth reacting to.
Polling costs latency and list calls. A callback costs you a subscription that now exists in the account whether or not you remember it, which is the part most event-driven walkthroughs skip and the part this one starts with.
Which upload path emits which event
| How the bytes arrive | Event you get | Typical use |
|---|---|---|
PUT /v1/storage/object/put/{bucket}/{key} | object.created | small server-side writes, under 1 MB |
Multipart: create → parts → POST /v1/storage/multipart/complete/{upload_id} | multipart.completed | video, archives, anything multi-GB |
DELETE /v1/storage/object/delete/{bucket}/{key} | object.deleted | cache invalidation, audit trails |
Those three strings are the whole enum. A subscription asking for anything else is rejected at registration, so you find out immediately rather than after six weeks of silence.
Register the subscription
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"],"target":{"url":"https://hooks.yourdomain.example/storage/9f2c"}}'
{
"ok": true,
"data": { "subscription_id": "stnf_359a5eae68760e0386f5f799" }
}
The target has to be a real, publicly resolvable HTTPS endpoint. A hostname that doesn’t resolve, or an address inside your own network, comes back as WEBHOOK_URL_INVALID with a 400 before any subscription is written — an SSRF guard, and a useful one, since a typo in a callback URL otherwise becomes deliveries going nowhere.
Treat the subscription as state you own
This is the habit that keeps event plumbing sane. Register from a migration or a deploy step, list what’s really registered before you add another, and delete on teardown.
curl -sS "https://api.infrai.cc/v1/storage/bucket/notifications/video-ingest" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"subscription_id": "stnf_359a5eae68760e0386f5f799",
"bucket_id": "bkt_58b5cc5bb7464121889c1b",
"events": ["multipart.completed", "object.created"],
"webhook_id": null,
"url": "https://hooks.yourdomain.example/storage/9f2c"
}
],
"next_cursor": null
}
}
Retiring a worker is the mirror image, and it’s free:
curl -sS -X DELETE \
"https://api.infrai.cc/v1/storage/bucket/notification/delete/video-ingest/stnf_359a5eae68760e0386f5f799" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A CI job that lists subscriptions and diffs them against the ones your infrastructure code declares takes about ten lines, and it removes an entire category of “why did staging just get that event” confusion.
The ingest path that produces the event
Three calls bracket the transfer. POST /v1/storage/multipart/create/{bucket} returns an upload_id plus part_size_min (5,242,880 bytes) and part_count_max (10,000); every part gets its own URL from POST /v1/storage/multipart/presign_part/{upload_id}/{part_number}; and complete assembles them.
// ingest.mjs — Node 22 ESM. Runs on your server, a CLI, or 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: 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 parts = [];
try {
for (let i = 0, n = 1; i < bytes.length; i += PART, n++) {
const slot = await call(`/v1/storage/multipart/presign_part/${start.upload_id}/${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/${start.upload_id}`, "POST", { parts });
} catch (err) {
await fetch(`${API}/v1/storage/multipart/abort/${start.upload_id}`, { method: "DELETE", headers: auth });
throw err;
}
}
The manifest you hand to complete has to name exactly the parts you uploaded — all of them, each with the etag the vendor returned. A list missing a part, or carrying an etag from the wrong one, is refused with HTTP 409 STORAGE_MULTIPART_INCONSISTENT, and the message names the part at fault. Array order doesn’t matter; the manifest is sorted by part_number before assembly.
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("/storage/9f2c", 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}`, {
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 queued = await fetch(`${API}/v1/queue/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ queue: "transcode", payload: { key: evt.key, bytes: data.size_bytes, etag: data.etag } }),
});
console.log("queued", evt.key, queued.status);
});
app.listen(3000, () => console.log("listening on :3000"));
Answer 202 first and work second — a handler that blocks on ffmpeg is a handler that times out.
Then read the object before acting on the message. GET /v1/storage/object/head/{bucket}/{key} is free, answers in a couple of hundred milliseconds, and returns found, size_bytes, etag and the stored content_type, which together are the authoritative account of what landed. A multipart object’s etag ends in -N where N is the part count, so it doubles as a cheap check against how many parts you meant to send.
The catch is that delivery is at-least-once. Your worker will see the same event twice sooner or later, so key the job on key + etag the way the snippet above does and make the transcode step safe to run again.
What this costs
Verified 27 July 2026. The whole notification surface is free and rate-limited: subscribing, listing, deleting, multipart/create, presign_part, abort, object/head and object/list don’t bill, and they leave the $2 of starting credit alone. Only the routes that move bytes through the API are metered, and they are not metered the same way:
| Metered route | Unit | Rate read 27 July 2026 |
|---|---|---|
PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} | per call | $0.0001 |
POST /v1/storage/multipart/complete/{upload_id} | per call | $0.0002 |
GET /v1/storage/object/get/{bucket}/{key} | per GB of response body | $0.104 |
That asymmetry is the durable part. Writes are counted; reads are weighed. Parts pushed straight to their presigned URLs never touch the metered part route at all, so ingesting a 500 MB clip is one assemble call no matter how many parts it arrived in — the ingest side of a video pipeline is close to free. The read side is not, because half a gigabyte leaves the bucket every time somebody pulls that clip back down through the API, and a thousand of those is half a terabyte. Size your read bill from average object size times downloads, not from downloads alone. A thumbnail-heavy library and a raw-master library with identical request counts land in different places entirely.
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'), c['billing'].get('unit')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Read that rather than this paragraph in six months. Rates here trend downward and discount campaigns run, so today’s figure is at least as likely to be lower as higher.
A concrete check that the ingest prefix is filling up:
curl -sS "https://api.infrai.cc/v1/storage/object/list/video-ingest?prefix=videos/raw/&limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
When something else is the better call
If you already live in AWS, S3 event notifications into EventBridge or SQS give you filter rules, delivery retries and dead-letter behaviour as configurable primitives, and that depth is worth more than consolidation when fan-out is the hard part of your product. Self-hosted MinIO publishes bucket notifications into a broker you run, which is the right pick when the source video can’t leave your network at all.
What you get here instead is that the step after “the video landed” needs nothing new. The POST /v1/queue/publish the worker above calls to enqueue the transcode, the POST /v1/cron/create that sweeps abandoned multipart uploads overnight, and the POST /v1/errors/capture wrapped around the handler are already on the same account as the bucket that raised the event — no second vendor wedged between the bucket and the job, and no second bill for the privilege.