---
title: "Monitor your users' live streams for music (Amazon IVS, Mux, Cloudflare Stream, any HLS)"
description: "Wire AudD audio streams into a platform that hosts many user live streams: add each channel's HLS URL when it goes live, remove it when it ends, route results by radio_id, and scan the recording before you publish the replay."
slug: "/resources/integrations/live-stream-platforms"
section: "integrations"
keywords: [audd, live streaming platform, amazon ivs, mux, cloudflare stream, hls, eventbridge, webhooks, streams, radio_id, now playing, music log]
---

# Monitor your users' live streams for music (Amazon IVS, Mux, Cloudflare Stream, any HLS)

This page is for teams that run a platform where many people go live: creator
channels, live shopping, fitness classes, lectures, events. You want to know
which recorded music plays on each channel while it is live, and possibly
check the recording before it becomes a replay. AudD's streams API pulls an
HLS URL, recognizes the songs that play in it, and POSTs each match to your
server. Your platform already knows when a channel goes live and when it stops,
so the integration is mostly wiring those two events to two AudD calls.

AudD reports what it heard. It does not mute, block, or end a stream. Anything
that happens to the stream after a match (an overlay update, a log entry, a
moderation flag, a mute) is done by your platform.

## How it fits together

```
video host (IVS / Mux / Cloudflare / your own origin)
   │  "stream started" event ──► your webhook handler ──► audd.streams.add(url=HLS, radio_id=N)
   │  "stream ended" event   ──► your webhook handler ──► audd.streams.delete(radio_id=N)
   │
   └─ HLS playback URL ◄── AudD pulls the audio while the channel is live
                               │
                               ▼
        one account-wide callback URL ──► your callback receiver
                                            route by radio_id → channel
                                            log play / update now-playing / flag for review
after the stream:
   recording ──► ffmpeg (audio only) ──► audd.recognize_enterprise(..., limit=...) ──► publish, edit, or hold the replay
```

Four rules shape the design:

1. **One callback URL per AudD account.** `setCallbackUrl` is account-wide.
   Every channel's results arrive at the same endpoint, and the only thing that
   tells them apart is the `radio_id` you chose when you added the stream. Set
   the URL once at deploy time and never per channel.
2. **`radio_id` is an integer you own.** Keep a table that maps it to your
   channel.
