# Private Podcast | AudioDN Docs

> Serve a subscriber-only podcast with AudioDN: a collection as the feed, entitlement checks on your server, and signed delivery (or per-listener play sessions) so only paying subscribers can stream.

Source: https://audiodeliverynetwork.com/docs/recipes/private-podcast/

---

# 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

Use AudioDN when you want managed transcoding, per-file signed delivery, and accurate play analytics without running your own media pipeline. It is a strong fit for private podcasts because entitlement stays in your app while delivery, expiry, and edge distribution are handled for you. If your feed is fully public with no gating, plain [signed delivery](/docs/integration/signed-delivery) alone is enough.

## 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

The API Access key and the URL Signing secret are **server-only**. The client never sees them — it only ever receives a finished, signed URL (or a play-session ID). Never sign in the browser.

## 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](/docs/api/upload-sessions) 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

In production, configure the [Track Processing webhook](/docs/webhooks/track-processing) and react to the terminal `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](/docs/api/play-sessions) 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. missing `collection_id` / `track_id`).
-   `404` — unknown track or session ID.
-   A signed delivery URL returns `403` at the edge if the signature is missing, malformed, or expired.

## Testing

-   Run `node ingest.mjs` against a test collection and confirm the track reaches `ready`.
-   Request `/feed/<token>.xml` for an active and an inactive subscriber; assert 200 vs 403.
-   Open a signed enclosure URL in a browser — it should stream; tamper with the `verify` param 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](/docs/recipes).

### OpenAPI operations used

-   `createUploadSession` — `POST /v1/upload_session` ([reference](/docs/api/upload-sessions), [openapi.json](/openapi.json))
-   `createUploadSessionTrack` — `POST /v1/upload/:upload_session_id/track` ([reference](/docs/api/upload-sessions))
-   `getTrack` — `GET /v1/track/:track_id` ([reference](/docs/api/tracks))
-   `createPlaySession` — `POST /v1/play_session/:scope` ([reference](/docs/api/play-sessions))
