Serving podcast audio from a bucket: four things to check first
Object URLs really are permanent and support byte-range seeking. But the host isn't yours, every response says attachment, and there are no CORS headers.
Short answer: yes, this works, and the URL really is permanent. An object stored on Infrai answers a plain GET with no signature at all — we took a presigned link, deleted everything from the ? onward, and the bytes came back 200 OK with Accept-Ranges: bytes, which is the header a podcast player cares about most. Seeking works, resuming works, and you never have to refresh a link.
Four things are worth checking before you paste that URL into an RSS feed, though, because a feed enclosure is close to unchangeable once listeners’ apps have cached it. The host is not your domain. Every response carries Content-Disposition: attachment. There are no CORS headers at all. And “private” on the bucket doesn’t stop anybody reading the file.
Publishing an episode
Sign a slot, push the MP3 straight at it, and confirm. Three calls, only one of which is billable:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/podcast-media/episodes/2026/07/ep-014.mp3" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":900,"content_type":"audio/mpeg"}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.podcast-media/episodes/2026/07/ep-014.mp3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-SignedHeaders=content-type%3Bhost&X-Amz-Signature=7c2e2aaafb0b5fa8",
"method": "PUT",
"headers": { "Content-Type": "audio/mpeg" },
"expires_at": "2026-07-26T01:18:39.708467Z"
}
}
Strip the query string from that url and you have the address the feed will use forever. Here’s the whole publish step in Node 22 — sign, upload, verify:
import { readFile } from "node:fs/promises";
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 = "podcast-media";
async function signSlot(objectKey) {
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 900, content_type: "audio/mpeg" }),
});
const out = await res.json();
if (!res.ok || out.ok === false) throw new Error(out?.error?.code ?? `HTTP ${res.status}`);
return out.data;
}
async function verify(objectKey) {
const res = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${objectKey}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const out = await res.json();
if (!out.data?.found) throw new Error(`${objectKey} did not land`);
return out.data;
}
const objectKey = "episodes/2026/07/ep-014.mp3";
const audio = await readFile("ep-014.mp3");
const slot = await signSlot(objectKey);
const upload = await fetch(slot.url, { method: "PUT", headers: slot.headers, body: audio });
if (!upload.ok) throw new Error(`upload failed: ${upload.status}`);
const meta = await verify(objectKey);
console.log("enclosure:", slot.url.split("?")[0]);
console.log("bytes:", meta.size_bytes, "type:", meta.content_type);
size_bytes and content_type from that verify step are exactly what the <enclosure> element needs, so generate the feed from the head response rather than from local file stats:
<item>
<title>Episode 14</title>
<enclosure url="https://podcast.example.com/audio/ep-014.mp3"
length="3000000"
type="audio/mpeg" />
</item>
Note the domain in that enclosure. It isn’t the storage host, and that’s deliberate.
The URL is permanent — which is the risk
Object addresses look like https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.podcast-media/episodes/2026/07/ep-014.mp3. The vendor hostname and the account prefix are both baked in, there’s no custom-domain support, and no CDN in front. Publish that raw and you’ve handed your show’s distribution to an address you can’t move.
The fix costs an afternoon: serve /audio/{file} from your own domain and 302 to the storage URL. Every podcast client follows redirects, you keep the ability to change backends without breaking eight years of episodes, and you get a download count as a side effect — which the bucket won’t give you.
One more placement wrinkle: our bucket was created with region: "eu-central-1" and reports that back on GET /v1/storage/bucket/get/{bucket}, but the signed URL points at an ap-singapore host. Region isn’t honoured for placement today, so if most of your listeners are in North America or Europe, expect first-byte latency in the hundreds of milliseconds rather than the tens a CDN would give you.
What the response headers do to a player
Look at what a bare GET actually returns:
curl -sS -D - -o /dev/null \
"https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.podcast-media/episodes/2026/07/ep-014.mp3"
You’ll see Content-Type: audio/mpeg, Content-Length: 3000000, Accept-Ranges: bytes — and also Content-Disposition: attachment with x-cos-force-download: true. That last pair is set at the vendor bucket level and there’s no route to change it. An <audio src> element ignores it (subresource fetches aren’t navigations, so playback is unaffected), but anyone who clicks the naked link in a browser gets a download instead of a player. Another reason to publish through your own redirect.
Range requests work, which is the thing that actually matters:
curl -sS -o /dev/null -w '%{http_code}\n' -r 1000000-1000099 \
"https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.podcast-media/episodes/2026/07/ep-014.mp3"
That returns 206. Scrubbing to minute 34 of a two-hour episode won’t drag the whole file down.
Set content_type when you sign the upload, not afterwards. Objects written through PUT /v1/storage/object/put/{bucket}/{key} come back carrying Content-Encoding: aws-chunked, a non-standard encoding no browser has a decoder for; objects uploaded to a presigned URL don’t. For audio a player will fetch, take the presigned-PUT path.
No CORS headers, so no waveform
A GET with an Origin header comes back without Access-Control-Allow-Origin. Consequences, in rough order of how often they bite:
| Player feature | Works? | Why |
|---|---|---|
<audio src="…"> playback | Yes | Media elements don’t need CORS |
| Seek / scrub | Yes | Accept-Ranges: bytes, 206 on ranged GETs |
crossorigin="anonymous" | No | No Access-Control-Allow-Origin in the response |
| Web Audio waveform or analyser | No | Needs the CORS-enabled fetch above |
fetch()-based or MSE player | No | Blocked by the browser before playback |
| Custom domain / CDN | No | Vendor host only, no CDN layer |
If your site draws waveforms — a fair number of podcast themes do — that’s a limitation you can’t code around from here. Cloudflare R2 with a public bucket and a custom domain handles it directly, and so does a Backblaze B2 bucket fronted by Cloudflare.
”Private” doesn’t mean private
The bucket reports "acl": "private", and the unsigned GET above still returned the file. The signature governs writes; reads only need the key. POST /v1/storage/object/set_acl/{bucket}/{key} rejects public-read with STORAGE_ACL_INVALID, because there’s nothing to toggle.
For a public podcast that’s the behaviour you want. But don’t keep subscriber-only episodes, unreleased cuts or ad-free versions in the same bucket and assume the ACL protects them — it doesn’t. Put paid audio behind your own API, or in a bucket whose keys are random enough that they can’t be guessed, and accept that anyone who receives the URL keeps it.
Bandwidth, cost and what you can measure
Storage is metered by the gigabyte-month and egress by the gigabyte. A 60-minute episode at 128 kbps is roughly 55 MB, so 10,000 downloads is about 550 GB of egress in a month — check the numbers before a launch rather than after, and watch for STORAGE_BANDWIDTH_EXCEEDED.
Call fees are the small part. Verified 26 July 2026: signing, GET /v1/storage/object/head/{bucket}/{key} and GET /v1/storage/bucket/usage/{bucket} are free, and writes run $0.0001 per call. Read today’s figures and your own totals:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/podcast-media" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
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 trend downward and campaigns run, so treat those as a ceiling rather than a forecast.
The honest summary: for a small show, or for a podcast that’s one feature of a larger app, this is fine — the same key that stores the audio also runs the transcription job, schedules the publish, and emails your subscribers. If your show is the product and you need IAB-style download analytics, a custom domain and edge caching, you’d be better off on a dedicated podcast host or an R2 bucket with a domain in front.