Knowing an upload finished when it never touched your backend
A client callback your server verifies with a free head call, a sweeper for the clients that vanish, and where bucket notifications genuinely do and don't fire.
The reliable answer is a two-step: the client tells your API it’s done, and your API confirms with the storage layer before it believes a word of it. On Infrai that confirmation is GET /v1/storage/object/head/{bucket}/{key} — free, unmetered against your trial, and back in a couple of hundred milliseconds with the size, etag and content type of whatever actually landed.
You might expect a bucket event to do this for you, and Infrai does have one: POST /v1/storage/bucket/set_notification/{bucket} registers a webhook that fires within about two seconds. The catch is that in our testing it fires for writes that go through the API, and not for a client PUT to a presigned URL — which is precisely the case you’re asking about. So the callback is the primary path, the sweeper below is the safety net, and the webhook is a bonus for the writes your own code makes.
Model the upload as a state machine, not an event
Write the database row before the upload starts, not after it finishes. When your API mints the slot it already knows everything except whether the bytes arrived:
CREATE TABLE uploads (
id text PRIMARY KEY,
tenant_id text NOT NULL,
object_key text NOT NULL UNIQUE,
declared_type text NOT NULL,
status text NOT NULL DEFAULT 'pending',
size_bytes bigint,
etag text,
created_at timestamptz NOT NULL DEFAULT now(),
confirmed_at timestamptz
);
Now “did the upload finish?” is a column, every slot you ever issued is accounted for, and an abandoned upload is a row you can find rather than an object nobody knows about. Keys go under a pending/ prefix so the two states are separable by listing alone.
The callback, and why you verify it
Your client posts {"uploadId": "..."} when its PUT returns 200. Treat that as a hint — the client is untrusted and may be lying, retrying, or reporting an upload that failed halfway. One head call settles it:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/head/app-uploads/pending/t_4471/9f2c1a04.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "pending/t_4471/9f2c1a04.pdf",
"size_bytes": 23,
"etag": "822cc15c8c63a3c432a2b77e8dcaf782",
"content_type": "application/pdf",
"last_modified": "2026-07-26T01:08:15Z"
}
}
found: false means the client is wrong and the row stays pending. In Node 22:
import { createServer } from "node:http";
import pg from "pg";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const API = "https://api.infrai.cc";
const BUCKET = "app-uploads";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
async function headObject(objectKey) {
const res = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${objectKey}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const out = await res.json();
if (!res.ok || out.ok === false) throw new Error(out?.error?.code ?? `HTTP ${res.status}`);
return out.data;
}
/** Flip pending -> confirmed only if the bytes are really there and plausible. */
async function confirmUpload(uploadId) {
const { rows } = await pool.query("SELECT * FROM uploads WHERE id = $1", [uploadId]);
const row = rows[0];
if (!row) throw new Error(`no upload row ${uploadId}`);
if (row.status === "confirmed") return row;
const meta = await headObject(row.object_key);
if (!meta.found) return row;
if (meta.size_bytes === 0) throw new Error("zero-byte object — client aborted mid-PUT");
if (meta.content_type !== row.declared_type) {
throw new Error(`type mismatch: declared ${row.declared_type}, stored ${meta.content_type}`);
}
const updated = await pool.query(
`UPDATE uploads SET status='confirmed', size_bytes=$2, etag=$3, confirmed_at=now()
WHERE id = $1 AND status = 'pending' RETURNING *`,
[uploadId, meta.size_bytes, meta.etag],
);
return updated.rows[0] ?? row;
}
createServer(async (req, res) => {
const match = /^\/uploads\/([A-Za-z0-9_-]+)\/complete$/.exec(req.url ?? "");
if (req.method !== "POST" || !match) { res.writeHead(404).end(); return; }
try {
const row = await confirmUpload(match[1]);
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(row));
} catch (err) {
console.error("confirm failed", err);
res.writeHead(409, { "content-type": "application/json" })
.end(JSON.stringify({ error: String(err.message ?? err) }));
}
}).listen(3000);
The AND status = 'pending' in that update is doing quiet work — a client that fires the callback three times produces one state change, and the follow-on job runs once.
What the bucket webhook actually delivers
Register one and it’s live immediately:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_notification/app-uploads" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["object.created"],"target":{"url":"https://api.example.com/hooks/storage"}}'
The delivered body is complete enough to skip a lookup, and arrives with an x-infrai-event header:
{
"type": "object.created",
"event": "object.created",
"account_id": "acct_email_77c768e42148275b",
"bucket": "app-uploads",
"bucket_id": "bkt_55e84a7fc81a48d592711e",
"key": "pending/t_4471/9f2c1a04.pdf",
"timestamp": "2026-07-26T00:53:15.939242+00:00",
"object": { "size_bytes": 23, "etag": "822cc15c8c63a3c432a2b77e8dcaf782", "content_type": "application/pdf" },
"subscription_id": "stnf_0461e970b8fda47ea99e8042"
}
Three measured behaviours to design around, all from probes run on 26 July 2026:
- Delivery lands in roughly two seconds after the write. Fast enough to drive a UI refresh.
- Subscriptions fan out across the whole account, not just the bucket you registered on. A subscription created against one bucket received
object.createdfor every other bucket on the same account. Filter onbucketin your handler; don’t assume scoping. - There’s no signature header.
x-infrai-eventtells you the event type and that’s it, so anyone who learns your endpoint can post to it. Use an unguessable path, and re-check withheadbefore you trust a payload that moves money or grants access.
And the gap that decides the architecture: a presigned PUT straight from a client produced no event at all, verified twice with a 60-second wait. Nor does POST /v1/storage/multipart/complete/{upload_id} emit one. Writes through PUT /v1/storage/object/put/{bucket}/{key} and deletes through DELETE /v1/storage/object/delete/{bucket}/{key} do.
| Mechanism | Covers direct uploads | Typical latency | Trust level |
|---|---|---|---|
Client callback + head verify | Yes | Immediate | High — you checked |
| Bucket notification | No (API writes only) | ~2 s | Medium — unsigned, account-wide |
| Prefix sweep on a cron | Yes | Your interval | High |
Polling head from the client | Yes | Poll interval | Low — burns calls |
The sweeper, for clients that vanish
Tabs get closed. The sweep is a listing pass over the pending prefix, matched against rows that are still pending:
curl -sS "https://api.infrai.cc/v1/storage/object/list/app-uploads?prefix=pending/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Page with cursor until next_cursor comes back null. One limitation to plan for: list responses return content_type and metadata as null for every item — only key, size_bytes, etag and timestamps are populated — so use the listing to find candidates and head to judge them.
Objects that no client ever claims shouldn’t accumulate. A lifecycle rule expires them without a job:
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/app-uploads" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"pending/","expire_days":1}]}'
Anything still under pending/ after a day is gone, which also keeps your storage bill honest. Move confirmed objects to a permanent prefix with POST /v1/storage/object/copy if you want that separation physical rather than logical.
Cost, and where the alternatives win
Everything in the confirmation path is free: head, list, set_notification and set_lifecycle are all unbilled and rate-limited, and signing an upload slot is free too. You pay on the data path — verified 26 July 2026, a write is $0.0001 per call and a read is $0.0002 — so a confirmation-heavy design costs nothing extra, which is unusual and worth exploiting. Current 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.')]"
Rates drift downward and campaigns run, so expect the live numbers to be equal or lower.
If event-driven ingest is the spine of your product — thumbnails, virus scanning, transcoding, all fanning out from an upload — then Amazon S3 with EventBridge or SQS, or Supabase Storage with its database triggers, gives you first-class notifications for client-side uploads that Infrai doesn’t currently match. That’s a real trade-off and worth taking seriously. What you get back here is that the same key runs the queue that processes the file, the cron that sweeps the prefix, and the email that tells the user it’s ready — one account, one bill, and no cross-vendor IAM to reason about at 3am.