Build on your station.

A REST API for everything your dashboard can see and do, signed webhooks when something happens on air, and a public realtime feed for your website. Included on every plan.

Overview

With a key from your dashboard you can:

  • Read everything — stations, now playing, play history, who is listening, stats and the full analytics report.
  • Manage the library — list, search, upload and delete tracks; read playlists and the weekly schedule.
  • Control the station — start and stop it, push a title during a live show, play a track next.
  • Subscribe to events — webhooks for track and on-air changes, or a public Server-Sent Events feed without a key.
Base URLhttps://streaming-api.nobexpartners.com
Version header (on every response)X-Nobex-Api-Version: 2026-09-08
Rate limit600 requests / minute per key, reported in X-RateLimit-Limit, -Remaining and -Reset
FormatJSON in, JSON out; timestamps are ISO 8601 in UTC
Station identifierstation id or public slug — either works in {station}

Errors

Every error, from every endpoint, has the same shape:

Error response
{
  "error": {
    "code": "not_found",
    "message": "Station not found"
  }
}
StatuscodeMeaning
400validation_errorA parameter or body field is missing or malformed.
401unauthorized / invalid_api_key / revoked_api_keyNo key, an unknown key, or a key you revoked.
402plan_requiredThe station’s plan does not include this action.
403forbidden / insufficient_scopeA read-only key called a write endpoint.
404not_foundNo station, track or playlist by that id that you own.
429rate_limitedOver 600 requests in a minute. Wait for X-RateLimit-Reset.
500internal_errorSomething failed on our side. Safe to retry.

Authentication

Create a key under Dashboard → Developers. Keys look like nbx_live_…, are shown once at creation, and are stored hashed — if you lose one, revoke it and create another.

Send the key either way:

Either header works
Authorization: Bearer nbx_live_YOUR_KEY
# or
X-API-Key: nbx_live_YOUR_KEY
  • Scopes. A key has read, or read and write. Reads never change anything; every endpoint marked write in the reference needs the write scope, and a read-only key gets a 403 insufficient_scope.
  • Account-wide. A key reaches every station on the account. Give an integration its own key so you can revoke it alone.
  • Revoke any time. Revoked keys fail immediately with 401 revoked_api_key. Up to 20 active keys per account.

Quick start

Replace nbx_live_YOUR_KEY with your key and moonlightfm with your station id or slug.

List your stations
curl https://streaming-api.nobexpartners.com/v1/stations \
  -H "Authorization: Bearer nbx_live_YOUR_KEY"
What is playing
curl https://streaming-api.nobexpartners.com/v1/stations/moonlightfm/now-playing \
  -H "X-API-Key: nbx_live_YOUR_KEY"
Start the station (write scope)
curl -X POST https://streaming-api.nobexpartners.com/v1/stations/moonlightfm/start \
  -H "Authorization: Bearer nbx_live_YOUR_KEY"

Node.js

Node 18+ (built-in fetch)
const API = "https://streaming-api.nobexpartners.com";
const KEY = process.env.NOBEX_API_KEY; // nbx_live_...

