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.
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.
Getting a key
Self-serve on tuneping.com/signup: enter an e-mail address, open the mailed link within 60 minutes, and the key is shown once. The same two steps are an API: POST /v1/signup with {"email": "…", "plan": "free|developer|pro"} answers 202 and sends the mail; POST /v1/signup/verify with {"token": "…"} from the link answers 201 with the key. Free needs nothing else. Developer and Pro continue to Stripe Checkout from the link in the response; the owner's tier changes within a minute of payment and every key of the owner follows.
Lost a key? Sign up again with the same address: the key is rotated, the old one keeps working for 24 hours. One live key on Free and Developer, five on Pro, twenty on Business; plus one tp_test_ key on every tier, issued on request until the portal ships.
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 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 |
GET/v1/now-playing/search no key public, max-age=5 · ETag
Live 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. |
Response · 200 · application/json
{
"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 RFC 3339 · estimate
- Estimated start of the current title, ±
start_uncertainty_secondseither way. - observed_at RFC 3339
- When Tuneping first saw this title.
- confirmed_at RFC 3339
- The station's latest successful poll: "still playing as of". Use it for freshness.
- track object · optional
- Present when the title was linked to a MusicBrainz recording.
- similarity 0–1
- Cut at your own threshold.
GET/v1/now-playing no key public, max-age=5 · ETag
Batch 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 }
GET/v1/stations/{id}/now-playing no key
Station 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.
GET/v1/stations GET/v1/stations/{id} no key max-age=60 · 300
Stations
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.
GET/v1/stations/{id}/history key · developer + private, max-age=30
Play history
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" } }
GET/v1/me GET/v1/me/usage key no-store
Keys and usage
/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": "…" } }
GET/v1/health GET/v1/health/series GET/v1/stats no key
Health and stats
/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
- classification enum
song·ident(station name or slogan) ·programme·ad_break·empty·junk·unknown. Only songs become play history; everything is kept in the live row.- match_grade enum
high·medium·none. How well the title was linked to a MusicBrainz recording.- confidence enum
high·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 enum
- 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 enum
- 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.