---
title: "Build a now-playing widget for a livestream"
description: "Show the currently-playing song on a livestream overlay using AudD audio streams, with a webhook callback receiver or longpoll, plus a copy-paste HTML widget."
slug: "/resources/recipes/now-playing-widget"
section: "recipes"
keywords: [audd, now playing, livestream overlay, twitch, youtube, streams, longpoll, obs]
---

# Build a now-playing widget for a livestream

If you run a music livestream — a Twitch DJ set, a YouTube radio channel, an
Icecast web radio — you often want to show viewers what's playing right now.
This recipe wires AudD's audio-streams API to a small web widget: AudD
recognizes songs off your stream as they play, your backend receives each
match, and an overlay renders the artist, title, and cover art.

## What you'll build

Three moving parts:

1. **A stream registered with AudD.** You tell AudD the URL of your
   livestream once (a direct HLS/Icecast URL, or a `twitch:<channel>` /
   `youtube:<video_id>` shortcut). AudD ingests it continuously and
   fingerprints the audio.
2. **A way to consume matches.** AudD delivers recognized songs to you in one
   of two ways: it **POSTs a callback** to a URL you host (the recommended
   path), or you **longpoll** an HTTP endpoint (when you can't host a public
   receiver — a browser, a laptop behind NAT). This recipe shows both.
3. **A widget.** A self-contained HTML/JS overlay that polls your backend for
   the current song and renders it. Drop it into an OBS browser source or
   embed it on a web page.

The streams API lives on `https://api.audd.io/`. By default AudD sends a
callback *after* a song finishes (so it can report total play time); pass
`callbacks=before` if you want the match the moment a song starts — which is
what you want for a live "now playing" display.

## Prerequisites

- An API token from [dashboard.audd.io](https://dashboard.audd.io). Audio
  streams are a paid add-on billed per stream per month; the `test` token
  does **not** work on the streams endpoints, so use your real token here.
- Node.js 18+ with the official SDK: `npm install @audd/sdk`
- A livestream to recognize. For a quick test you can point AudD at any
  public radio URL, e.g. `https://npr-ice.streamguys1.com/live.mp3`.

## Walkthrough

### Step 1: Set the account callback URL (once)

A callback URL is configured **per account**, not per stream — every stream
on the account POSTs its results to the same URL. Set it once before you add
any streams.

```typescript
import { AudD } from "@audd/sdk";

const audd = new AudD(process.env.AUDD_API_TOKEN!); // dashboard.audd.io

await audd.streams.setCallbackUrl(
  "https://your-app.example.com/audd-callback",
  { returnMetadata: ["apple_music", "spotify"] }, // adds streaming links
);
```

`returnMetadata` is optional. Including `apple_music` / `spotify` means each
callback carries that provider's block with streaming URLs and IDs. Cover art
doesn't need it: every match has a `songLink` (a lis.tn URL), and appending
`?thumb` to it returns the cover image — that's what the widget below uses, so
it works whether or not you request provider blocks.

> **Even if you only plan to longpoll, the account still needs a callback
> URL.** Longpoll delivers nothing until one is set. If you have no real
> receiver, set it to `https://audd.tech/empty/` — a no-op URL that accepts
> and discards the POSTs:
>
> ```typescript
> await audd.streams.setCallbackUrl("https://audd.tech/empty/");
> ```

### Step 2: Add your stream

Register the livestream with `streams.add`. You pick a `radioId` — any integer
— as your handle for this stream; you'll see it on every match so you can
tell streams apart.

```typescript
const radioId = 1; // your handle for this stream

// A direct stream URL:
await audd.streams.add({
  url: "https://npr-ice.streamguys1.com/live.mp3",
  radioId,
  callbacks: "before", // deliver at song START — best for "now playing"
});
```

AudD also recognizes Twitch and YouTube live streams natively. Instead of a
raw media URL, pass a shortcut:

```typescript
// Twitch channel:
await audd.streams.add({ url: "twitch:monstercat", radioId: 2, callbacks: "before" });

// YouTube video id (from youtube.com/watch?v=5qap5aO4i9A):
await audd.streams.add({ url: "youtube:5qap5aO4i9A", radioId: 3, callbacks: "before" });

// YouTube channel's current live stream:
await audd.streams.add({ url: "youtube-ch:UC3zwjSYv4k5HKGXCHMpjVRg", radioId: 4, callbacks: "before" });
```

To change a stream's URL later use `audd.streams.setUrl(radioId, url)`, and
`audd.streams.delete(radioId)` to remove it. `audd.streams.list()` enumerates
everything on the account.

`callbacks: "before"` delivers the match as soon as a song starts, which is
what a live display needs. The tradeoff: a "before" callback doesn't carry the
song's total played time (AudD doesn't know it yet). For a now-playing widget
you don't need play time, so "before" is the right choice.