3. **AudD must be able to fetch the playback URL** from its own servers for the
   whole time the channel is live. Private or signed URLs need care (see
   [Signed and private playback URLs](#signed-and-private-playback-urls)).
4. **The stream calls are idempotent.** A duplicated "stream started" webhook
   that calls `addStream` twice, or a `deleteStream` for a stream that is
   already gone, does no harm. Vendors do send duplicates, so this matters.

## Prerequisites

- An AudD token with audio streams enabled. Get your own token at
  [dashboard.audd.io](https://dashboard.audd.io). The public `test` token does
  not work on stream methods.
- A concurrent-stream limit on the dashboard that covers your peak number of
  channels that are live at once (see [Sizing and cost](#sizing-and-cost)).
- Python 3.10+ with `pip install audd fastapi uvicorn`.
- A public HTTPS endpoint for AudD's callbacks and for your video host's
  webhooks.

## Step 1: Map each event source to two calls

| Host | "Went live" event | "Ended" event | HLS URL you give AudD |
|---|---|---|---|
| Amazon IVS | EventBridge `detail-type: "IVS Stream State Change"`, `detail.event_name: "Stream Start"` | same detail-type, `event_name: "Stream End"` | The channel's `playbackUrl` (returned by `CreateChannel` / `GetChannel`) |
| Mux | Webhook `video.live_stream.active` | Webhook `video.live_stream.idle` | `https://stream.mux.com/{PLAYBACK_ID}.m3u8` |
| Cloudflare Stream | Notification with `data.event_type: "live_input.connected"` | `data.event_type: "live_input.disconnected"` | The live input's `playback.hls` URL, `https://customer-<CODE>.cloudflarestream.com/<INPUT_ID>/manifest/video.m3u8` |
| Your own origin (nginx-rtmp, SRS, a media server) | Its publish hook, e.g. nginx-rtmp `on_publish` | Its unpublish hook, e.g. `on_publish_done` | The HLS playlist your origin serves for that stream key |

Details worth knowing for each host:

- **IVS.** Use `Stream Start`, not `Session Created`: `Stream Start` fires once
  segments are available to viewers, which is when there is something for
  AudD to fetch. AWS documents EventBridge delivery as best-effort, and events
  can arrive late or out of order (for example `Stream End` before
  `Stream Start`). Each go-live has its own `detail.stream_id`. Store it and
  ignore an end event whose `stream_id` is not the current one. IVS events go
  to EventBridge, not to an HTTP URL, so create a rule with the pattern below
  and target either a Lambda that forwards the event or an EventBridge API
  destination that POSTs to your handler with a secret header.

  ```json
  {
    "source": ["aws.ivs"],
    "detail-type": ["IVS Stream State Change"],
    "detail": { "event_name": ["Stream Start", "Stream End"] }
  }
  ```

- **Mux.** `video.live_stream.disconnected` fires when the encoder drops, but
  the stream can still reconnect within its `reconnect_window`.
  `video.live_stream.idle` means the broadcast is over. Delete on `idle`, not
  on `disconnected`. Mux retries webhooks that don't get a 2xx and can deliver
  the same event more than once. Verify the `Mux-Signature` header as Mux's
  webhook-signature guide describes.
- **Cloudflare Stream.** Live input events come through Cloudflare
  Notifications: create a webhook destination with a secret, then add a
  Stream notification. Cloudflare sends the secret in the `cf-webhook-auth`
  header. `live_input.connected` means the encoder connected, and the HLS
  manifest may not be serving yet. Probe the manifest URL for a 200 before you
  call `addStream`, or add it anyway and expect a `650` notification until it
  is ready. `live_input.errored` carries an error code. Log it, and keep
  treating `disconnected` as the end.

## Step 2: Allocate radio_ids

The `radio_id` is how every result finds its channel, so choose the allocation
scheme deliberately.

| Scheme | How | Good for | Watch out for |
|---|---|---|---|
| **One per channel** (recommended) | The channel table's integer primary key, never reused | Now-playing overlays, per-channel logs; the longpoll category (derived from token + `radio_id`) stays the same across broadcasts | Callbacks for one broadcast can arrive after the next broadcast starts (see below) |
| **One per broadcast** | A fresh integer from a sequence each time the channel goes live | Unambiguous per-session attribution | A new longpoll category every session, so any widget wired to it changes every time; the mapping table grows |

Whichever you pick, **never reuse an integer for a different channel.** AudD
queues callbacks your server didn't accept and re-sends the backlog later, so a
result can arrive after `deleteStream`. If that `radio_id` already belongs to
someone else, the match lands on the wrong channel. In SQLite,
`INTEGER PRIMARY KEY AUTOINCREMENT` guarantees that deleted IDs are not handed
out again. Other databases have sequences that do the same.

To attribute plays to a specific broadcast with per-channel IDs, record each
broadcast's start and end time and assign each play by its `timestamp` field,
not by when the callback arrived.

## Step 3: The webhook handler (add and remove streams)

One FastAPI app takes the three hosts' events and keeps AudD's stream list in
step with what is live. The state lives in SQLite here so the example runs as
is. Swap in your own database.

```python
# app.py
import hashlib, hmac, json, os, sqlite3, time

from fastapi import FastAPI, Header, HTTPException, Request
from audd import AsyncAudD, AudDAPIError, AudDSerializationError

audd = AsyncAudD("your-api-token")  # get your own token at dashboard.audd.io
app = FastAPI()

MUX_WEBHOOK_SECRET = os.environ["MUX_WEBHOOK_SECRET"]
CF_WEBHOOK_SECRET = os.environ["CF_WEBHOOK_SECRET"]
IVS_HOOK_SECRET = os.environ["IVS_HOOK_SECRET"]  # header set on the EventBridge API destination

db = sqlite3.connect("live.db", check_same_thread=False)
db.row_factory = sqlite3.Row
db.executescript("""
CREATE TABLE IF NOT EXISTS channels (
  radio_id     INTEGER PRIMARY KEY AUTOINCREMENT,  -- what AudD sees; never reused
  provider     TEXT NOT NULL,                      -- 'ivs' | 'mux' | 'cloudflare'
  external_id  TEXT NOT NULL UNIQUE,               -- IVS channel ARN / Mux live stream id / CF input id
  playback_url TEXT NOT NULL,                      -- the HLS URL AudD pulls
  policy       TEXT NOT NULL DEFAULT 'log',        -- 'log' | 'review_all_music'
  session_id   TEXT,                               -- current broadcast, NULL when offline
  monitored    INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS plays (
  radio_id INTEGER, heard_at TEXT, artist TEXT, title TEXT,
  label TEXT, isrc TEXT, song_link TEXT, score INTEGER, play_length INTEGER
);
CREATE TABLE IF NOT EXISTS review_queue (
  radio_id INTEGER, heard_at TEXT, artist TEXT, title TEXT, reason TEXT
);
""")


def channel_by_external_id(external_id: str):
    return db.execute("SELECT * FROM channels WHERE external_id = ?", (external_id,)).fetchone()


async def start_monitoring(external_id: str, session_id: str | None = None) -> None:
    ch = channel_by_external_id(external_id)
    if ch is None:
        return  # a channel you don't track
    try:
        await audd.streams.add(
            url=ch["playback_url"],
            radio_id=ch["radio_id"],
            callbacks="before",  # match at song start; use the default for play_length
        )
    except AudDAPIError as e:
        # e.g. the account is at its concurrent-stream limit; alert, don't crash the hook
        print(f"addStream failed for radio_id={ch['radio_id']}: #{e.error_code} {e.message}")
        return
    db.execute("UPDATE channels SET monitored = 1, session_id = ? WHERE radio_id = ?",
               (session_id, ch["radio_id"]))
    db.commit()


async def stop_monitoring(external_id: str, session_id: str | None = None) -> None:
    ch = channel_by_external_id(external_id)
    if ch is None:
        return
    if session_id and ch["session_id"] and session_id != ch["session_id"]:
        return  # a late end event for an earlier broadcast; the channel is live again
    await audd.streams.delete(radio_id=ch["radio_id"])
    db.execute("UPDATE channels SET monitored = 0, session_id = NULL WHERE radio_id = ?",
               (ch["radio_id"],))
    db.commit()


# --- Amazon IVS (EventBridge rule -> API destination -> here) -------------------
@app.post("/hooks/ivs")
async def ivs_hook(request: Request, x_hook_secret: str = Header(default="")):
    if not hmac.compare_digest(x_hook_secret, IVS_HOOK_SECRET):
        raise HTTPException(401)
    event = await request.json()
    detail = event.get("detail", {})
    channel_arn = (event.get("resources") or [None])[0]
    if detail.get("event_name") == "Stream Start":
        await start_monitoring(channel_arn, detail.get("stream_id"))
    elif detail.get("event_name") == "Stream End":
        await stop_monitoring(channel_arn, detail.get("stream_id"))
    return {"ok": True}


# --- Mux ------------------------------------------------------------------------
def verify_mux_signature(raw: bytes, header: str) -> None:
    # Header looks like "t=1700000000,v1=<hex>"; check Mux's signature guide for details.
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    signed = f"{parts.get('t', '')}.".encode() + raw
    expected = hmac.new(MUX_WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise HTTPException(401)
    if abs(time.time() - int(parts.get("t", "0"))) > 300:
        raise HTTPException(401)


@app.post("/hooks/mux")
async def mux_hook(request: Request, mux_signature: str = Header(default="")):
    raw = await request.body()
    verify_mux_signature(raw, mux_signature)
    event = json.loads(raw)
    live_stream_id = event.get("data", {}).get("id")
    if event.get("type") == "video.live_stream.active":
        await start_monitoring(live_stream_id)
    elif event.get("type") == "video.live_stream.idle":
        await stop_monitoring(live_stream_id)
    return {"ok": True}


# --- Cloudflare Stream (Notifications webhook) ---------------------------------
@app.post("/hooks/cloudflare")
async def cloudflare_hook(request: Request, cf_webhook_auth: str = Header(default="")):
    if not hmac.compare_digest(cf_webhook_auth, CF_WEBHOOK_SECRET):
        raise HTTPException(401)
    data = (await request.json()).get("data", {})
    if data.get("event_type") == "live_input.connected":
        await start_monitoring(data.get("input_id"))
    elif data.get("event_type") == "live_input.disconnected":
        await stop_monitoring(data.get("input_id"))
    return {"ok": True}
```

Rows in `channels` are created when a creator's channel is created on your
platform. That is when you have the IVS channel ARN and `playbackUrl`, the Mux
live stream ID and playback ID, or the Cloudflare input ID and its HLS URL:

```python
def register_channel(provider: str, external_id: str, playback_url: str, policy: str = "log") -> int:
    cur = db.execute(
        "INSERT INTO channels (provider, external_id, playback_url, policy) VALUES (?, ?, ?, ?)",
        (provider, external_id, playback_url, policy),
    )
    db.commit()
    return cur.lastrowid  # the radio_id for this channel, permanently
```

A connection error from AudD propagates as a 500 on purpose. Mux and
EventBridge API destinations both retry on failure, and because `addStream` and
`deleteStream` are idempotent, the retry is safe.

### Reconcile on a schedule

Webhooks get lost, and IVS says outright that EventBridge delivery is
best-effort. Run a job every few minutes that compares what your host says is
live against what AudD is monitoring, and fixes the difference:

```python
async def reconcile(live_external_ids: set[str]) -> None:
    """live_external_ids: from the host's API (e.g. IVS ListStreams, Mux live streams with status 'active')."""
    monitored = {s.radio_id for s in await audd.streams.list()}
    for ch in db.execute("SELECT * FROM channels").fetchall():
        is_live = ch["external_id"] in live_external_ids
        if is_live and ch["radio_id"] not in monitored:
            await start_monitoring(ch["external_id"], ch["session_id"])
        elif not is_live and ch["radio_id"] in monitored:
            await audd.streams.delete(radio_id=ch["radio_id"])
            db.execute("UPDATE channels SET monitored = 0, session_id = NULL WHERE radio_id = ?",
                       (ch["radio_id"],))
            db.commit()
```

`audd.streams.list()` returns every stream on the account with its `radio_id`,
`url`, `stream_running`, and `longpoll_category`.

## Step 4: The callback receiver (route by radio_id)

Set the account's callback URL once, at deploy time. Put a long random secret
in the path so that only AudD knows where to POST:

```python
# setup_once.py
from audd import AudD

audd = AudD("your-api-token")
audd.streams.set_callback_url("https://live.example.com/audd/9f2c7e1b4a5d")  # your secret path
```

Then add the receiver to the same app:

```python
AUDD_CALLBACK_SECRET = os.environ["AUDD_CALLBACK_SECRET"]  # the "9f2c7e1b4a5d" part
now_playing: dict[int, dict] = {}  # radio_id -> current song; use Redis across workers


@app.post("/audd/{secret}")
async def audd_callback(secret: str, request: Request):
    if not hmac.compare_digest(secret, AUDD_CALLBACK_SECRET):
        raise HTTPException(404)
    try:
        match, notif = await audd.streams.handle_callback(request)
    except AudDSerializationError as e:
        print("unparseable AudD callback:", e)
        return {"ok": True}  # a non-200 makes AudD queue and re-send it

    if match is not None:
        ch = db.execute("SELECT * FROM channels WHERE radio_id = ?", (match.radio_id,)).fetchone()
        if ch is None:
            return {"ok": True}  # unknown id: nothing to route to
        song = match.song
        db.execute(
            "INSERT INTO plays VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (match.radio_id, match.timestamp, song.artist, song.title, song.label,
             song.isrc, song.song_link, song.score, match.play_length),
        )
        if ch["monitored"]:
            now_playing[match.radio_id] = {
                "artist": song.artist, "title": song.title,
                "cover": f"{song.song_link}?thumb" if song.song_link else None,
            }
        if ch["policy"] == "review_all_music":
            db.execute("INSERT INTO review_queue VALUES (?, ?, ?, ?, ?)",
                       (match.radio_id, match.timestamp, song.artist, song.title,
                        "recorded music on a no-music channel"))
        db.commit()

    elif notif is not None:
        ch = db.execute("SELECT monitored FROM channels WHERE radio_id = ?",
                        (notif.radio_id,)).fetchone()
        # 650 = can't connect, 651 = no music / white noise. After a broadcast ends and
        # before deleteStream lands, a 650 is expected; only alert for channels still live.
        if ch and ch["monitored"] and notif.notification_code in (650, 651):
            print(f"radio_id={notif.radio_id}: {notif.notification_code} {notif.notification_message}")

    return {"ok": True}


@app.get("/channels/{radio_id}/now-playing")
async def get_now_playing(radio_id: int):
    return now_playing.get(radio_id)
```

Keep the receiver fast: write the row, update the cache, return 200. Push
anything slow (notifying a moderator, calling your own APIs) onto a queue.

What to do with a match is your platform's decision:

- **Log music use.** The `plays` table is a per-channel record of artist,
  title, label, and when each song was heard, which you can bring to a
  licensing conversation. `label` is the releasing label, not necessarily the
  rights owner. `isrc` and `upc` are empty unless enabled for your account: the
  [streams docs](https://docs.audd.io/streams) say to contact AudD for them,
  and they need a Startup plan or higher.
- **Show now playing.** Serve `now_playing` to an overlay in your player. The
  [now-playing widget recipe](/resources/recipes/now-playing-widget) has a
  frontend, and the hosted widget at `widget.audd.tech` works from a channel's
  `longpoll_category` without exposing your token.
- **Flag for moderation.** Queue the match for a person to review, with the
  channel's policy as context. Use the
  [score guidance](/resources/concepts/score-thresholds) to decide what goes
  straight to the queue.
- **Mute or notify.** Your player or ingest does the muting. AudD has no
  control over the stream. A softer option is to message the creator ("a
  recorded track was detected in your stream").

### `callbacks="before"` or the default

`addStream` takes a per-stream `callbacks` setting. With `"before"`, the match
arrives when a song starts, which suits overlays and moderation. With the
default, it arrives when the song ends and includes `play_length`, which suits
duration-based music-use logs. If both matter, start with `"before"` and
estimate durations from the gap between consecutive matches on a channel, or
scan the recording afterwards (Step 5).

## Step 5: Scan the recording before publishing the replay

The live log tells you what played. Before you publish a replay you may want a
second pass over the actual recording: it covers moments the live log
missed, and it gives you file positions you can mute or cut. Get the recording
from the host:

- **IVS:** wait for the `IVS Recording State Change` event with
  `recording_status: "Recording End"`. AWS recommends processing recordings
  only after it. The files are under `recording_s3_bucket_name` /
  `recording_s3_key_prefix`.
- **Mux:** `video.asset.live_stream_completed` marks the recorded asset as
  on-demand.
- **Cloudflare:** list the live input's videos
  (`GET /accounts/<ACCOUNT_ID>/stream/live_inputs/<INPUT_ID>/videos`). Each
  video has `playback.hls`.

Extract the audio with ffmpeg and send it to the enterprise endpoint. Always
pass `limit`:

```bash
ffmpeg -i "https://stream.mux.com/PLAYBACK_ID.m3u8" -vn -ac 1 -c:a libmp3lame -b:a 96k replay.mp3
```

```python
from audd import AudD

audd = AudD("your-api-token")

matches = audd.recognize_enterprise(
    "replay.mp3",
    limit=50,       # hard cap on metered 12-second chunks while you tune this
    every=1, skip=4,  # one 12 s probe per minute: enough for "is there music in this replay?"
)
for m in matches:
    print(f"{m.start_seconds:.0f}s  {m.artist} — {m.title}  (score {m.score})")
```

Enterprise bills one request per 12 seconds of audio processed. A two-hour
replay is 600 chunks at full coverage, so thin it with `every`/`skip` when a
yes/no is enough, and cover every chunk only when you need exact segments to
cut. [Controlling enterprise cost](/resources/concepts/enterprise-cost-control)
explains the trade-off. To mute or cut what you find, follow
[Flag and remove music in a live-event recording](/resources/recipes/flag-music-in-live-recording).

## Signed and private playback URLs

AudD fetches the playback URL from its own servers, for as long as the stream
is added. Anything that ties the URL to a viewer can break that:

- **Expiring tokens.** A Mux signed playback ID needs `?token=<JWT>`. Mux
  denies requests once the token's `exp` has passed, even mid-playback.
  An IVS private channel takes `?token=<JWT>`, and AWS says the token is
  validated when playback starts. A reconnect starts playback again, though.
  Cloudflare live inputs can require signed URLs. In each case, **test that
  the URL you give AudD stays valid for the longest broadcast you expect**,
  or issue a fresh URL before the old one expires and swap it in with
  `audd.streams.set_url(radio_id=..., url=new_url)`. Check either approach on
  a real broadcast before relying on it, and watch for `650` notifications.
- **Origin or viewer binding.** IVS claims like
  `strict-origin-enforcement`, `single-use-uuid`, and `viewer-id` are built for
  browsers and individual viewers. A single-use token is spent on the first
  playlist fetch, and `viewer-id` caps `exp` at 10 minutes. Mint a separate
  token for AudD without these claims.
- **IP allowlists, cookies, geo-blocking.** AudD's fetcher has no session
  cookie and isn't on your allowlist. A URL that only works from your network
  or a logged-in browser won't work for AudD.

Public channels need none of this. For private channels, such as paid classes
or internal events, also decide whether sending their audio to a third party
fits your terms and privacy policy. See
[GDPR and music recognition](/resources/articles/gdpr-music-recognition).

## Sizing and cost

- **Concurrent streams.** The [streams docs](https://docs.audd.io/streams)
  have you set a maximum number of concurrent streams on the dashboard and pay
  for it. What matters is how many channels are live at the same moment, not
  how many channels you have. Size the limit to your peak, and decide what
  happens when a go-live would exceed it (the `AudDAPIError` branch in Step 3):
  skip, prioritize by category, or alert. Monitoring only channels where music
  matters (public, monetized, a "music" category) also keeps the number down.
  Current prices are on the streams docs page. For platform volumes, write to
  api@audd.io.
- **Callback rate.** With fewer than 500 streams, AudD sends at most 3
  callbacks per second, with a burst of 15. To estimate your rate, take
  concurrent live channels × songs per channel per minute ÷ 60, then add
  notifications. 300 channels each changing songs every 3 minutes is about 1.7
  per second. Above the limit, callbacks queue and arrive late, which is fine
  for a log and worse for an overlay. With 500 streams or more, ask AudD what
  applies.
- **Latency.** Don't plan around a number from this page. Measure it: play a
  known song on a test channel, note when it starts, and log when the callback
  arrives. Repeat across a few songs and times of day, with `callbacks="before"`
  and with the default.

## Gotchas

- Don't call `setCallbackUrl` per channel. It overwrites the account-wide
  URL for every stream.
- Branch on `match` / `notification`. Notification callbacks have no song.
- Expect a `650` between a broadcast ending and your `deleteStream`. It means
  the source went away.
- Recognition matches commercially released recordings. A live performance, a
  cover, or humming generally won't match. AudD doesn't analyze the video
  picture and doesn't tell you whether a use is licensed.
- More stream gotchas, written for coding agents:
  [Agent notes: streams and longpoll](/resources/agents/streams).

---

**Related**

- [AudD for live-streaming platforms](/resources/for/live-streaming-platforms)
- [Show recognized music on a Twitch stream](/resources/integrations/twitch-extension)
- [Build a now-playing widget for a livestream](/resources/recipes/now-playing-widget)
- [Flag and remove music in a live-event recording](/resources/recipes/flag-music-in-live-recording)
- [Webhook callbacks vs longpoll for stream results](/resources/concepts/callback-vs-longpoll)
- [Stream callback and longpoll payload reference](/resources/reference/streams-callback-shape)
- [Streams API reference](https://docs.audd.io/streams)

## Sitemap

See the [resources sitemap](https://audd.io/resources/sitemap.md) for every guide and the [glossary](https://audd.io/resources/reference/glossary.md) for AudD terms.
