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.
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:
- One callback URL per AudD account.
setCallbackUrlis account-wide. Every channel’s results arrive at the same endpoint, and the only thing that tells them apart is theradio_idyou chose when you added the stream. Set the URL once at deploy time and never per channel. radio_idis an integer you own. Keep a table that maps it to your channel.- 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).
- The stream calls are idempotent. A duplicated “stream started” webhook
that calls
addStreamtwice, or adeleteStreamfor 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
testtoken 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” 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, notSession Created:Stream Startfires 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 exampleStream EndbeforeStream Start). Each go-live has its owndetail.stream_id. Store it and ignore an end event whosestream_idis 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.disconnectedfires when the encoder drops, but the stream can still reconnect within itsreconnect_window.video.live_stream.idlemeans the broadcast is over. Delete onidle, not ondisconnected. Mux retries webhooks that don’t get a 2xx and can deliver the same event more than once. Verify theMux-Signatureheader 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-authheader.live_input.connectedmeans the encoder connected, and the HLS manifest may not be serving yet. Probe the manifest URL for a 200 before you calladdStream, or add it anyway and expect a650notification until it is ready.live_input.erroredcarries an error code. Log it, and keep treatingdisconnectedas 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.
# 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
playstable is a per-channel record of artist, title, label, and when each song was heard, which you can bring to a licensing conversation.labelis the releasing label, not necessarily the rights owner.isrcandupcare 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_playingto an overlay in your player. The now-playing widget recipe has a frontend, and the hosted widget atwidget.audd.techworks from a channel’slongpoll_categorywithout 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 Changeevent withrecording_status: "Recording End". AWS recommends processing recordings only after it. The files are underrecording_s3_bucket_name/recording_s3_key_prefix. - Mux:
video.asset.live_stream_completedmarks 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 hasplayback.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’sexphas 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 withaudd.streams.set_url(radio_id=..., url=new_url). Check either approach on a real broadcast before relying on it, and watch for650notifications. - Origin or viewer binding. IVS claims like
strict-origin-enforcement,single-use-uuid, andviewer-idare built for browsers and individual viewers. A single-use token is spent on the first playlist fetch, andviewer-idcapsexpat 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
AudDAPIErrorbranch 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
setCallbackUrlper channel. It overwrites the account-wide URL for every stream. - Branch on
match/notification. Notification callbacks have no song. - Expect a
650between a broadcast ending and yourdeleteStream. 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.