### Step 3a: Receive matches with a callback (recommended)

If you can host a public HTTPS endpoint, callbacks are the lowest-latency,
lowest-effort path. AudD POSTs a JSON body to your URL for every recognized
song and every stream-lifecycle notification. The SDK parses the body for
you.

```typescript
import express from "express";
import { handleCallback } from "@audd/sdk";

const app = express();
app.use(express.json());

// In-memory "current song" per stream. Use Redis in production.
const nowPlaying = new Map<number, unknown>();

app.post("/audd-callback", async (req, res) => {
  try {
    const { match, notification } = await handleCallback(req);
    if (match) {
      const s = match.song;
      nowPlaying.set(match.radioId, {
        artist: s.artist,
        title: s.title,
        cover: s.songLink ? `${s.songLink}?thumb` : null, // lis.tn cover-art
        songLink: s.songLink, // universal lis.tn link
        at: Date.now(),
      });
    } else if (notification) {
      // 650 = can't connect to the stream; 651 = only white noise
      console.warn("stream", notification.notificationCode, notification.notificationMessage);
    }
    res.sendStatus(200); // ALWAYS 200 — see note below
  } catch (err) {
    console.error("bad callback body", err);
    res.sendStatus(400);
  }
});

// The widget polls this:
app.get("/now-playing/:radioId", (req, res) => {
  res.json(nowPlaying.get(Number(req.params.radioId)) ?? null);
});

app.listen(8080);
```

When a song starts on the stream, AudD POSTs to `/audd-callback`, you cache
it, and `/now-playing/1` returns the current track as JSON.

> **Always respond `200 OK` to a callback.** If your endpoint errors or is
> unreachable, AudD queues the callbacks and re-sends the backlog once you
> recover — so a transient bug won't lose matches, but it will delay them.

### Step 3b: Receive matches with longpoll (no public server)

When you can't expose a public URL — you're prototyping on a laptop, the
widget runs in a browser, the backend sits behind NAT — longpoll is the
alternative. Your process holds a `GET https://api.audd.io/longpoll/` request
open and matches arrive over that connection.

```typescript
const radioId = 1;

const poll = await audd.streams.longpoll({ radioId, timeout: 50 });

for await (const m of poll.matches) {
  console.log("now playing:", m.song.artist, "—", m.song.title);
  // update the same nowPlaying cache your widget reads
}
```

`longpoll` returns a handle with three independent async-iterables —
`matches`, `notifications`, and `errors` — fed by a background loop. Call
`poll.close()` to stop it. `timeout` is the server-side hold in seconds
(default 50); a longer hold means fewer reconnects.

Remember the rule from Step 1: longpoll returns nothing unless the account
has a callback URL set. The SDK preflights this on your first `longpoll`
call and raises `AudDInvalidRequestError` if it's missing. Set
`https://audd.tech/empty/` if you have no real receiver.

### Step 4: The widget

This is a self-contained overlay. It polls your `/now-playing/:radioId`
endpoint every few seconds and re-renders when the song changes. Save it as
`widget.html` and point an OBS **Browser** source at the file (or host it and
load the URL).

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Now Playing</title>
  <style>
    body { margin: 0; background: transparent; font-family: system-ui, sans-serif; }
    .np {
      display: flex; align-items: center; gap: 14px;
      padding: 12px 16px; border-radius: 12px;
      background: rgba(0,0,0,.65); color: #fff; width: max-content;
      box-shadow: 0 4px 20px rgba(0,0,0,.4);
    }
    .np img { width: 56px; height: 56px; border-radius: 8px; object-fit: cover; }
    .np .meta { line-height: 1.3; }
    .np .title { font-weight: 600; font-size: 16px; }
    .np .artist { opacity: .85; font-size: 14px; }
    .np.hidden { display: none; }
  </style>
</head>
<body>
  <div id="np" class="np hidden">
    <img id="cover" alt="" />
    <div class="meta">
      <div id="title" class="title"></div>
      <div id="artist" class="artist"></div>
    </div>
  </div>

  <script>
    const BACKEND = "https://your-app.example.com"; // your Step 3 server
    const RADIO_ID = 1;
    const POLL_MS = 5000;

    const el = {
      box: document.getElementById("np"),
      cover: document.getElementById("cover"),
      title: document.getElementById("title"),
      artist: document.getElementById("artist"),
    };
    let current = null;

    async function tick() {
      try {
        const res = await fetch(`${BACKEND}/now-playing/${RADIO_ID}`);
        const song = await res.json();
        if (!song) { el.box.classList.add("hidden"); current = null; return; }
        const key = `${song.artist} - ${song.title}`;
        if (key === current) return; // unchanged
        current = key;
        el.title.textContent = song.title ?? "";
        el.artist.textContent = song.artist ?? "";
        if (song.cover) { el.cover.src = song.cover; el.cover.style.display = ""; }
        else { el.cover.style.display = "none"; }
        el.box.classList.remove("hidden");
      } catch (e) {
        console.error("poll failed", e);
      }
    }

    tick();
    setInterval(tick, POLL_MS);
  </script>
