{
  "openapi": "3.1.0",
  "info": {
    "title": "FitRadio Partner API",
    "version": "2.0.0",
    "summary": "Music catalog discovery, playback assets, and royalty reporting for FitRadio integration partners.",
    "description": "# Overview\n\nThe FitRadio Partner API gives approved partners access to FitRadio's licensed workout-music catalog. The content model is a simple hierarchy:\n\n**Catalog → Page → List → Mix**\n\n1. `GET /partners/catalog` returns the *pages* (music rows) of the partner catalog.\n2. `GET /partners/page/{page_id}` returns the *lists* on one page.\n3. `GET /partners/list/{list_id}` returns the *mixes* in one list.\n4. `GET /partners/mix/{mix_id}` returns one mix with its playable HLS/MP3 URLs, artwork, DJ, and full track listing.\n5. `POST /partners/royalty-tracking` reports every track play back to FitRadio — required for licensing compliance.\n\n# Base URL\n\nAll endpoints are served from `https://p.fitradio.com`.\n\n# Authentication\n\nAll endpoints except the two `POST /partners/session*` endpoints require a session token.\n\n1. FitRadio issues you a `client_id` and `client_signature` out of band.\n2. Call `POST /partners/session` with them. The response contains a JWT session token.\n3. Send that token on every subsequent request in the `Authorization` header.\n\n**Important — send the raw token.** The `Authorization` header value must be the JWT *by itself*, with **no `Bearer ` prefix** and no other decoration:\n\n```\nAuthorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOi...\n```\n\nA value of `Bearer eyJ...` is rejected with `401`.\n\nThere 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.\n\nTokens 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.\n\n# Quickstart\n\n```bash\n# 1. Create a session\ncurl -s -X POST https://p.fitradio.com/partners/session \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client_id\": \"YOUR_CLIENT_ID\", \"client_signature\": \"YOUR_CLIENT_SIGNATURE\"}'\n# → {\"session\":{\"token\":{\"token\":\"eyJhbGciOi...\",\"error\":null}}}\n\nTOKEN='eyJhbGciOi...'   # the inner session.token.token value, used raw\n\n# 2. Discover content\ncurl -s https://p.fitradio.com/partners/catalog        -H \"Authorization: $TOKEN\"\ncurl -s https://p.fitradio.com/partners/page/71        -H \"Authorization: $TOKEN\"\ncurl -s https://p.fitradio.com/partners/list/529       -H \"Authorization: $TOKEN\"\ncurl -s https://p.fitradio.com/partners/mix/9252       -H \"Authorization: $TOKEN\"\n\n# 3. Report a play (required for every track you play)\ncurl -s -X POST https://p.fitradio.com/partners/royalty-tracking \\\n  -H \"Authorization: $TOKEN\" -H 'Content-Type: application/json' \\\n  -d '{\n    \"trackid\": 704991,\n    \"playedat\": 1754380800000,\n    \"playlength\": 213,\n    \"sourcestream\": \"mix\",\n    \"sourcedetail\": \"9252\",\n    \"os\": \"web\",\n    \"device\": \"chrome\",\n    \"country\": \"US\",\n    \"vendor\": 12,\n    \"userid\": \"partner-user-42\",\n    \"isrc\": \"USUM71703861\",\n    \"endreason\": 1\n  }'\n```\n\n# Data types and formats\n\nThese rules exist because getting them wrong silently corrupts royalty reporting. Please read them carefully.\n\n| Rule | Detail |\n|---|---|\n| **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. |\n| **`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. |\n| **`playlength` is seconds** | Integer number of seconds the track actually played, e.g. `213`. |\n| **Mix/track durations are strings** | `length` and `time` fields are `HH:MM:SS` strings, e.g. `\"00:56:13\"`. |\n| **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. |\n| **Country codes** | ISO 3166-1 alpha-2, uppercase, e.g. `US`, `GB`, `DE`. |\n\n# Country\n\nCatalog, 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.\n\nRequesting a page or list id that has no content returns `400` with `reason: \"no results for given type\"`.\n\n# Standby mode\n\nWhen 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:\n\n* Responses include the header `x-fitradio-served-by: standby` (or `live-bypass`). Absence of the header means normal live service.\n* `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`.\n* Standby-minted tokens are **rejected with `401` the moment normal service resumes.** This is the main reason your client must re-authenticate automatically on any `401`.\n* Some catalog metadata is served from cache and may be degraded (e.g. a mix's `min_bpm`/`max_bpm` may be equal, `description` may be empty).\n* `POST /partners/royalty-tracking` accepts and acknowledges reports; queue and re-send your reports after standby ends if you require delivery guarantees.\n\n# Errors\n\nTwo error envelopes are in use:\n\n* Most endpoints: `{ \"error\": true, \"reason\": \"<human-readable message>\", \"mix\": null }` (the legacy `mix` field is always `null` and can be ignored).\n* `POST /partners/royalty-tracking`: `{ \"error\": true, \"message\": \"<human-readable message>\" }`.\n* `401` responses from the authentication layer have a plain-text body (`Unauthorized`), not JSON.\n\n| Status | Meaning |\n|---|---|\n| `400` | Missing/invalid parameter, or no content for the given id |\n| `401` | Missing, malformed, expired, or standby-minted token; invalid session credentials |\n| `404` | Mix not found |\n| `500` | Internal error — retry with backoff, then contact FitRadio |\n| `503` | Standby failover error — retry with backoff |\n\n# Downloads\n\n* [Raw OpenAPI spec](./openapi.json)\n* [Postman collection](./fitradio-partner-api.postman_collection.json)\n",
    "termsOfService": "https://www.fitradio.com/tos",
    "contact": {
      "name": "FitRadio Partner Support",
      "url": "https://www.fitradio.com"
    }
  },
  "servers": [
    {
      "url": "https://p.fitradio.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "PartnerToken": []
    }
  ],
  "tags": [
    {
      "name": "authentication",
      "x-displayName": "Authentication",
      "description": "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`."
    },
    {
      "name": "catalog",
      "x-displayName": "Catalog",
      "description": "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."
    },
    {
      "name": "page",
      "x-displayName": "Pages",
      "description": "A page (music row) groups related lists — e.g. a genre or featured collection. Returns the lists on the page."
    },
    {
      "name": "list",
      "x-displayName": "Lists",
      "description": "A list is an ordered collection of mixes (a station or curated genre list). Returns list metadata plus lightweight mix summaries."
    },
    {
      "name": "genres",
      "x-displayName": "Genre rows",
      "description": "Raw items of a single music row, with the full artwork set per item."
    },
    {
      "name": "mix",
      "x-displayName": "Mixes",
      "description": "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."
    },
    {
      "name": "royalty",
      "x-displayName": "Royalty tracking",
      "description": "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."
    }
  ],
  "x-tagGroups": [
    {
      "name": "Getting Started",
      "tags": [
        "authentication"
      ]
    },
    {
      "name": "Content Discovery",
      "tags": [
        "catalog",
        "page",
        "list",
        "genres"
      ]
    },
    {
      "name": "Playback",
      "tags": [
        "mix"
      ]
    },
    {
      "name": "Reporting",
      "tags": [
        "royalty"
      ]
    }
  ],
  "paths": {
    "/partners/session": {
      "post": {
        "operationId": "createSession",
        "tags": [
          "authentication"
        ],
        "summary": "Create a session token",
        "description": "Exchanges your `client_id` + `client_signature` for an 8-hour JWT session token.\n\nDuring 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.\n\n**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.",
        "security": [
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SessionRequest"
              },
              "example": {
                "client_id": "a1b2c3d4e5",
                "client_signature": "f6e5d4c3b2a1098765432109876543210fedcba9"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session created. `session.token` is an object during normal service and a plain JWT string during standby mode (see examples).",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionResponse"
                },
                "examples": {
                  "live": {
                    "summary": "Normal service — token nested at session.token.token",
                    "value": {
                      "session": {
                        "token": {
                          "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOiJhMWIyYzNkNGU1IiwidWlkIjoxMjUwOTQ3NiwiaWF0IjoxNzU0MzgwODAwLCJleHAiOjE3NTQ0MDk2MDB9.signature",
                          "error": null
                        }
                      }
                    }
                  },
                  "standby": {
                    "summary": "Standby mode — session.token is the JWT string itself",
                    "value": {
                      "session": {
                        "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOiJhMWIyYzNkNGU1IiwidWlkIjoxMjUwOTQ3Niwic3RkIjp0cnVlLCJpYXQiOjE3NTQzODA4MDAsImV4cCI6MTc1NDM4MTcwMH0.signature"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Credentials invalid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "credentials invalid",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "description": "`client_id` or `client_signature` missing from the request body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "please provide user information to create session: email, salt",
                  "mix": null
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/partners/session/with-user": {
      "post": {
        "operationId": "createSessionWithUser",
        "tags": [
          "authentication"
        ],
        "summary": "Create a session token bound to a partner account",
        "description": "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.\n\n**Note the response shape difference:** here `session.token` is the JWT string directly, while `/partners/session` nests it one level deeper (`session.token.token`).",
        "security": [
          {}
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SessionWithUserRequest"
              },
              "example": {
                "client_id": "a1b2c3d4e5",
                "client_signature": "f6e5d4c3b2a1098765432109876543210fedcba9",
                "email": "integration@yourcompany.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session created.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionWithUserResponse"
                },
                "example": {
                  "session": {
                    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOiJhMWIyYzNkNGU1IiwidWlkIjo0MiwiaWF0IjoxNzU0MzgwODAwLCJleHAiOjE3NTQ0MDk2MDB9.signature"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Credentials invalid, partner not found for the given email, or session creation failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "credentials invalid",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "description": "`client_id` or `client_signature` missing from the request body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "please provide user information to create session: email, client_id, client_signature",
                  "mix": null
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/partners/catalog": {
      "get": {
        "operationId": "getCatalog",
        "tags": [
          "catalog"
        ],
        "summary": "List catalog pages",
        "description": "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}`.",
        "responses": {
          "200": {
            "description": "Catalog pages.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CatalogResponse"
                },
                "example": {
                  "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
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "No catalog available.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "no catalogs available",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        }
      }
    },
    "/partners/catalog/restricted": {
      "get": {
        "operationId": "getRestrictedCatalog",
        "tags": [
          "catalog"
        ],
        "summary": "List catalog pages allowed for your partner account",
        "description": "Same shape as `GET /partners/catalog`, additionally filtered by the content restrictions configured for your partner account.\n\nThe 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.",
        "responses": {
          "200": {
            "description": "Catalog pages allowed for this partner.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CatalogResponse"
                },
                "example": {
                  "catalogs": [
                    {
                      "page_id": 46,
                      "title": "SWEATBASE",
                      "type": "genre",
                      "ranking": 1,
                      "list_count": 8
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "No catalog available for this partner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "no catalogs available for this partner",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/CfIpCountry"
          }
        ]
      }
    },
    "/partners/page/{page_id}": {
      "get": {
        "operationId": "getPage",
        "tags": [
          "page"
        ],
        "summary": "Get the lists on a page",
        "description": "Returns the lists on one catalog page. Use each list's `list_id` with `GET /partners/list/{list_id}`.",
        "parameters": [
          {
            "name": "page_id",
            "in": "path",
            "required": true,
            "description": "Page id from `GET /partners/catalog` (`page_id`).",
            "schema": {
              "type": "integer"
            },
            "example": 71
          }
        ],
        "responses": {
          "200": {
            "description": "The page with its lists.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PageResponse"
                },
                "example": {
                  "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,
                        "image": {
                          "thumbnail": "https://cdn.fitradio.com/resources/artwork/android_xxhdpi/0362102862_thumb.jpg",
                          "large": "https://cdn.fitradio.com/resources/artwork/0857061661_large.jpg",
                          "player": "https://cdn.fitradio.com/resources/artwork/1768199292_player.jpg"
                        }
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing/unknown page id, or the page has no content.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "no results for given type",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        }
      }
    },
    "/partners/list/{list_id}": {
      "get": {
        "operationId": "getList",
        "tags": [
          "list"
        ],
        "summary": "Get the mixes in a list",
        "description": "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.",
        "parameters": [
          {
            "name": "list_id",
            "in": "path",
            "required": true,
            "description": "List id from `GET /partners/page/{page_id}` (`list_id`).",
            "schema": {
              "type": "integer"
            },
            "example": 529
          }
        ],
        "responses": {
          "200": {
            "description": "The list with its mixes.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListResponse"
                },
                "example": {
                  "list": {
                    "id": 529,
                    "ranking": 1,
                    "title": "Straight Outta Hollywood",
                    "description": "Hip hop and Top 40 for lifting and HIIT workouts.",
                    "image": {
                      "thumbnail": "https://cdn.fitradio.com/resources/artwork/android_xxhdpi/0362102862_thumb.jpg",
                      "large": "https://cdn.fitradio.com/resources/artwork/0857061661_large.jpg",
                      "player": "https://cdn.fitradio.com/resources/artwork/1768199292_player.jpg"
                    },
                    "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
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing/unknown list id, or the list has no content.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "no results for given type",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        }
      }
    },
    "/partners/genres/{music_row_id}": {
      "get": {
        "operationId": "getGenreRowItems",
        "tags": [
          "genres"
        ],
        "summary": "Get the items of a music row with full artwork",
        "description": "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.",
        "parameters": [
          {
            "name": "music_row_id",
            "in": "path",
            "required": true,
            "description": "Music row id (same id space as catalog `page_id`).",
            "schema": {
              "type": "integer"
            },
            "example": 46
          }
        ],
        "responses": {
          "200": {
            "description": "Items of the music row. `items` is `null` when the row exists but has no items.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GenreRowsResponse"
                },
                "example": {
                  "rows": {
                    "items": [
                      {
                        "id": 529,
                        "title": "Straight Outta Hollywood",
                        "description": "Hip hop and Top 40 for lifting and HIIT workouts.",
                        "bpm": 80,
                        "image": "https://cdn.fitradio.com/resources/artwork/0857061661_large.jpg",
                        "image_wide_url": "https://cdn.fitradio.com/resources/artwork/0857061661_wide.jpg",
                        "player_image": "https://cdn.fitradio.com/resources/artwork/1768199292_player.jpg",
                        "thumbnail": "https://cdn.fitradio.com/resources/artwork/0362102862_thumb.jpg",
                        "basic_image": "https://cdn.fitradio.com/resources/artwork/0857061661_basic.jpg",
                        "wide_image": "https://cdn.fitradio.com/resources/artwork/0857061661_wide.jpg",
                        "spotlight_image": "https://cdn.fitradio.com/resources/artwork/0857061661_spot.jpg",
                        "ordering": 1
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "description": "Music row id missing from the URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "please provide a music row id as a URL parameter",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        }
      }
    },
    "/partners/mix/{mix_id}": {
      "get": {
        "operationId": "getMix",
        "tags": [
          "mix"
        ],
        "summary": "Get a mix with playback URLs and track listing",
        "description": "Returns everything needed to play one mix and stay license-compliant:\n\n* `android_hls_url` / `ios_hls_url` — ready-to-play HLS manifests per platform. `hls_url` mirrors the Android rendition for backwards compatibility.\n* `url` — the raw MP3.\n* `tracks` — the full track listing with each track's `isrc` and start `time`, which you need for royalty reporting.\n\nThis endpoint is **not** geo-gated.\n\nDuring 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`).",
        "parameters": [
          {
            "name": "mix_id",
            "in": "path",
            "required": true,
            "description": "Mix id from `GET /partners/list/{list_id}` (`mixes[].id`).",
            "schema": {
              "type": "integer"
            },
            "example": 9252
          }
        ],
        "responses": {
          "200": {
            "description": "The mix.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MixResponse"
                },
                "example": {
                  "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,
                    "url": "https://cdn.fitradio.com/resources/mixes/1703282564_straight_outta_hollywood_3.mp3",
                    "android_hls_url": "https://cdn.fitradio.com/hls_android/mix_9252_android.m3u8",
                    "ios_hls_url": "https://cdn.fitradio.com/hls_ios/9252/mix_9252.m3u8",
                    "hls_url": "https://cdn.fitradio.com/hls_android/mix_9252_android.m3u8",
                    "image_large": "https://cdn.fitradio.com/resources/artwork/0857061661_art.jpg",
                    "image_thumbnail": "https://cdn.fitradio.com/resources/artwork/ios_retina/0857061661_art.jpg",
                    "dj": {
                      "name": "Breis Gordan",
                      "image_large": "https://cdn.fitradio.com/resources/artwork/1768199292_dj.jpg",
                      "image_thumbnail": "https://cdn.fitradio.com/resources/artwork/ios_retina/1768199292_dj.jpg"
                    },
                    "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"
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "description": "Mix id missing from the URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "Please provide a mix ID as a URL parameter",
                  "mix": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No mix with this id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": true,
                  "reason": "Mix not found",
                  "mix": null
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        }
      }
    },
    "/partners/royalty-tracking": {
      "post": {
        "operationId": "reportPlay",
        "tags": [
          "royalty"
        ],
        "summary": "Report a track play",
        "description": "Report every track play — one call per track, sent when the track finishes (or playback stops). Required for licensing compliance.\n\n**Read the *Data types and formats* section first.** The most common integration mistakes, all of which corrupt reporting silently or fail the request:\n\n* `playedat` sent in seconds instead of **milliseconds**\n* numeric fields serialized with a trailing `.0`\n* `playlength`, `userid`, or `isrc` omitted\n\n`isrc` 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.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RoyaltyPlay"
              },
              "example": {
                "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
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Play accepted.",
            "headers": {
              "x-fitradio-served-by": {
                "$ref": "#/components/headers/XFitradioServedBy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RoyaltyAccepted"
                },
                "example": {
                  "error": false,
                  "message": "success"
                }
              }
            }
          },
          "400": {
            "description": "One or more required fields are missing or invalid. `message` lists the offending fields.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RoyaltyError"
                },
                "example": {
                  "error": true,
                  "message": "include the required fields: playedat,country"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "description": "Internal error. Queue the report and retry with backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RoyaltyError"
                },
                "example": {
                  "error": true,
                  "message": "internal service error"
                }
              }
            }
          },
          "503": {
            "$ref": "#/components/responses/StandbyError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "PartnerToken": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "The JWT returned by `POST /partners/session`, sent **raw** — the header value is the token by itself, with no `Bearer ` prefix. Tokens expire after 8 hours; re-authenticate on any `401`."
      }
    },
    "parameters": {
      "CfIpCountry": {
        "name": "CF-IPCountry",
        "in": "header",
        "required": false,
        "description": "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.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z]{2}$"
        },
        "example": "US"
      }
    },
    "headers": {
      "XFitradioServedBy": {
        "description": "Present only when the request was served by the standby failover (`standby`) or explicitly bypassed it (`live-bypass`). Absent during normal live service. See the *Standby mode* section.",
        "schema": {
          "type": "string",
          "enum": [
            "standby",
            "live-bypass"
          ]
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Session token missing, malformed, expired, or minted during standby. The body is plain text, not JSON. Create a new session via `POST /partners/session` and retry.",
        "content": {
          "text/plain": {
            "schema": {
              "type": "string"
            },
            "example": "Unauthorized"
          }
        }
      },
      "InternalError": {
        "description": "Internal error. Retry with backoff; contact FitRadio if it persists.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": true,
              "reason": "internal service error",
              "mix": null
            }
          }
        }
      },
      "StandbyError": {
        "description": "The standby failover failed to serve the request. Retry with backoff.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "type": "boolean",
                  "const": true
                },
                "reason": {
                  "type": "string"
                }
              }
            },
            "example": {
              "error": true,
              "reason": "standby upstream error"
            }
          }
        }
      }
    },
    "schemas": {
      "SessionRequest": {
        "type": "object",
        "required": [
          "client_id",
          "client_signature"
        ],
        "properties": {
          "client_id": {
            "type": "string",
            "description": "Your partner client id, issued by FitRadio (10 characters).",
            "example": "a1b2c3d4e5"
          },
          "client_signature": {
            "type": "string",
            "description": "Your partner client signature, issued by FitRadio (40 characters). Treat it as a secret — server-side use only.",
            "example": "f6e5d4c3b2a1098765432109876543210fedcba9"
          }
        }
      },
      "SessionWithUserRequest": {
        "type": "object",
        "required": [
          "client_id",
          "client_signature",
          "email"
        ],
        "properties": {
          "client_id": {
            "type": "string",
            "description": "Your partner client id, issued by FitRadio (10 characters).",
            "example": "a1b2c3d4e5"
          },
          "client_signature": {
            "type": "string",
            "description": "Your partner client signature, issued by FitRadio (40 characters). Treat it as a secret — server-side use only.",
            "example": "f6e5d4c3b2a1098765432109876543210fedcba9"
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "The partner account email registered with FitRadio for this client id.",
            "example": "integration@yourcompany.com"
          }
        }
      },
      "SessionResponse": {
        "type": "object",
        "description": "Response of `POST /partners/session`. **`session.token` has two shapes** — an object during normal service, a plain JWT string during standby mode — so extract the token with a type check: `typeof session.token === 'string' ? session.token : session.token.token`.",
        "properties": {
          "session": {
            "type": "object",
            "properties": {
              "token": {
                "oneOf": [
                  {
                    "title": "Live session token",
                    "type": "object",
                    "description": "Normal service: the JWT is at `token.token`.",
                    "properties": {
                      "token": {
                        "type": "string",
                        "description": "The JWT session token. Send raw in the `Authorization` header."
                      },
                      "error": {
                        "type": [
                          "string",
                          "null"
                        ],
                        "description": "Always `null` on success."
                      }
                    }
                  },
                  {
                    "title": "Standby session token",
                    "type": "string",
                    "description": "Standby mode (response carries `x-fitradio-served-by: standby`): the JWT string itself, short-lived (~15 min) and rejected with `401` once normal service resumes."
                  }
                ]
              }
            }
          }
        }
      },
      "SessionWithUserResponse": {
        "type": "object",
        "description": "Response of `POST /partners/session/with-user`. Here `session.token` is the JWT string directly (unlike `/partners/session`).",
        "properties": {
          "session": {
            "type": "object",
            "properties": {
              "token": {
                "type": "string",
                "description": "The JWT session token. Send raw in the `Authorization` header."
              }
            }
          }
        }
      },
      "CatalogEntry": {
        "type": "object",
        "properties": {
          "page_id": {
            "type": "integer",
            "description": "Use with `GET /partners/page/{page_id}`.",
            "example": 71
          },
          "title": {
            "type": "string",
            "example": "Featured"
          },
          "type": {
            "type": "string",
            "description": "Row type classifier.",
            "example": "genre"
          },
          "ranking": {
            "type": "integer",
            "description": "Display order, ascending.",
            "example": 1
          },
          "list_count": {
            "type": "integer",
            "description": "Number of lists on the page.",
            "example": 12
          }
        }
      },
      "CatalogResponse": {
        "type": "object",
        "properties": {
          "catalogs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CatalogEntry"
            }
          }
        }
      },
      "ImageSet": {
        "type": "object",
        "description": "Artwork renditions for a list.",
        "properties": {
          "thumbnail": {
            "type": "string",
            "format": "uri",
            "description": "Small artwork (grid/list cells)."
          },
          "large": {
            "type": "string",
            "format": "uri",
            "description": "Full-size artwork."
          },
          "player": {
            "type": "string",
            "format": "uri",
            "description": "Player-screen artwork."
          }
        }
      },
      "PageList": {
        "type": "object",
        "properties": {
          "list_id": {
            "type": "integer",
            "description": "Use with `GET /partners/list/{list_id}`.",
            "example": 529
          },
          "ranking": {
            "type": "integer",
            "description": "Display order within the page, ascending.",
            "example": 1
          },
          "title": {
            "type": "string",
            "example": "Straight Outta Hollywood"
          },
          "description": {
            "type": "string"
          },
          "mix_count": {
            "type": "integer",
            "description": "Number of mixes in the list.",
            "example": 14
          },
          "image": {
            "$ref": "#/components/schemas/ImageSet"
          }
        }
      },
      "PageResponse": {
        "type": "object",
        "properties": {
          "page": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 71
              },
              "title": {
                "type": "string",
                "example": "Featured"
              },
              "lists": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PageList"
                }
              }
            }
          }
        }
      },
      "ListMixSummary": {
        "type": "object",
        "description": "Lightweight mix summary as returned inside a list. Fetch `GET /partners/mix/{mix_id}` for playback URLs and tracks.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Mix id — use with `GET /partners/mix/{mix_id}`.",
            "example": 9252
          },
          "title": {
            "type": "string",
            "example": "Straight Outta Hollywood 3"
          },
          "description": {
            "type": "string"
          },
          "length": {
            "type": "string",
            "description": "Mix duration, `HH:MM:SS`.",
            "example": "00:56:13"
          },
          "explicit": {
            "type": "integer",
            "description": "`1` if the mix contains explicit lyrics, else `0`.",
            "enum": [
              0,
              1
            ],
            "example": 0
          },
          "bpm": {
            "type": "integer",
            "description": "Representative BPM.",
            "example": 80
          },
          "max_bpm": {
            "type": "integer",
            "example": 150
          },
          "list_id": {
            "type": "integer",
            "description": "Id of the containing list.",
            "example": 529
          }
        }
      },
      "ListResponse": {
        "type": "object",
        "properties": {
          "list": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer",
                "example": 529
              },
              "ranking": {
                "type": "integer"
              },
              "title": {
                "type": "string",
                "example": "Straight Outta Hollywood"
              },
              "description": {
                "type": "string"
              },
              "image": {
                "$ref": "#/components/schemas/ImageSet"
              },
              "mixes": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ListMixSummary"
                }
              }
            }
          }
        }
      },
      "GenreRowItem": {
        "type": "object",
        "description": "One item of a music row with its full artwork set. During standby mode, only `id`, `title`, `description`, `bpm`, `image`, `thumbnail`, `player_image`, and `ordering` are guaranteed present.",
        "properties": {
          "id": {
            "type": "integer",
            "example": 529
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "bpm": {
            "type": "integer",
            "example": 80
          },
          "image": {
            "type": "string",
            "format": "uri"
          },
          "image_wide_url": {
            "type": "string",
            "format": "uri"
          },
          "player_image": {
            "type": "string",
            "format": "uri"
          },
          "thumbnail": {
            "type": "string",
            "format": "uri"
          },
          "basic_image": {
            "type": "string",
            "format": "uri"
          },
          "wide_image": {
            "type": "string",
            "format": "uri"
          },
          "spotlight_image": {
            "type": "string",
            "format": "uri"
          },
          "ordering": {
            "type": "integer",
            "description": "Display order, ascending.",
            "example": 1
          }
        }
      },
      "GenreRowsResponse": {
        "type": "object",
        "properties": {
          "rows": {
            "type": "object",
            "properties": {
              "items": {
                "type": [
                  "array",
                  "null"
                ],
                "description": "`null` when the row has no items.",
                "items": {
                  "$ref": "#/components/schemas/GenreRowItem"
                }
              }
            }
          }
        }
      },
      "Dj": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "Breis Gordan"
          },
          "image_large": {
            "type": "string",
            "format": "uri"
          },
          "image_thumbnail": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "Track": {
        "type": "object",
        "description": "One track inside a mix. Echo `id` (as `trackid`) and `isrc` in royalty reports.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Track id — send as `trackid` in royalty reports.",
            "example": 704991
          },
          "time": {
            "type": "string",
            "description": "Start time of the track within the mix, `HH:MM:SS`.",
            "example": "00:03:33"
          },
          "isrc": {
            "type": "string",
            "description": "International Standard Recording Code — echo in royalty reports.",
            "example": "USUM71703861"
          },
          "artist": {
            "type": "string",
            "example": "Example Artist"
          },
          "title": {
            "type": "string",
            "example": "Example Track"
          }
        }
      },
      "MixGenre": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 2
          },
          "title": {
            "type": "string",
            "example": "Hip Hop"
          },
          "description": {
            "type": "string"
          }
        }
      },
      "Mix": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "example": 9252
          },
          "title": {
            "type": "string",
            "example": "Straight Outta Hollywood 3"
          },
          "description": {
            "type": "string",
            "description": "May be empty during standby mode."
          },
          "length": {
            "type": "string",
            "description": "Mix duration, `HH:MM:SS`.",
            "example": "00:56:13"
          },
          "min_bpm": {
            "type": "integer",
            "example": 80
          },
          "max_bpm": {
            "type": "integer",
            "description": "May equal `min_bpm` during standby mode.",
            "example": 150
          },
          "explicit": {
            "type": "boolean",
            "description": "Whether the mix contains explicit lyrics."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Raw MP3 URL."
          },
          "android_hls_url": {
            "type": "string",
            "format": "uri",
            "description": "Ready-to-play HLS manifest, Android rendition."
          },
          "ios_hls_url": {
            "type": "string",
            "format": "uri",
            "description": "Ready-to-play HLS manifest, iOS rendition."
          },
          "hls_url": {
            "type": "string",
            "format": "uri",
            "description": "Legacy alias of `android_hls_url`."
          },
          "image_large": {
            "type": "string",
            "format": "uri"
          },
          "image_thumbnail": {
            "type": "string",
            "format": "uri"
          },
          "dj": {
            "$ref": "#/components/schemas/Dj"
          },
          "tracks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Track"
            }
          },
          "genres": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MixGenre"
            }
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "lifting",
              "crossfit"
            ]
          }
        }
      },
      "MixResponse": {
        "type": "object",
        "properties": {
          "mix": {
            "$ref": "#/components/schemas/Mix"
          }
        }
      },
      "RoyaltyPlay": {
        "type": "object",
        "description": "One track play. See *Data types and formats* in the introduction — `playedat` is epoch **milliseconds**, all ids are JSON integers (no trailing `.0`).",
        "required": [
          "trackid",
          "playedat",
          "sourcedetail",
          "sourcestream",
          "os",
          "device",
          "country",
          "vendor"
        ],
        "properties": {
          "trackid": {
            "type": "integer",
            "description": "Track id from the mix's track listing (`tracks[].id`). Must be a JSON integer — `704991`, never `704991.0` or a string.",
            "example": 704991
          },
          "playedat": {
            "type": "integer",
            "format": "int64",
            "description": "When playback of the track started, as a Unix epoch timestamp in **milliseconds** (13 digits). Sending seconds corrupts reporting.",
            "example": 1754380800000
          },
          "sourcestream": {
            "type": "string",
            "description": "What kind of stream the play came from, e.g. `mix`.",
            "example": "mix"
          },
          "sourcedetail": {
            "type": "string",
            "description": "Identifier of the stream source — for mix playback, the mix id as a string.",
            "example": "9252"
          },
          "os": {
            "type": "string",
            "description": "Operating system / platform of the player.",
            "example": "web"
          },
          "device": {
            "type": "string",
            "description": "Device or client identifier.",
            "example": "chrome"
          },
          "country": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2 country of the listener. Required: a report without it is rejected with `400`; it is never inferred from the request.",
            "example": "US"
          },
          "vendor": {
            "type": "integer",
            "description": "Your vendor id, assigned by FitRadio. Must be a JSON integer.",
            "example": 12
          },
          "playlength": {
            "type": "integer",
            "description": "How many **seconds** of the track actually played. Strongly recommended — required for accurate reporting.",
            "example": 213
          },
          "userid": {
            "type": "string",
            "description": "Stable identifier of the end listener in your system. Strongly recommended — required for per-listener compliance reporting.",
            "example": "partner-user-42"
          },
          "uuid": {
            "type": "string",
            "description": "Unique id for this play event (deduplication).",
            "example": "3f6a1c2e-8f4b-4b7e-9d2a-5c8e1f0a9b3d"
          },
          "isrc": {
            "type": "string",
            "description": "ISRC of the track, echoed from `tracks[].isrc` exactly as received (do not reformat). If omitted, FitRadio looks it up from `trackid`; sending it explicitly is strongly preferred.",
            "example": "USUM71703861"
          },
          "endreason": {
            "type": "integer",
            "description": "Numeric code for why playback of the track ended. Defaults to `1` when omitted. FitRadio provides the code list for your integration during onboarding.",
            "default": 1,
            "example": 1
          },
          "cachedplay": {
            "type": "boolean",
            "description": "Whether the play was served from a local cache rather than streamed.",
            "default": false
          },
          "jumpforward": {
            "type": "boolean",
            "description": "Whether the user seeked forward during the track.",
            "default": false
          }
        }
      },
      "RoyaltyAccepted": {
        "type": "object",
        "properties": {
          "error": {
            "type": "boolean",
            "const": false
          },
          "message": {
            "type": "string",
            "const": "success"
          }
        }
      },
      "RoyaltyError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "boolean",
            "const": true
          },
          "message": {
            "type": "string",
            "description": "Human-readable message; for validation failures, lists the missing/invalid fields."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "description": "Standard error envelope. The `mix` field is legacy and always `null`.",
        "properties": {
          "error": {
            "type": "boolean",
            "const": true
          },
          "reason": {
            "type": "string",
            "description": "Human-readable message."
          },
          "mix": {
            "type": "null"
          }
        }
      }
    }
  }
}