async function nobex(path, init = {}) {
  const res = await fetch(API + path, {
    ...init,
    headers: {
      Authorization: "Bearer " + KEY,
      "Content-Type": "application/json",
      ...(init.headers || {}),
    },
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error.code + ": " + body.error.message);
  return body;
}

const { data: stations } = await nobex("/v1/stations");
for (const s of stations) {
  const np = await nobex("/v1/stations/" + s.id + "/now-playing");
  console.log(s.name, "-", np.now_playing ? np.now_playing.title : "off air");
}

Python

Python 3 with requests
import os, requests

API = "https://streaming-api.nobexpartners.com"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["NOBEX_API_KEY"]

stations = session.get(f"{API}/v1/stations").json()["data"]
for s in stations:
    r = session.get(f"{API}/v1/stations/{s['id']}/history", params={"limit": 5})
    r.raise_for_status()
    for play in r.json()["data"]:
        print(s["name"], play["played_at"], play["artist"], "-", play["title"])

Endpoint reference

The complete, machine-readable description is the OpenAPI 3.1 document at https://streaming-api.nobexpartners.com/v1/openapi.json. It imports into Postman, Insomnia, Bruno, and any OpenAPI code generator, so you can have a typed client in your language without writing one.

{station} is a station id or its public slug. Every station-scoped call is checked against your ownership — a station you do not own is a 404, not a 403.

Account and stations

MethodPathScopeWhat it does
GET/v1/mereadThe account the key belongs to: id, email, name, created_at.
GET/v1/stationsreadEvery station you own.
GET/v1/stations/{station}readOne station: slug, name, genre, timezone, status, on_air, live, stream_urls (mp3, hls, public_page), logo_url, plan.

Now playing and history

MethodPathScopeWhat it does
GET/v1/stations/{station}/now-playingreadThe current track, plus source (autodj, live or manual) when it can be derived.
POST/v1/stations/{station}/now-playingwriteSet the title (and optional artist) shown to listeners — for live shows and external playout.
GET/v1/stations/{station}/history?limit=50&before=<iso>readRecently played tracks, newest first, with the listener count at the time each started.

Listeners

MethodPathScopeWhat it does
GET/v1/stations/{station}/listenersreadCurrent listener count, split by delivery path (icecast, hls, relay).
GET/v1/stations/{station}/listeners/livereadWho is connected right now: a hashed id, country, city, region, device, browser, OS, connected_at and seconds listening. Never an IP address.

Stats and analytics

MethodPathScopeWhat it does
GET/v1/stations/{station}/stats?range=1h|24h|7d|30dreadListeners over time as evenly bucketed points, with the peak and average for the range.
GET/v1/stations/{station}/analytics?start=YYYY-MM-DD&end=YYYY-MM-DDreadThe full analytics report for a date range: overview, geography, devices, hour-by-day heatmap, top tracks, session lengths.
GET/v1/stations/{station}/analytics/export.csv?start&endreadThe same report as CSV — daily rows, then a top-tracks section.

Tracks and uploads

Uploads go straight to storage in two steps: ask for an upload URL, PUT the file to it, then call upload-complete so the track is added to the library. Files up to 500 MB.

MethodPathScopeWhat it does
GET/v1/stations/{station}/tracks?page=1&per_page=100&q=readYour library, paginated, with an optional title/artist search.
GET/v1/stations/{station}/tracks/{trackId}readOne track: title, artist, album, duration_seconds, file_size, created_at.
POST/v1/stations/{station}/tracks/upload-urlwriteBody { filename, content_type, size_bytes } → a short-lived URL to PUT the file to.
POST/v1/stations/{station}/tracks/upload-completewriteTell the platform the PUT finished so the file is processed and added to the library.
DELETE/v1/stations/{station}/tracks/{trackId}writeRemove a track from the library.

Playlists

MethodPathScopeWhat it does
GET/v1/stations/{station}/playlistsreadEvery playlist with its track count and mode.
GET/v1/stations/{station}/playlists/{id}readOne playlist including its tracks in order.

Schedule

MethodPathScopeWhat it does
GET/v1/stations/{station}/schedulereadThe weekly grid — the same shape the public schedule feed returns, in the station’s timezone.

Control

MethodPathScopeWhat it does
POST/v1/stations/{station}/startwritePut the station on air. Same checks as the Start button in the dashboard.
POST/v1/stations/{station}/stopwriteTake the station off air.
POST/v1/stations/{station}/play-nowwriteBody { track_id } — play a library track next, ahead of the rotation.

Discovery

MethodPathScopeWhat it does
GET/v1no keyName, version and links to these docs and the OpenAPI document.
GET/v1/openapi.jsonno keyThe OpenAPI 3.1 description of everything above.

Webhooks

A webhook is an HTTPS URL of yours that we POST to when something happens on your station. Add one under Dashboard → Developers, choose the events, and scope it to one station or to every station you own. Up to 10 endpoints per account.

Events

EventSent when
track.startedA new track starts — from AutoDJ, a live encoder, or a manually set title.
station.onlineThe station engine starts running (the station goes on air).
station.offlineThe station engine stops.
live.startedA live source takes over from AutoDJ.
live.endedThe live source disconnects and AutoDJ resumes.
test.pingYou pressed “Send test event” in the dashboard.
listeners.thresholdListener count crosses a level you choose. (coming — not yet available)

Payload

Every delivery is one JSON object with the same envelope: an id (unique per event — the same event redelivered keeps its id), the type, created_at, the api_version, and the event’s data.

track.started
{
  "id": "4f1c1a2e-6f0e-4d0b-9c2a-8b5f3d2e1a77",
  "type": "track.started",
  "created_at": "2026-09-08T14:03:11.204Z",
  "api_version": "2026-09-08",
  "data": {
    "station": { "id": "moonlightfm", "slug": "moonlightfm", "name": "Moonlight FM" },
    "track": {
      "title": "Backseat Star",
      "artist": "The Hold Steady",
      "album": null,
      "isrc": null,
      "source": "autodj",
      "started_at": "2026-09-08T14:03:10.980Z"
    },
    "listeners": 42
  }
}
station.online
{
  "id": "0d9b7c44-2a1e-4c6b-b1f3-5e2a9c8d7f10",
  "type": "station.online",
  "created_at": "2026-09-08T06:00:02.118Z",
  "api_version": "2026-09-08",
  "data": {
    "station": { "id": "moonlightfm", "slug": "moonlightfm", "name": "Moonlight FM" },
    "at": "2026-09-08T06:00:02.118Z"
  }
}

station.offline carries the same station and at fields, plus a reason when one is known. live.started and live.ended carry station, at and, when known, dj.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentNobex-Webhooks/1.0
X-Nobex-EventThe event type, e.g. track.started
X-Nobex-DeliveryA unique id for this delivery attempt — use it to ignore duplicates
X-Nobex-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Verifying the signature

Each endpoint has a signing secret (whsec_…), shown once when you create the endpoint and replaceable with Rotate secret. The signature is HMAC-SHA256 over the string <t>.<raw body> where t is the unix timestamp from the header. Compare in constant time and reject anything older than five minutes.

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the exact bytes received — verify BEFORE JSON.parse.
export function verifyNobexSignature(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: keep the raw body available.
// app.post("/hooks/nobex", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyNobexSignature(process.env.NOBEX_WEBHOOK_SECRET, req.get("X-Nobex-Signature"), req.body.toString())) {
//     return res.status(400).end();
//   }
//   const event = JSON.parse(req.body.toString());
//   res.status(204).end(); // reply fast, then do the work
// });
Python
import hmac, hashlib, time

def verify_nobex_signature(secret: str, header: str, raw_body: bytes, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

# Flask
# @app.post("/hooks/nobex")
# def hook():
#     if not verify_nobex_signature(SECRET, request.headers["X-Nobex-Signature"], request.get_data()):
#         abort(400)
#     event = request.get_json()
#     return "", 204

Delivery, retries and auto-disable

  • We wait up to 10 seconds for a response; any 2xx counts as delivered. Reply first, process after.
  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours — then marked exhausted. You can retry any delivery by hand from the dashboard.
  • After 100 consecutive failures the endpoint is disabled (too_many_failures) and stops receiving events until you re-enable it.
  • Endpoints must be https:// on a public host; private and loopback addresses are refused when you save.
  • Delivery history — status, attempt, response code, the payload we sent and an excerpt of what you replied — is kept for 30 days under each endpoint.

Testing

Send test event on any endpoint posts a test.ping immediately and shows you the response code or error inline. It is signed like everything else, so it also proves your verification code.

Realtime (Server-Sent Events)

A public, keyless stream of what is happening on a station, for websites and status pages. It is plain text/event-stream, so a browser’s built-in EventSource — or any SSE client — is all you need.

Endpoint
GET https://streaming-api.nobexpartners.com/public/stations/{slug}/events
eventdata
now_playingThe public now-playing object — sent on connect and again whenever the track changes.
listeners{ "listeners": n } — sent on connect.
track.startedThe same envelope a webhook receives for this event.
station.online / station.offlineEnvelope, as for webhooks.
live.started / live.endedEnvelope, as for webhooks.
Browser
const es = new EventSource(
  "https://streaming-api.nobexpartners.com/public/stations/moonlightfm/events"
);

es.addEventListener("now_playing", (e) => {
  const np = JSON.parse(e.data);
  document.getElementById("track").textContent = np.now_playing
    ? np.now_playing.artist + " — " + np.now_playing.title
    : "Off air";
});

es.addEventListener("listeners", (e) => {
  document.getElementById("count").textContent = JSON.parse(e.data).listeners;
});

es.addEventListener("station.offline", () => {
  document.getElementById("track").textContent = "Off air";
});

// EventSource reconnects on its own after a drop and you get a fresh
// now_playing on every connect, so there is no state to rebuild.
  • On connect you get now_playing and listeners straight away, so there is nothing to fetch first.
  • A : keepalive comment goes out every 25 seconds; if you use a proxy, make sure it does not buffer the response.
  • EventSource reconnects on its own. If you write your own client, reconnect with a short backoff and treat the first now_playing after reconnect as the truth.
  • Unknown or private stations answer 404. Each API instance serves up to 200 concurrent streams and answers 503 too_many_connections beyond that — one connection per page is plenty.

Public read-only feeds (no key)

These answer without authentication and are safe to call from a browser. {slug} is the station’s public slug or id; {key} is the station key from your MP3 direct link. The now-playing guide walks through each with examples.

FeedURLWhat you get
Now playing (JSON)https://streaming-api.nobexpartners.com/public/stations/{slug}/now-playingOnline flag, listener count, current title/artist/album. Cached 10 s.
Recent history (JSON)https://streaming-api.nobexpartners.com/public/stations/{slug}/history?limit=20Last plays, newest first, up to 100. Cached 15 s.
Realtime events (SSE)https://streaming-api.nobexpartners.com/public/stations/{slug}/eventsPushes now_playing, listeners and station events as they happen.
Schedule (JSON)https://streaming-api.nobexpartners.com/public/stations/{slug}/scheduleThe weekly grid and the block on air now, in the station’s timezone. Public stations only.
currentsong (text)https://listen.stream.nobexpartners.com/station_{key}/currentsong“Artist - Title” as plain text, for directories.
7.html (status line)https://listen.stream.nobexpartners.com/station_{key}/7.htmlThe classic comma-separated status line older players read.
Podcast RSShttps://streaming-api.nobexpartners.com/public/stations/{slug}/feed.xmlRecorded shows as a podcast feed, for stations that publish recordings.

Embeds

For a player rather than data: Share & Embed in the dashboard generates a one-line <iframe> player for your station, with size, autoplay and metadata options. The free radio player builder does the same for any stream URL, with no account. Both are covered in Your public page, embed player and sharing.

FAQ

Is the API included in my plan?

Yes. The REST API, webhooks and the realtime feed are available on every plan. Create a key under Dashboard → Developers.

Do I need a key to read what my station is playing?

No. Now playing, recent history, the schedule and the realtime feed are public and read-only. A key is needed for account data, analytics, the library, and anything that changes the station.

What is the rate limit?

600 requests per minute per key on /v1. The X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers on every response tell you where you stand; a 429 with code rate_limited means wait until the reset.

How do I verify a webhook really came from Nobex?

Compute HMAC-SHA256 over the string "<timestamp>.<raw body>" with your endpoint’s signing secret and compare it, in constant time, with the v1 value in the X-Nobex-Signature header. Reject timestamps more than five minutes old. The signing secret is shown once when the endpoint is created and can be rotated at any time.

What happens when my webhook endpoint is down?

Each delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked exhausted. Every attempt is recorded under Dashboard → Developers, where you can retry any delivery by hand. After 100 consecutive failures the endpoint is disabled until you re-enable it.

Does it work with Zapier, Make, n8n or IFTTT?

Yes, through their generic webhook trigger — “Webhooks by Zapier”, Make’s Custom webhook, n8n’s Webhook node, IFTTT’s Webhooks service. Paste the URL they give you as the endpoint, pick the events, and each event arrives as a JSON body they can map from. There is no dedicated Nobex app in those catalogues.

Can I let someone else build against my station without sharing my login?

Create a read-only key for them. Keys are account-wide and can be revoked at any time from Dashboard → Developers without affecting anything else.

Something missing? Email support@nobexinc.com with what you are building.