Developers
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 URL | https://streaming-api.nobexpartners.com |
| Version header (on every response) | X-Nobex-Api-Version: 2026-09-08 |
| Rate limit | 600 requests / minute per key, reported in X-RateLimit-Limit, -Remaining and -Reset |
| Format | JSON in, JSON out; timestamps are ISO 8601 in UTC |
| Station identifier | station id or public slug — either works in {station} |
Errors
Every error, from every endpoint, has the same shape:
{
"error": {
"code": "not_found",
"message": "Station not found"
}
}| Status | code | Meaning |
|---|---|---|
| 400 | validation_error | A parameter or body field is missing or malformed. |
| 401 | unauthorized / invalid_api_key / revoked_api_key | No key, an unknown key, or a key you revoked. |
| 402 | plan_required | The station’s plan does not include this action. |
| 403 | forbidden / insufficient_scope | A read-only key called a write endpoint. |
| 404 | not_found | No station, track or playlist by that id that you own. |
| 429 | rate_limited | Over 600 requests in a minute. Wait for X-RateLimit-Reset. |
| 500 | internal_error | Something 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:
Authorization: Bearer nbx_live_YOUR_KEY # or X-API-Key: nbx_live_YOUR_KEY
- Scopes. A key has
read, orreadandwrite. Reads never change anything; every endpoint marked write in the reference needs the write scope, and a read-only key gets a403 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.
Keep keys server-side
Quick start
Replace nbx_live_YOUR_KEY with your key and moonlightfm with your station id or slug.
curl https://streaming-api.nobexpartners.com/v1/stations \ -H "Authorization: Bearer nbx_live_YOUR_KEY"
curl https://streaming-api.nobexpartners.com/v1/stations/moonlightfm/now-playing \ -H "X-API-Key: nbx_live_YOUR_KEY"
curl -X POST https://streaming-api.nobexpartners.com/v1/stations/moonlightfm/start \ -H "Authorization: Bearer nbx_live_YOUR_KEY"
Node.js
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
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
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/me | read | The account the key belongs to: id, email, name, created_at. |
| GET | /v1/stations | read | Every station you own. |
| GET | /v1/stations/{station} | read | One station: slug, name, genre, timezone, status, on_air, live, stream_urls (mp3, hls, public_page), logo_url, plan. |
Now playing and history
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/stations/{station}/now-playing | read | The current track, plus source (autodj, live or manual) when it can be derived. |
| POST | /v1/stations/{station}/now-playing | write | Set the title (and optional artist) shown to listeners — for live shows and external playout. |
| GET | /v1/stations/{station}/history?limit=50&before=<iso> | read | Recently played tracks, newest first, with the listener count at the time each started. |
Listeners
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/stations/{station}/listeners | read | Current listener count, split by delivery path (icecast, hls, relay). |
| GET | /v1/stations/{station}/listeners/live | read | Who 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
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/stations/{station}/stats?range=1h|24h|7d|30d | read | Listeners 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-DD | read | The 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&end | read | The 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.
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/stations/{station}/tracks?page=1&per_page=100&q= | read | Your library, paginated, with an optional title/artist search. |
| GET | /v1/stations/{station}/tracks/{trackId} | read | One track: title, artist, album, duration_seconds, file_size, created_at. |
| POST | /v1/stations/{station}/tracks/upload-url | write | Body { filename, content_type, size_bytes } → a short-lived URL to PUT the file to. |
| POST | /v1/stations/{station}/tracks/upload-complete | write | Tell the platform the PUT finished so the file is processed and added to the library. |
| DELETE | /v1/stations/{station}/tracks/{trackId} | write | Remove a track from the library. |
Playlists
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/stations/{station}/playlists | read | Every playlist with its track count and mode. |
| GET | /v1/stations/{station}/playlists/{id} | read | One playlist including its tracks in order. |
Schedule
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1/stations/{station}/schedule | read | The weekly grid — the same shape the public schedule feed returns, in the station’s timezone. |
Control
| Method | Path | Scope | What it does |
|---|---|---|---|
| POST | /v1/stations/{station}/start | write | Put the station on air. Same checks as the Start button in the dashboard. |
| POST | /v1/stations/{station}/stop | write | Take the station off air. |
| POST | /v1/stations/{station}/play-now | write | Body { track_id } — play a library track next, ahead of the rotation. |
Discovery
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /v1 | no key | Name, version and links to these docs and the OpenAPI document. |
| GET | /v1/openapi.json | no key | The 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
| Event | Sent when |
|---|---|
| track.started | A new track starts — from AutoDJ, a live encoder, or a manually set title. |
| station.online | The station engine starts running (the station goes on air). |
| station.offline | The station engine stops. |
| live.started | A live source takes over from AutoDJ. |
| live.ended | The live source disconnects and AutoDJ resumes. |
| test.ping | You pressed “Send test event” in the dashboard. |
| listeners.threshold | Listener 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.
{
"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
}
}{
"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
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | Nobex-Webhooks/1.0 |
| X-Nobex-Event | The event type, e.g. track.started |
| X-Nobex-Delivery | A unique id for this delivery attempt — use it to ignore duplicates |
| X-Nobex-Signature | t=<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.
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
// });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 "", 204Delivery, 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.
Zapier, Make, n8n, IFTTT
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.
GET https://streaming-api.nobexpartners.com/public/stations/{slug}/events| event | data |
|---|---|
| now_playing | The public now-playing object — sent on connect and again whenever the track changes. |
| listeners | { "listeners": n } — sent on connect. |
| track.started | The same envelope a webhook receives for this event. |
| station.online / station.offline | Envelope, as for webhooks. |
| live.started / live.ended | Envelope, as for webhooks. |
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_playingandlistenersstraight away, so there is nothing to fetch first. - A
: keepalivecomment goes out every 25 seconds; if you use a proxy, make sure it does not buffer the response. EventSourcereconnects on its own. If you write your own client, reconnect with a short backoff and treat the firstnow_playingafter reconnect as the truth.- Unknown or private stations answer
404. Each API instance serves up to 200 concurrent streams and answers503 too_many_connectionsbeyond 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.
| Feed | URL | What you get |
|---|---|---|
| Now playing (JSON) | https://streaming-api.nobexpartners.com/public/stations/{slug}/now-playing | Online flag, listener count, current title/artist/album. Cached 10 s. |
| Recent history (JSON) | https://streaming-api.nobexpartners.com/public/stations/{slug}/history?limit=20 | Last plays, newest first, up to 100. Cached 15 s. |
| Realtime events (SSE) | https://streaming-api.nobexpartners.com/public/stations/{slug}/events | Pushes now_playing, listeners and station events as they happen. |
| Schedule (JSON) | https://streaming-api.nobexpartners.com/public/stations/{slug}/schedule | The 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.html | The classic comma-separated status line older players read. |
| Podcast RSS | https://streaming-api.nobexpartners.com/public/stations/{slug}/feed.xml | Recorded 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.