Buckets can't be searched by tag or content type — design around it
Object listing is prefix-only: no server-side filter by tag or MIME type. How to lay out avatar keys, what a listing does hand you, and where the real index belongs.
Short answer: you can’t. Object storage indexes exactly one thing — the key — and every “search” you’ve seen is a prefix scan wearing a costume. On Infrai the same holds, with one detail in your favour: GET /v1/storage/object/list/{bucket} hands back each object’s content_type and its full metadata map alongside the key, so filtering a page of results is a loop in your code rather than a call per object. What no route will do is filter server-side, and that’s the part that shapes the design.
That single fact decides the layout. Put whatever you’ll want to filter on into the key, and put anything you’ll want to query into a database.
What a listing gives you
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/list/avatar-store?prefix=avatars/usr_3310/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "key": "avatars/usr_3310/128.webp", "size_bytes": 12, "etag": "bf7b92f14cea1852576fac588bb7a3e0", "content_type": "image/webp", "metadata": { "uploaded-by": "web", "user-id": "usr_3310", "variant": "128" }, "last_modified": "2026-07-27T13:04:14Z" },
{ "key": "avatars/usr_3310/512.webp", "size_bytes": 12, "etag": "bf7b92f14cea1852576fac588bb7a3e0", "content_type": "image/webp", "metadata": { "variant": "v2" }, "last_modified": "2026-07-27T13:04:44Z" },
{ "key": "avatars/usr_3310/original.png", "size_bytes": 12, "etag": "bf7b92f14cea1852576fac588bb7a3e0", "content_type": "image/png", "metadata": { "uploaded-by": "web", "user-id": "usr_3310", "variant": "original" }, "last_modified": "2026-07-26T00:32:58Z" }
],
"next_cursor": null
}
}
Everything you’d want to filter on is in that payload — which is the good news, and also the trap. The route’s parameters are prefix, delimiter, a page size and a cursor. There is no ?filter=content_type:image/webp, so “all WebP avatars” means paging the whole bucket and throwing most of it away. Over three objects that costs nothing. Over 400,000 it is your entire runtime.
Pass a delimiter and you get the folder illusion instead of objects:
curl -sS "https://api.infrai.cc/v1/storage/object/list/avatar-store?prefix=avatars/&delimiter=/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [],
"next_cursor": null,
"common_prefixes": ["avatars/usr_3310/", "avatars/usr_9902/"]
}
}
That’s the closest thing to a directory tree you get: one level at a time, keyed on string prefixes. It’s genuinely useful for “which users have avatars”, and useless for “which avatars are WebP”.
Reading and writing one object’s metadata
head gives you the same fields for a single key, free, without paging anything:
curl -sS "https://api.infrai.cc/v1/storage/object/head/avatar-store/avatars/usr_3310/128.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "avatars/usr_3310/128.webp",
"size_bytes": 12,
"etag": "bf7b92f14cea1852576fac588bb7a3e0",
"content_type": "image/webp",
"metadata": { "user-id": "usr_3310", "variant": "128", "uploaded-by": "web" },
"last_modified": "2026-07-26T00:32:23Z"
}
}
Head calls are free and rate-limited, and each is a round trip of about 75 ms of server time in our testing plus whatever your network adds. Since the listing already carries metadata, reach for head when you have one key in hand — a link about to be signed, a webhook that named an object — not as a fan-out behind a listing you already paged.
Two properties of POST /v1/storage/object/set_metadata/{bucket}/{key} worth knowing before you write an updater:
- It replaces, it doesn’t merge. Send
{"metadata":{"variant":"v2"}}on an object that also haduser-idand theuser-idis gone — we re-checked that on 2026-07-27. Read withhead, merge in your code, write the whole map back. - Keys are normalised to hyphens. Send
user_idand it is accepted and stored; read it back withheadorlistand it comes back asuser-id. Either style works on the way in, so pick one convention in your code and read the hyphenated form.
There’s also no object tagging API here — no equivalent of S3’s GetObjectTagging, and no bucket inventory export. If tag-based lifecycle rules are load-bearing for you, that’s a genuine limitation of this surface.
Design the key like an index
Since the key is the only indexed field, spend it deliberately. For avatars, the useful order is owner → variant → format, most selective first:
avatars/usr_3310/original.png
avatars/usr_3310/128.webp
avatars/usr_3310/512.webp
avatars/usr_9902/original.jpg
Now “all avatars for user 3310” is one prefix scan, “does a 128px WebP exist” is one head, and “delete everything for this user on account closure” is a prefix list plus a batch delete. A flat avatars/3310-128.webp layout answers the first question and none of the others.
What the key can’t encode is anything that changes after upload, or anything with two orthogonal axes you both want to filter by. That’s the boundary where you stop being clever with prefixes.
The index belongs in your database
One row per object, written in the same request that uploads it:
CREATE TABLE avatar_objects (
object_key text PRIMARY KEY,
user_id text NOT NULL,
content_type text NOT NULL,
variant text NOT NULL,
size_bytes bigint,
uploaded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX avatar_objects_user_idx ON avatar_objects (user_id, variant);
CREATE INDEX avatar_objects_type_idx ON avatar_objects (content_type);
import pg from "pg";
const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function indexAvatar(objectKey) {
const res = await fetch(`${API}/v1/storage/object/head/avatar-store/${objectKey}`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const json = await res.json();
if (!res.ok || json.ok === false || json.data?.found !== true) throw new Error(`head failed for ${objectKey}`);
const { content_type: contentType, size_bytes: sizeBytes, metadata } = json.data;
await db.query(
`INSERT INTO avatar_objects (object_key, user_id, content_type, variant, size_bytes)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (object_key) DO UPDATE
SET content_type = EXCLUDED.content_type, variant = EXCLUDED.variant, size_bytes = EXCLUDED.size_bytes`,
[objectKey, metadata?.["user-id"] ?? "unknown", contentType ?? "application/octet-stream", metadata?.variant ?? "original", sizeBytes],
);
return { objectKey, contentType, sizeBytes };
}
Now “every WebP avatar uploaded this week” is a WHERE clause that answers in milliseconds, and the bucket goes back to doing the one job it’s good at. Reconciling the two is a periodic prefix scan comparing keys against rows — cheap, because listing is free.
The options, honestly
| Approach | Answers “all WebP avatars?” | Cost | When it’s right |
|---|---|---|---|
| Prefix listing | Not server-side | Free | Ownership scans, cleanup, “does this user have files” |
| Filter the listing in your code | Yes, after paging everything | Free, one round trip per page | Ad-hoc jobs over thousands, not millions |
| Your own index table | Yes, instantly | A row per object | The default for anything user-facing |
| S3 object tags + Inventory + Athena | Yes | Storage plus query cost | Compliance sweeps over very large buckets |
| Cloudinary or a DAM | Yes, with search built in | Per-asset pricing | Media libraries where search is the product |
If your product’s core loop is searching a media library by attributes, you’d be better off on a digital asset platform than reimplementing one on top of a bucket. If storage is just where the bytes go, the index table wins on every axis.
What the calls cost
Listing, head, set_metadata, delete and bucket reads are free and rate-limited on Infrai, which is what makes a reconciliation sweep viable at all — you can page a whole prefix and pay nothing for the privilege. Writes are $0.0001 per storage.object.put call, verified 2026-07-27.
Reading bytes back is priced by volume rather than by call: storage.object.get meters $0.104 per GB, with stored bytes billed separately. For an avatar pipeline that is the number to design against, because it makes rendition size the lever — serving the 40 KB 128px WebP instead of the 4 MB original is about a hundredfold difference on the same page views, and it costs you one extra PUT at upload time to have the small one ready.
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.object')]"
Rates drift downward here and campaigns run, so the live call is the one to trust. What doesn’t drift is the shape of the account: the POST /v1/image/resize that makes the 128px variant, the POST /v1/queue/publish that runs that job off the request path and the POST /v1/errors/capture that catches the one that failed are already on the same key as the bucket — no second vendor, no second bill, and the index table’s Postgres is on it too.