</body>
</html>
```

**Embedding in OBS.** Add a **Browser** source, point it at the hosted
`widget.html` URL (or the local file), and set the canvas width/height to fit
the overlay. The transparent background means it composites cleanly over your
scene.

**AudD-hosted widgets.** If you'd rather not build a frontend, AudD hosts an
embeddable widget at `widget.audd.tech` that renders the last recognized song
(and optionally a history strip) directly from a longpoll subscription. You
pass it a stream identifier as a URL parameter — see *Going further*.

## What you get back

A callback body for a recognized song looks like this (the SDK parses it into
a `StreamCallbackMatch`):

```json
{
  "status": "success",
  "result": {
    "radio_id": 1,
    "timestamp": "2020-04-13 10:31:43",
    "play_length": 111,
    "results": [
      {
        "artist": "Alan Walker, A$AP Rocky",
        "title": "Live Fast (PUBGM)",
        "album": "Live Fast (PUBGM)",
        "release_date": "2019-07-25",
        "label": "MER Recordings",
        "score": 100,
        "song_link": "https://lis.tn/LiveFastPUBGM"
      }
    ]
  }
}
```

| Field | Meaning for the widget |
|---|---|
| `radio_id` | Which stream this match is for — your handle from `streams.add`. |
| `timestamp` | When AudD recognized the song. |
| `play_length` | Total seconds the song played. **Absent with `callbacks=before`** (the song hasn't finished yet). |
| `results[0].artist`, `.title`, `.album` | What's playing — the core of the widget. |
| `results[0].song_link` | A universal `lis.tn` link to the track; good as a click-through. |
| `results[0].score` | Match confidence. |

A **notification** body arrives instead of a result when something happens to
the stream itself — code `0` means all is well, `650` means AudD can't
connect to the stream, `651` means it's receiving only white noise. Surface
650/651 so you know to fix the stream URL.

If you set `returnMetadata` in Step 1, each result also carries the requested
provider blocks (e.g. `apple_music`, `spotify`) with their streaming URLs and
IDs. Any field AudD returns that the SDK doesn't expose as a typed property is
available on the result's `extras` map.

## Handling errors

For a now-playing widget the failure modes are narrow:

- **Authentication errors** (`AudDAuthenticationError`) — bad or missing
  token, or the streams add-on isn't enabled on your account. Fail at startup.
- **Invalid-request errors** (`AudDInvalidRequestError`) — most commonly the
  longpoll preflight firing because no callback URL is set. Set one (a real
  URL or `https://audd.tech/empty/`) and retry.
- **Stream notifications** (codes `650` / `651`) — not exceptions, but
  delivered as notification callbacks. A `650` usually means your stream URL
  went stale; update it with `setUrl`.
- **Connection errors** (`AudDConnectionError`) — transient. The longpoll
  loop reconnects on its own; for callbacks, AudD queues and re-sends.

```typescript
import { AudDInvalidRequestError } from "@audd/sdk";

try {
  const poll = await audd.streams.longpoll({ radioId: 1 });
  // ...
} catch (err) {
  if (err instanceof AudDInvalidRequestError) {
    // almost always: no callback URL on the account
    await audd.streams.setCallbackUrl("https://audd.tech/empty/");
  } else {
    throw err;
  }
}
```

## Going further

- **No-code widget.** AudD's hosted widget at
  `https://widget.audd.tech/?ch=-<category>&background&history&shadow` renders
  the last recognized song and a history strip without any frontend code of
  your own. The `ch` parameter is the stream's longpoll category with a `-`
  prefix; you can derive it server-side without shipping your token to the
  browser.
- **Multiple streams on one overlay.** Each stream has its own `radioId`;
  cache per `radioId` and let the widget pick which one to show.
- **History strip.** Keep the last *N* matches per stream instead of just the
  current one, and render a "recently played" list.
- For the difference between recognizing against AudD's public 160M-track
  database and your own uploaded catalog, see
  [Public database vs. your custom catalog](/resources/concepts/custom-vs-public-db).

---

**Related**

- [Monitor radio airplay for your music catalog](/resources/recipes/radio-airplay-monitor)
- [Public database vs. your custom catalog](/resources/concepts/custom-vs-public-db)
- [Node.js SDK docs](https://docs.audd.io/sdks/node)
- [Streams API reference](https://docs.audd.io/streams)