Integration

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

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.

view .md auddlive streaming platformamazon ivsmux

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).
  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. 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).
  • 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” eventHLS URL you give AudD
Amazon IVSEventBridge 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)
MuxWebhook video.live_stream.activeWebhook video.live_stream.idlehttps://stream.mux.com/{PLAYBACK_ID}.m3u8
Cloudflare StreamNotification 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_publishIts unpublish hook, e.g. on_publish_doneThe 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.

    {
      "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.

SchemeHowGood forWatch out for
One per channel (recommended)The channel table’s integer primary key, never reusedNow-playing overlays, per-channel logs; the longpoll category (derived from token + radio_id) stays the same across broadcastsCallbacks for one broadcast can arrive after the next broadcast starts (see below)
One per broadcastA fresh integer from a sequence each time the channel goes liveUnambiguous per-session attributionA 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.

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

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:

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:

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

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

ffmpeg -i "https://stream.mux.com/PLAYBACK_ID.m3u8" -vn -ac 1 -c:a libmp3lame -b:a 96k replay.mp3
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 explains the trade-off. To mute or cut what you find, follow Flag and remove music in a live-event 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.

Sizing and cost

  • Concurrent streams. The streams docs 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 [email protected].
  • 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.

Related

Reading this as an AI agent? The raw Markdown is at integrations/live-stream-platforms.md, and the full index is /resources/llms.txt.