Private Podcast
Publish a podcast that only paying subscribers can play. Your server decides who is entitled, then hands out a signed audio URL (for RSS apps) or a per-listener play session (for in-app playback).
When AudioDN fits this job
Architecture
- Ingest (server): upload each episode into one collection that represents the show.
- Entitlement (server): map a subscriber token to an active subscription in your database.
- Delivery (server): for an RSS feed, sign a delivery URL per episode; for an app, mint a per-listener play session.
- Playback (client): any podcast app or
<audio>element streams the signed URL.
Server / client boundary
Environment variables
# Server-only secrets — never ship these to the client
ADN_API_KEY=adn_... # API Access key (server-side); used to mint sessions
ADN_SIGNING_SECRET=... # URL Signing key secret (from the dashboard, shown once)
ADN_DELIVERY_DOMAIN=1bb2....audiodelivery.net # your org delivery host
ADN_COLLECTION_ID=... # the collection that backs this podcast feed 1. Ingest an episode
The full, correct upload lifecycle: create session → create a per-track URL (no key) → PUT bytes →
wait for ready. See the Upload Sessions API for details.
// ingest.mjs — add an episode to the podcast (server-side, run once per file).
// Auth: the API Access key is used ONLY to create the upload session.
const API = 'https://api.audiodelivery.net/v1';
async function addEpisode(filePath, fileName) {
// 1. Create an upload session (Bearer, server-side only).
const session = await fetch(`${API}/upload_session`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ADN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ collection_id: process.env.ADN_COLLECTION_ID }),
}).then((r) => r.json());
// 2. Create a track in the session (NO Bearer — the session ID authorizes it).
const { track_id, track_upload } = await fetch(
`${API}/upload/${session.upload_session_id}/track`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: fileName }),
},
).then((r) => r.json());
// 3. Upload the audio bytes to the per-track signed URL.
const bytes = await (await import('node:fs/promises')).readFile(filePath);
await fetch(track_upload.upload_url, { method: track_upload.method, body: bytes });
// 4. Wait until processing finishes (or handle this with a webhook instead).
let status;
do {
await new Promise((r) => setTimeout(r, 5000));
const res = await fetch(`${API}/track/${track_id}`, {
headers: { Authorization: `Bearer ${process.env.ADN_API_KEY}` },
}).then((r) => r.json());
status = res.track?.track_status_id;
} while (status !== 'ready' && status !== 'incomplete' && status !== 'error');
return { track_id, status };
} Prefer webhooks over polling
ready event instead of polling track_status_id.
2. Sign delivery URLs (RSS path)
// sign.mjs — sign a delivery URL for one file path (Web Crypto; Node 18+, Deno, Bun, Workers).
// This is the canonical scheme from /docs/integration/signed-delivery.
function base64url(bytes) {
let binary = '';
const b = new Uint8Array(bytes);
for (let i = 0; i < b.byteLength; i++) binary += String.fromCharCode(b[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function hmacSha256(key, data) {
const cryptoKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(key),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)));
}
export async function signUrl(secret, domain, path) {
const u = new URL('https://' + domain + '/' + path);
u.searchParams.delete('verify');
const message = u.pathname + (u.search || '');
const issued = Math.floor(Date.now() / 1000).toString();
const mac = await hmacSha256(secret, message + issued);
u.searchParams.append('verify', issued + '-' + base64url(mac)); // verify MUST be last
return u.toString();
} 3. Serve a per-subscriber feed
// feed.mjs — Express route that returns a per-subscriber RSS feed.
// Entitlement is enforced HERE, before any URL is signed.
import express from 'express';
import { signUrl } from './sign.mjs';
const app = express();
app.get('/feed/:subscriberToken.xml', async (req, res) => {
const subscriber = await lookupSubscriber(req.params.subscriberToken);
if (!subscriber || !subscriber.active) {
return res.status(403).send('Subscription inactive');
}
// episodes: [{ title, path, durationSec, publishedAt }] from your DB,
// where "path" is the track file path, e.g. "folder-id/track-index_hq.aac".
const episodes = await listEpisodes();
const items = await Promise.all(
episodes.map(async (ep) => {
const url = await signUrl(
process.env.ADN_SIGNING_SECRET,
process.env.ADN_DELIVERY_DOMAIN,
ep.path,
);
return `<item>
<title>${ep.title}</title>
<enclosure url="${url}" type="audio/aac" />
<pubDate>${new Date(ep.publishedAt).toUTCString()}</pubDate>
</item>`;
}),
);
res.type('application/rss+xml').send(`<?xml version="1.0"?>
<rss version="2.0"><channel><title>My Private Podcast</title>
${items.join('\n')}
</channel></rss>`);
});
app.listen(3000); In-app playback (alternative to RSS)
If listeners play inside your own app rather than a podcast client, skip RSS and mint a play session per listener. This gives you per-play authorization and a short expiry.
// In-app playback (no RSS): mint a short-lived, per-listener play session instead.
// Returns signed variant URLs in first_track.variants[].url.
const play = await fetch('https://api.audiodelivery.net/v1/play_session/track', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ADN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ track_id, variants: ['hq'], expires_in: 3600 }),
}).then((r) => r.json()); Expected errors
All API errors use the standard envelope { ok: false, message, api_request_id }.
401— missing/invalid Bearer key on a server call (upload session or play session).400— bad body (e.g. missingcollection_id/track_id).404— unknown track or session ID.- A signed delivery URL returns
403at the edge if the signature is missing, malformed, or expired.
Testing
- Run
node ingest.mjsagainst a test collection and confirm the track reachesready. - Request
/feed/<token>.xmlfor an active and an inactive subscriber; assert 200 vs 403. - Open a signed enclosure URL in a browser — it should stream; tamper with the
verifyparam and expect 403.
Deployment
Deploy the feed server anywhere that keeps secrets server-side (Node host, container, or a serverless function). Set the four environment variables above. For a fully edge-native variant, sign inside a Cloudflare Worker — see the Cloudflare Worker signed playback example.
OpenAPI operations used
createUploadSession—POST /v1/upload_session(reference, openapi.json)createUploadSessionTrack—POST /v1/upload/:upload_session_id/track(reference)getTrack—GET /v1/track/:track_id(reference)createPlaySession—POST /v1/play_session/:scope(reference)