Tuneping API v1
A JSON API for what internet radio stations are playing right now, and what they played. Base URL https://api.tuneping.com/v1. Metadata only: the service reads the public title line of each stream and never touches audio.
Public beta. The live endpoints (search, batch, station now-playing, station directory, health and stats) work without a key, limited per IP address. Keys for the Free tier and paid plans are issued by hand during the beta: write to hello@tuneping.com.
Quickstart
Who is playing Madonna right now?
curl "https://api.tuneping.com/v1/now-playing/search?q=madonna&limit=3"
Now playing on a station you know by id (ids are Radio Browser station UUIDs):
curl "https://api.tuneping.com/v1/stations/159824f0-5475-4263-bcc8-59a48a213896/now-playing"
What a station played in the last 24 hours (needs a key with history, Developer plan or above):
curl -H "Authorization: Bearer tp_live_…" \
"https://api.tuneping.com/v1/stations/159824f0-5475-4263-bcc8-59a48a213896/history"
Every response is JSON. Errors are application/problem+json (see Errors). Unknown query parameters are rejected with 400 rather than ignored, so typos surface immediately.
Authentication
Send your key as a bearer token, or in X-Api-Key:
Authorization: Bearer tp_live_a1b2c3…
X-Api-Key: tp_live_a1b2c3…
Keys start with tp_live_ or tp_test_ followed by 43 characters. A request without a key is treated as anonymous and limited by IP address. A malformed, unknown or revoked key answers 401 immediately, even on endpoints that would have worked anonymously. Endpoints marked key required below answer 401 unauthenticated without one.
Partner apps (currently Tapelark) send a signed entitlement token instead of a key: Authorization: Bearer TPE1-…. The token is verified offline against the partner's Ed25519 keys and resolves to the partner plan in the table below; the API never learns who the end user is. A malformed or unknown token answers 401 token_invalid, an expired one 401 token_expired (refresh it), a token for another audience 401 token_audience, and a lapsed or revoked entitlement 403 entitlement_revoked.
Rate limits and quotas
| Tier | Rate | Quota | Batch ids | History |
|---|---|---|---|---|
| Anonymous (per IP) | 2 req/s, burst 5 | 1,000 / day | 20 | none |
| Free | 2 req/s, burst 5 | 1,000 / day | 20 | none |
| Developer | 10 req/s, burst 20 | 100,000 / month | 100 | 7 days |
| Pro | 50 req/s, burst 100 | 1,000,000 / month | 100 | 12 months |
| Business | custom | custom | 100 | 12 months |
| Tapelark (entitlement token, per licence) | 5 req/s, burst 10 | 2,000 / day | 50 | 7 days |
The per-second limit is a token bucket. When it is empty you get 429 rate_limited with Retry-After. When the quota is spent you get 429 quota_exceeded with Retry-After set to the seconds until the window resets.
Authenticated responses that are not shared-cacheable carry the IETF rate-limit fields:
RateLimit-Policy: "burst";q=10;w=1, "monthly";q=100000;w=2592000
RateLimit: "burst";r=7;t=1
Shared-cacheable responses (search, batch, station now-playing, stations, stats, health series) carry no rate-limit headers, because the edge may serve the same body to another caller. Your remaining budget is always available at GET /v1/me.
Caching
Live responses are cacheable for 5 seconds (Cache-Control: public, max-age=5, s-maxage=5) and carry a weak ETag. Send If-None-Match to get 304 when nothing changed; a 304 does not count against your quota at the origin. The station directory caches for 60 seconds, a single station for 300. History is private, max-age=30. Health and key endpoints are no-store.
Every response carries X-Request-Id. Quote it when you write to support.
Errors
Errors follow RFC 9457 problem details with two extension members: code, a stable machine-readable string, and endpoint-specific extras such as errors[] on validation failures or required_tier on 403.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://tuneping.com/docs/errors/#validation_failed",
"title": "Validation failed",
"status": 422,
"detail": "One or more query parameters are invalid.",
"instance": "urn:tuneping:request:req_01a18837c239727fbacb40d6c488405a",
"code": "validation_failed",
"errors": [ { "field": "q", "code": "length", "message": "`q` is required and must be 2–120 characters." } ]
}
The full list of codes with their meaning is in the error catalogue. The ones you will meet first:
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | Unknown query parameter or malformed request |
| 400 | invalid_cursor | Cursor malformed, expired (24 h) or used with different filters |
| 401 | unauthenticated · invalid_api_key · key_revoked | Key missing, unknown or revoked |
| 403 | forbidden_tier | Endpoint or parameter needs a higher plan; required_tier says which |
| 404 | not_found · station_opted_out | Unknown id, or the station asked not to be indexed |
| 422 | validation_failed | Field errors in errors[] |
| 429 | rate_limited · quota_exceeded | Slow down; Retry-After in seconds |
| 503 | unavailable | Database unavailable; retry after Retry-After |
Live search GET /v1/now-playing/search
Which stations are playing q right now. Fuzzy and accent-insensitive: gronemeyer manner finds Grönemeyer – Männer. Rows are ordered by similarity, then by how recently the title appeared. No pagination: the index changes every few seconds, so pages would skip or repeat. Raise limit instead.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
q | string, 2–120 chars | required | Artist, title or both. Three or more characters use trigram matching; shorter queries are a substring match. |
country | ISO 3166-1 alpha-2, repeatable | any | Only stations from these countries. |
codec | string | any | e.g. MP3, AAC. |
min_bitrate | integer | 0 | kbit/s. |
classification | enum | song | song, ident, programme, ad_break, empty, junk, unknown or any. |
max_age_seconds | 30–900 | 180 | Only rows whose station was successfully polled within this many seconds. |
limit | 1–100 | 20 | Rows to return; truncated: true says there were more. |
{
"data": [
{
"station": { "id": "159824f0-5475-4263-bcc8-59a48a213896", "name": "Radio 10 80's Hits", "country": "NL",
"codec": "MP3", "bitrate": 128, "is_hls": false,
"stream_url": "http://…/TLPSTR20.mp3", "homepage": "http://www.radio10.nl/", "recordability": "recordable" },
"raw_title": "Madonna - Holiday",
"artist": "Madonna", "title": "Holiday",
"track": { "id": "trk_c6f", "mbid": "d4708b79-62e7-4ce4-a6ca-247237d4ef4e", "artist_id": "art_fn" },
"classification": "song", "match_grade": "high", "confidence": "high",
"started_at": "2026-10-29T15:08:44.883Z", "start_uncertainty_seconds": 32,
"observed_at": "2026-10-29T15:09:26.631Z", "confirmed_at": "2026-10-29T15:10:30.679Z",
"method": "icy", "poll_interval_seconds": 60, "similarity": 0.5
}
],
"truncated": false,
"query": { "q": "madonna", "normalized": "madonna", "max_age_seconds": 180 }
}
Reading a row: started_at is the estimated start of the current title with start_uncertainty_seconds either way; observed_at is when the title was first seen; confirmed_at is the station's latest successful poll, so "still playing as of". track is present when the title was linked to MusicBrainz. similarity (0–1) lets you cut at your own threshold.
Batch now-playing GET /v1/now-playing
Current title for a list of stations in one call, for favourites screens.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
station_ids | comma-separated UUIDs | required | Up to 20 anonymous or Free, 100 from Developer. Ids are sorted and de-duplicated before caching, so two clients with the same favourites share one cached body. |
max_age_seconds | 30–86400 | 900 | Rows older than this are still returned but flagged is_stale: true. |
{ "data": [ { …same row shape as search, without similarity, plus "is_stale": false… } ],
"missing": [ "ids that are unknown or opted out" ],
"max_age_seconds": 900 }
Station now-playing GET /v1/stations/{id}/now-playing
One row for one station. 404 not_found when no title has been observed yet, 404 station_opted_out when the station asked not to be indexed.
Stations GET /v1/stations · /v1/stations/{id}
The directory, built from the community-maintained Radio Browser directory and enriched with how Tuneping reads each station.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
q | string | — | Station name, fuzzy. |
country | alpha-2, repeatable | any | |
tag | string, repeatable | any | All given tags must match. |
codec, min_bitrate | As in search. | ||
has_now_playing | boolean | false | Only stations Tuneping currently reads titles from. |
sort | enum | clickcount | clickcount (popularity, descending), name, bitrate. |
limit | 1–200 | 50 | |
cursor | opaque string | — | From next_cursor of the previous page; valid 24 hours and only with the same filters. |
{ "data": [ {
"id": "159824f0-5475-4263-bcc8-59a48a213896", "name": "Radio 10 80's Hits", "country": "NL", "state": null,
"language": "dutch", "tags": ["80s", "hits"], "codec": "MP3", "bitrate": 128, "is_hls": false,
"stream_url": "http://…", "homepage": "http://www.radio10.nl/", "favicon_url": "https://…/favicon.png",
"clickcount": 1234, "recordability": "recordable",
"metadata": { "method": "icy", "poll_interval_seconds": 60, "lag_seconds": 9, "last_ok_at": "2026-10-29T15:10:30Z", "coverage": "live" },
"created_at": "2026-10-29T10:12:03Z", "updated_at": "2026-10-29T15:10:30Z" } ],
"next_cursor": "eyJ…" }
favicon_url is passed through as text from the directory; Tuneping never copies or serves logos.
Play history GET /v1/stations/{id}/history key required
Songs a station played, newest first, with estimated start and end. Depth depends on the plan: 7 days on Developer, 12 months on Pro and Business. Asking for an older from answers 403 forbidden_tier with required_tier, never a silently clamped window.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
from, to | RFC 3339 | to = now, from = to − 24 h | At most 31 days per call. |
classification | enum | song | As in search. |
limit | 1–200 | 50 | |
cursor | opaque string | — | The window is frozen into the cursor. |
{ "data": [ {
"id": "pe_…", "station": { …station ref… },
"raw_title": "Hecht - Charlotta", "artist": "Hecht", "title": "Charlotta",
"track": { "id": "trk_…", "mbid": "…", "artist_id": "art_…" },
"classification": "song", "match_grade": "high",
"started_at": "2026-10-29T10:31:12Z", "start_uncertainty_seconds": 10,
"ended_at": "2026-10-29T10:34:40Z", "duration_seconds": 208,
"method": "icecast_json", "confidence": "high" } ],
"next_cursor": null,
"window": { "from": "2026-10-28T15:00:00Z", "to": "2026-10-29T15:00:00Z" } }
Keys and usage GET /v1/me · /v1/me/usage key required
/v1/me returns the key, its owner's plan and status, the limits that apply and the usage so far in the current window. /v1/me/usage?from=&to= returns requests per day (origin requests only; edge cache hits are not counted).
{ "key": { "id": "key_…", "hint": "tp_live_a1b2c3…", "scopes": ["live", "history"], "livemode": true,
"created_at": "…", "last_used_at": "…" },
"owner": { "id": "own_…", "tier": "developer", "status": "active",
"current_period_start": "…", "current_period_end": "…" },
"limits": { "requests_per_second": 10, "burst": 20, "requests_per_day": null, "requests_per_month": 100000,
"history_days": 7, "batch_station_ids": 100, "webhooks": 0, "wishlists": 10,
"sse_connections": 1, "tightened_stations": 200 },
"usage": { "period_requests": 41230, "today_requests": 1810, "resets_at": "…" } }
Health and stats GET /v1/health · /v1/health/series · /v1/stats
No key needed. /v1/health answers 200 with status ok or degraded, 503 when the database is down; point your uptime monitor at it. /v1/health/series?minutes=60 (5–1440) returns per-minute poller figures: polls, ok, titles_changed, songs, bytes_in, bytes_out, latency_p50_ms, latency_p90_ms, inflight_max, ips_paused, queue_lag_seconds and a by_method breakdown. /v1/stats is the public coverage snapshot shown on the status page: stations total, polled, with live metadata, title changes per minute, songs share, by method, by country.
Fields and enums
classificationsong·ident(station name or slogan) ·programme·ad_break·empty·junk·unknown. Only songs become play history; everything is kept in the live row.match_gradehigh·medium·none. How well the title was linked to a MusicBrainz recording.confidencehigh·medium·low. Computed per row from classification, match grade, how recently the station was confirmed and the station's track record. Documented so you can audit it.method- How Tuneping reads the station:
icy(in-stream metadata),icecast_json(server status JSON),shoutcast_stats(status XML),shoutcast_currentsong,seven_html. Internal identifiers, not endorsements; display them as "in-stream" and "status page" if you show them. recordability- Whether the station's stream is a plain, recordable HTTP stream from the perspective of a recording client. Informational.
- Ids
- Stations use their Radio Browser UUID. Tracks are
trk_…, artistsart_…, play eventspe_…, keyskey_…, ownersown_…. Treat all of them as opaque strings. - Timestamps
- RFC 3339, UTC, millisecond precision where the source has it.
OpenAPI
The machine-readable description of this API is at /docs/openapi-v1.yaml (OpenAPI 3.1). It is the source for client generation; the operation ids are stable.
Attribution and fair use
Free-tier and anonymous use shows a visible "Data by Tuneping" link where the data is displayed (badge). Plays are detected from station metadata and are not certified monitoring; say "detected from station metadata" where you present them as evidence. Station names are text; Tuneping does not provide logos or artwork. See the terms.