Download OpenAPI specification:
Music catalog discovery, playback assets, and royalty reporting for FitRadio integration partners.
The FitRadio Partner API gives approved partners access to FitRadio's licensed workout-music catalog. The content model is a simple hierarchy:
Catalog → Page → List → Mix
GET /partners/catalog returns the pages (music rows) of the partner catalog.GET /partners/page/{page_id} returns the lists on one page.GET /partners/list/{list_id} returns the mixes in one list.GET /partners/mix/{mix_id} returns one mix with its playable HLS/MP3 URLs, artwork, DJ, and full track listing.POST /partners/royalty-tracking reports every track play back to FitRadio — required for licensing compliance.All endpoints except the two POST /partners/session* endpoints require a session token.
client_id and client_signature out of band.POST /partners/session with them. The response contains a JWT session token.Authorization header.Important — send the raw token. The Authorization header value must be the JWT by itself, with no Bearer prefix and no other decoration:
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOi...
A value of Bearer eyJ... is rejected with 401.
There is no X-Client-Id header anywhere in this API. Older documentation that mentioned one was wrong — the session token is the only credential sent on API calls.
Tokens expire 8 hours after issue. Any 401 response means your token is missing, malformed, expired, or was minted during standby mode (see below) — create a new session and retry. Build automatic re-authentication on 401 into your client.
# 1. Create a session
curl -s -X POST https://p.fitradio.com/partners/session \
-H 'Content-Type: application/json' \
-d '{"client_id": "YOUR_CLIENT_ID", "client_signature": "YOUR_CLIENT_SIGNATURE"}'
# → {"session":{"token":{"token":"eyJhbGciOi...","error":null}}}
TOKEN='eyJhbGciOi...' # the inner session.token.token value, used raw
# 2. Discover content
curl -s https://p.fitradio.com/partners/catalog -H "Authorization: $TOKEN"
curl -s https://p.fitradio.com/partners/page/71 -H "Authorization: $TOKEN"
curl -s https://p.fitradio.com/partners/list/529 -H "Authorization: $TOKEN"
curl -s https://p.fitradio.com/partners/mix/9252 -H "Authorization: $TOKEN"
# 3. Report a play (required for every track you play)
curl -s -X POST https://p.fitradio.com/partners/royalty-tracking \
-H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{
"trackid": 704991,
"playedat": 1754380800000,
"playlength": 213,
"sourcestream": "mix",
"sourcedetail": "9252",
"os": "web",
"device": "chrome",
"country": "US",
"vendor": 12,
"userid": "partner-user-42",
"isrc": "USUM71703861",
"endreason": 1
}'
These rules exist because getting them wrong silently corrupts royalty reporting. Please read them carefully.
| Rule | Detail |
|---|---|
| IDs are JSON integers | Send "trackid": 704991 — never 704991.0, never "704991". Serializers that add a trailing .0 (e.g. Python floats, spreadsheet exports) break processing. |
playedat is epoch milliseconds |
A 13-digit Unix timestamp, e.g. 1754380800000. Sending seconds (10 digits) is not rejected but corrupts play-time reporting — always milliseconds. |
playlength is seconds |
Integer number of seconds the track actually played, e.g. 213. |
| Mix/track durations are strings | length and time fields are HH:MM:SS strings, e.g. "00:56:13". |
| ISRC | Every track in GET /partners/mix/{mix_id} includes its isrc (International Standard Recording Code). Echo the value back in royalty reports exactly as received — do not reformat, trim, or reconstruct it. |
| Country codes | ISO 3166-1 alpha-2, uppercase, e.g. US, GB, DE. |
Catalog, page, list and mix responses are not filtered by country: every partner sees the same catalog. The country field of a royalty report is required and must be the listener's country (ISO 3166-1 alpha-2); a report without it is rejected with 400. It is never inferred from the request's IP or headers.
Requesting a page or list id that has no content returns 400 with reason: "no results for given type".
When FitRadio's primary database is unavailable, the API automatically fails over to a read-only standby service so playback keeps working. What changes for you:
x-fitradio-served-by: standby (or live-bypass). Absence of the header means normal live service.POST /partners/session succeeds without verifying credentials and returns a short-lived token (~15 minutes) — and the response shape flattens: session.token is the JWT string directly instead of the live {token, error} object. Extract with typeof session.token === 'string' ? session.token : session.token.token.401 the moment normal service resumes. This is the main reason your client must re-authenticate automatically on any 401.min_bpm/max_bpm may be equal, description may be empty).POST /partners/royalty-tracking accepts and acknowledges reports; queue and re-send your reports after standby ends if you require delivery guarantees.Two error envelopes are in use:
{ "error": true, "reason": "<human-readable message>", "mix": null } (the legacy mix field is always null and can be ignored).POST /partners/royalty-tracking: { "error": true, "message": "<human-readable message>" }.401 responses from the authentication layer have a plain-text body (Unauthorized), not JSON.| Status | Meaning |
|---|---|
400 |
Missing/invalid parameter, or no content for the given id |
401 |
Missing, malformed, expired, or standby-minted token; invalid session credentials |
404 |
Mix not found |
500 |
Internal error — retry with backoff, then contact FitRadio |
503 |
Standby failover error — retry with backoff |
Exchange your partner credentials for a session token. Tokens live 8 hours; send them raw in the Authorization header (no Bearer prefix). Re-authenticate automatically whenever you receive a 401.
Exchanges your client_id + client_signature for an 8-hour JWT session token.
During normal service the token is at session.token.token (note the nesting — this differs from /partners/session/with-user, where session.token is the string itself). Send it raw in the Authorization header on all other endpoints.
During standby mode the shape differs: the endpoint returns a short-lived (~15 min) token without verifying credentials, and session.token is the JWT string directly (no inner object). Handle both: typeof session.token === 'string' ? session.token : session.token.token. Standby-minted tokens stop working (401) as soon as normal service resumes.
| client_id required | string Your partner client id, issued by FitRadio (10 characters). |
| client_signature required | string Your partner client signature, issued by FitRadio (40 characters). Treat it as a secret — server-side use only. |
{- "client_id": "a1b2c3d4e5",
- "client_signature": "f6e5d4c3b2a1098765432109876543210fedcba9"
}{- "session": {
- "token": {
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOiJhMWIyYzNkNGU1IiwidWlkIjoxMjUwOTQ3NiwiaWF0IjoxNzU0MzgwODAwLCJleHAiOjE3NTQ0MDk2MDB9.signature",
- "error": null
}
}
}Like POST /partners/session, but additionally requires the partner account email registered with FitRadio and binds the token to that partner identity. Use this variant if your agreement uses the restricted catalog (GET /partners/catalog/restricted) — the restricted endpoint filters by the partner identity carried in this token.
Note the response shape difference: here session.token is the JWT string directly, while /partners/session nests it one level deeper (session.token.token).
| client_id required | string Your partner client id, issued by FitRadio (10 characters). |
| client_signature required | string Your partner client signature, issued by FitRadio (40 characters). Treat it as a secret — server-side use only. |
| email required | string <email> The partner account email registered with FitRadio for this client id. |
{- "client_id": "a1b2c3d4e5",
- "client_signature": "f6e5d4c3b2a1098765432109876543210fedcba9",
- "email": "[email protected]"
}{- "session": {
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOiJhMWIyYzNkNGU1IiwidWlkIjo0MiwiaWF0IjoxNzU0MzgwODAwLCJleHAiOjE3NTQ0MDk2MDB9.signature"
}
}The entry point for content discovery: the pages (music rows) of the partner catalog. Use /partners/catalog/restricted if your partner agreement scopes you to a subset of the catalog.
Returns the pages (music rows) of the partner catalog — the top level of the content hierarchy. Use each entry's page_id with GET /partners/page/{page_id}.
{- "catalogs": [
- {
- "page_id": 71,
- "title": "Featured",
- "type": "genre",
- "ranking": 1,
- "list_count": 12
}, - {
- "page_id": 46,
- "title": "SWEATBASE",
- "type": "genre",
- "ranking": 2,
- "list_count": 8
}
]
}Same shape as GET /partners/catalog, additionally filtered by the content restrictions configured for your partner account.
The partner identity is read from the session token, so call this with a token from POST /partners/session/with-user. With a plain /partners/session token (no partner identity), the response falls back to the unrestricted country catalog.
| CF-IPCountry | string^[A-Za-z]{2}$ Example: US ISO 3166-1 alpha-2 country. On production Cloudflare sets it automatically from the caller's IP; server-to-server integrations may send it explicitly. Read only by this endpoint (the partner-restriction lookup is per country); values that are not exactly two letters are ignored. |
{- "catalogs": [
- {
- "page_id": 46,
- "title": "SWEATBASE",
- "type": "genre",
- "ranking": 1,
- "list_count": 8
}
]
}A page (music row) groups related lists — e.g. a genre or featured collection. Returns the lists on the page.
Returns the lists on one catalog page. Use each list's list_id with GET /partners/list/{list_id}.
| page_id required | integer Example: 71 Page id from |
{- "page": {
- "id": 71,
- "title": "Featured",
- "lists": [
- {
- "list_id": 529,
- "ranking": 1,
- "title": "Straight Outta Hollywood",
- "description": "Hip hop and Top 40 for lifting and HIIT workouts.",
- "mix_count": 14,
}
]
}
}A list is an ordered collection of mixes (a station or curated genre list). Returns list metadata plus lightweight mix summaries.
Returns one list's metadata plus lightweight summaries of its mixes. Use each mix's id with GET /partners/mix/{mix_id} to get playable URLs and the track listing.
| list_id required | integer Example: 529 List id from |
{- "list": {
- "id": 529,
- "ranking": 1,
- "title": "Straight Outta Hollywood",
- "description": "Hip hop and Top 40 for lifting and HIIT workouts.",
- "image": {
}, - "mixes": [
- {
- "id": 9252,
- "title": "Straight Outta Hollywood 3",
- "description": "General lifting mix of hip hop, top 40, and EDM.",
- "length": "00:56:13",
- "explicit": 0,
- "bpm": 80,
- "max_bpm": 150,
- "list_id": 529
}
]
}
}Returns the raw items of a single music row with the complete artwork set per item (wide, spotlight, player, thumbnail, basic). Useful for building richer browse UIs than the catalog/page endpoints allow. Not country-filtered.
| music_row_id required | integer Example: 46 Music row id (same id space as catalog |
{- "rows": {
- "items": [
- {
- "id": 529,
- "title": "Straight Outta Hollywood",
- "description": "Hip hop and Top 40 for lifting and HIIT workouts.",
- "bpm": 80,
- "ordering": 1
}
]
}
}A mix is a continuous DJ mix. The detail endpoint returns everything needed for playback and compliance: HLS stream URLs (iOS/Android renditions), MP3 URL, artwork, DJ info, and the full track listing with ISRCs.
Returns everything needed to play one mix and stay license-compliant:
android_hls_url / ios_hls_url — ready-to-play HLS manifests per platform. hls_url mirrors the Android rendition for backwards compatibility.url — the raw MP3.tracks — the full track listing with each track's isrc and start time, which you need for royalty reporting.This endpoint is not geo-gated.
During standby mode, all three HLS fields may point at the same rendition and some metadata may be degraded (equal min_bpm/max_bpm, empty description).
| mix_id required | integer Example: 9252 Mix id from |
{- "mix": {
- "id": 9252,
- "title": "Straight Outta Hollywood 3",
- "description": "General lifting mix of hip hop, top 40, and EDM.",
- "length": "00:56:13",
- "min_bpm": 80,
- "max_bpm": 150,
- "explicit": false,
- "dj": {
- "name": "Breis Gordan",
}, - "tracks": [
- {
- "id": 704991,
- "time": "00:00:00",
- "isrc": "USUM71703861",
- "artist": "Example Artist",
- "title": "Example Track"
}, - {
- "id": 704992,
- "time": "00:03:33",
- "isrc": "GBUM71603392",
- "artist": "Another Artist",
- "title": "Another Track"
}
], - "genres": [
- {
- "id": 2,
- "title": "Hip Hop",
- "description": "Hip hop and rap workout mixes."
}
], - "tags": [
- "lifting",
- "crossfit"
]
}
}Report every track play. Required for licensing compliance — integrations are not approved for launch until reporting is verified end-to-end by FitRadio. Read the Data types and formats section above before implementing.
Report every track play — one call per track, sent when the track finishes (or playback stops). Required for licensing compliance.
Read the Data types and formats section first. The most common integration mistakes, all of which corrupt reporting silently or fail the request:
playedat sent in seconds instead of milliseconds.0playlength, userid, or isrc omittedisrc should be echoed from the mix's track listing; if omitted, FitRadio falls back to looking it up from trackid, but sending it explicitly is strongly preferred. Send your real per-request values — do not hardcode example payloads from this page.
| trackid required | integer Track id from the mix's track listing ( |
| playedat required | integer <int64> When playback of the track started, as a Unix epoch timestamp in milliseconds (13 digits). Sending seconds corrupts reporting. |
| sourcestream required | string What kind of stream the play came from, e.g. |
| sourcedetail required | string Identifier of the stream source — for mix playback, the mix id as a string. |
| os required | string Operating system / platform of the player. |
| device required | string Device or client identifier. |
| country required | string ISO 3166-1 alpha-2 country of the listener. Required: a report without it is rejected with |
| vendor required | integer Your vendor id, assigned by FitRadio. Must be a JSON integer. |
| playlength | integer How many seconds of the track actually played. Strongly recommended — required for accurate reporting. |
| userid | string Stable identifier of the end listener in your system. Strongly recommended — required for per-listener compliance reporting. |
| uuid | string Unique id for this play event (deduplication). |
| isrc | string ISRC of the track, echoed from |
| endreason | integer Default: 1 Numeric code for why playback of the track ended. Defaults to |
| cachedplay | boolean Default: false Whether the play was served from a local cache rather than streamed. |
| jumpforward | boolean Default: false Whether the user seeked forward during the track. |
{- "trackid": 704991,
- "playedat": 1754380800000,
- "playlength": 213,
- "sourcestream": "mix",
- "sourcedetail": "9252",
- "os": "web",
- "device": "chrome",
- "country": "US",
- "vendor": 12,
- "userid": "partner-user-42",
- "uuid": "3f6a1c2e-8f4b-4b7e-9d2a-5c8e1f0a9b3d",
- "isrc": "USUM71703861",
- "endreason": 1,
- "cachedplay": false,
- "jumpforward": false
}{- "error": false,
- "message": "success"
}