openapi: 3.1.0
info:
  title: Tuneping API
  version: "1.0.0-beta"
  summary: What internet radio stations are playing right now, and what they played.
  description: |
    Tuneping reads the public title line of internet radio streams (metadata only, never audio) and serves it as JSON.
    Live endpoints work without a key, limited per IP address (2 requests/s, burst 5, 1,000 per day). Keys unlock
    higher limits and play history. Errors are RFC 9457 problem documents; see https://tuneping.com/docs/errors/.
  contact:
    name: Tuneping
    url: https://tuneping.com
    email: hello@tuneping.com
  termsOfService: https://tuneping.com/terms/
servers:
  - url: https://api.tuneping.com/v1
tags:
  - name: now-playing
  - name: stations
  - name: history
  - name: account
  - name: health
security:
  - bearerKey: []
  - headerKey: []
  - tapelarkEntitlement: []
  - {}
paths:
  /now-playing/search:
    get:
      operationId: searchNowPlaying
      tags: [now-playing]
      summary: Which stations are playing q right now
      description: Fuzzy, accent-insensitive search over the current titles. Ordered by similarity, then recency. No pagination; raise `limit`.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 120 } }
        - { name: country, in: query, schema: { type: array, items: { $ref: "#/components/schemas/CountryCode" } }, style: form, explode: true }
        - { name: codec, in: query, schema: { type: string } }
        - { name: min_bitrate, in: query, schema: { type: integer, minimum: 0 } }
        - { name: classification, in: query, schema: { $ref: "#/components/schemas/ClassificationFilter" } }
        - { name: max_age_seconds, in: query, schema: { type: integer, minimum: 30, maximum: 900, default: 180 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
      responses:
        "200":
          description: Matching live rows
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/CacheControlLive" }
          content:
            application/json:
              schema:
                type: object
                required: [data, truncated, query]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/NowPlaying" } }
                  truncated: { type: boolean, description: More rows matched than `limit` }
                  query:
                    type: object
                    properties:
                      q: { type: string }
                      normalized: { type: string, description: q after unaccenting }
                      max_age_seconds: { type: integer }
        "304": { description: Not modified (If-None-Match matched) }
        "400": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /now-playing:
    get:
      operationId: batchNowPlaying
      tags: [now-playing]
      summary: Current title for a batch of stations
      parameters:
        - name: station_ids
          in: query
          required: true
          description: Comma-separated station UUIDs. Up to 20 anonymous/Free, 100 from Developer.
          schema: { type: string }
        - { name: max_age_seconds, in: query, schema: { type: integer, minimum: 30, maximum: 86400, default: 900 } }
      responses:
        "200":
          description: One row per known station; unknown or opted-out ids in `missing`
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/CacheControlLive" }
          content:
            application/json:
              schema:
                type: object
                required: [data, missing, max_age_seconds]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/NowPlaying" } }
                  missing: { type: array, items: { type: string, format: uuid } }
                  max_age_seconds: { type: integer }
        "304": { description: Not modified }
        "403": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /stations/{id}/now-playing:
    get:
      operationId: stationNowPlaying
      tags: [now-playing]
      summary: Current title for one station
      parameters:
        - { $ref: "#/components/parameters/StationId" }
      responses:
        "200":
          description: The live row
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Cache-Control: { $ref: "#/components/headers/CacheControlLive" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NowPlaying" }
        "304": { description: Not modified }
        "404": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /stations:
    get:
      operationId: listStations
      tags: [stations]
      summary: Station directory
      parameters:
        - { name: q, in: query, schema: { type: string }, description: Station name, fuzzy }
        - { name: country, in: query, schema: { type: array, items: { $ref: "#/components/schemas/CountryCode" } }, style: form, explode: true }
        - { name: tag, in: query, schema: { type: array, items: { type: string } }, style: form, explode: true, description: All tags must match }
        - { name: codec, in: query, schema: { type: string } }
        - { name: min_bitrate, in: query, schema: { type: integer, minimum: 0 } }
        - { name: has_now_playing, in: query, schema: { type: boolean, default: false } }
        - { name: sort, in: query, schema: { type: string, enum: [clickcount, name, bitrate], default: clickcount } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page of stations
          headers:
            Cache-Control: { schema: { type: string }, example: "public, max-age=60, s-maxage=60" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Station" } }
                  next_cursor: { type: [string, "null"] }
        "400": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /stations/{id}:
    get:
      operationId: getStation
      tags: [stations]
      summary: One station
      parameters:
        - { $ref: "#/components/parameters/StationId" }
      responses:
        "200":
          description: The station
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Station" }
        "404": { $ref: "#/components/responses/Problem" }
  /stations/{id}/history:
    get:
      operationId: stationHistory
      tags: [history]
      summary: What a station played
      description: "Key required. Depth by plan: 7 days (Developer), 12 months (Pro, Business). An older `from` answers 403 forbidden_tier."
      security:
        - bearerKey: []
        - headerKey: []
      parameters:
        - { $ref: "#/components/parameters/StationId" }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: classification, in: query, schema: { $ref: "#/components/schemas/ClassificationFilter" } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200":
          description: Play events, newest first
          headers:
            Cache-Control: { schema: { type: string }, example: "private, max-age=30" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                required: [data, window]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/PlayEvent" } }
                  next_cursor: { type: [string, "null"] }
                  window:
                    type: object
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /me:
    get:
      operationId: getMe
      tags: [account]
      summary: The calling key, its plan, limits and usage
      security:
        - bearerKey: []
        - headerKey: []
      responses:
        "200":
          description: Key, owner, limits, usage
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Problem" }
  /me/usage:
    get:
      operationId: getUsage
      tags: [account]
      summary: Requests per day for the calling key
      security:
        - bearerKey: []
        - headerKey: []
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Daily counts (origin requests only)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        day: { type: string, format: date }
                        requests: { type: integer }
                  note: { type: string }
        "401": { $ref: "#/components/responses/Problem" }
  /signup:
    post:
      operationId: requestSignup
      tags: [account]
      summary: Request an API key by e-mail (self-serve)
      description: Sends one verification link, valid 60 minutes, single use. Always 202 for a well-formed address; never says whether the address is known. At most 10 mails per IP and 3 per address per UTC day.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                plan: { type: string, enum: [free, developer, pro], default: free }
      responses:
        "202":
          description: Mail sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [sent] }
                  email: { type: string }
                  plan: { type: string }
                  expires_in_seconds: { type: integer }
        "400": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
  /signup/verify:
    post:
      operationId: verifySignup
      tags: [account]
      summary: Exchange the mailed token for the key (shown once)
      description: Creates the owner (Free) and a live key, or rotates the oldest key when the owner is at the tier's key limit (the old key stays valid 24 h). `checkout_url` is present for developer/pro requests when Stripe Payment Links are configured.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string, minLength: 43, maxLength: 43 }
      responses:
        "201":
          description: Key created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignupResult" }
        "200":
          description: Key rotated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignupResult" }
        "404": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
  /health:
    get:
      operationId: health
      tags: [health]
      summary: Service health
      security: [{}]
      responses:
        "200":
          description: ok or degraded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
        "503":
          description: down
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
  /health/series:
    get:
      operationId: healthSeries
      tags: [health]
      summary: Per-minute poller figures
      security: [{}]
      parameters:
        - { name: minutes, in: query, schema: { type: integer, minimum: 5, maximum: 1440, default: 60 } }
      responses:
        "200":
          description: One row per minute
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/HealthMinute" } }
                  minutes: { type: integer }
  /stats:
    get:
      operationId: stats
      tags: [health]
      summary: Public coverage snapshot
      security: [{}]
      responses:
        "200":
          description: Coverage figures
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "Authorization: Bearer tp_live_… (or tp_test_…)"
    headerKey:
      type: apiKey
      in: header
      name: X-Api-Key
    tapelarkEntitlement:
      type: http
      scheme: bearer
      description: >-
        Partner entitlement token (Tapelark): `Authorization: Bearer TPE1-<base64url claims>.<base64url Ed25519 signature>`,
        issued by the partner's licence server and verified offline. Claims: iss `https://api.tapelark.com`, aud `api.tuneping.com`,
        kid, lic, dev, tier, plan `tapelark`, optional feat[], ent_exp, iat, nbf, exp (Unix seconds), jti. Resolves to the Tapelark
        plan limits reported by `GET /v1/me`.
  parameters:
    StationId:
      name: id
      in: path
      required: true
      description: Radio Browser station UUID
      schema: { type: string, format: uuid }
  headers:
    ETag:
      description: Weak validator; send back as If-None-Match
      schema: { type: string }
    CacheControlLive:
      schema: { type: string }
      example: "public, max-age=5, s-maxage=5"
    RateLimit:
      description: 'IETF draft field, e.g. "burst";r=7;t=1'
      schema: { type: string }
    RateLimitPolicy:
      description: 'e.g. "burst";q=10;w=1, "monthly";q=100000;w=2592000'
      schema: { type: string }
  responses:
    Problem:
      description: RFC 9457 problem document
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Per-second bucket empty (rate_limited) or quota spent (quota_exceeded)
      headers:
        Retry-After: { schema: { type: integer }, description: seconds }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  schemas:
    SignupResult:
      type: object
      properties:
        status: { type: string, enum: [created, rotated] }
        key: { type: string, description: "tp_live_… — shown once, never stored" }
        key_id: { type: string }
        hint: { type: string }
        rotated_from: { type: string, nullable: true }
        previous_valid_until: { type: string, format: date-time, nullable: true }
        owner:
          type: object
          properties:
            id: { type: string }
            email: { type: string }
            tier: { type: string }
        plan_requested: { type: string }
        checkout_url: { type: string, nullable: true }
    CountryCode:
      type: string
      pattern: "^[A-Za-z]{2}$"
      description: ISO 3166-1 alpha-2
    ClassificationFilter:
      type: string
      enum: [song, ident, programme, ad_break, empty, junk, unknown, any]
      default: song
    Classification:
      type: string
      enum: [song, ident, programme, ad_break, empty, junk, unknown]
    MatchGrade:
      type: string
      enum: [high, medium, none]
    Confidence:
      type: string
      enum: [high, medium, low]
    Method:
      type: string
      description: How the station is read. Internal identifiers; display as "in-stream" or "status page".
      enum: [icy, icecast_json, shoutcast_stats, shoutcast_currentsong, seven_html]
    StationRef:
      type: object
      required: [id, name, stream_url, recordability]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        country: { type: [string, "null"] }
        codec: { type: [string, "null"] }
        bitrate: { type: [integer, "null"] }
        is_hls: { type: boolean }
        stream_url: { type: string }
        homepage: { type: [string, "null"] }
        recordability: { type: string }
    TrackRef:
      type: object
      required: [id]
      properties:
        id: { type: string, description: "trk_…" }
        mbid: { type: [string, "null"], format: uuid, description: MusicBrainz recording id }
        artist_id: { type: [string, "null"], description: "art_…" }
    NowPlaying:
      type: object
      required: [station, classification, match_grade, confidence, started_at, start_uncertainty_seconds, observed_at, method, poll_interval_seconds]
      properties:
        station: { $ref: "#/components/schemas/StationRef" }
        raw_title: { type: [string, "null"], description: The title line as the station sent it }
        artist: { type: [string, "null"] }
        title: { type: [string, "null"] }
        track: { oneOf: [{ $ref: "#/components/schemas/TrackRef" }, { type: "null" }] }
        classification: { $ref: "#/components/schemas/Classification" }
        match_grade: { $ref: "#/components/schemas/MatchGrade" }
        confidence: { $ref: "#/components/schemas/Confidence" }
        started_at: { type: string, format: date-time, description: Estimated start of the current title }
        start_uncertainty_seconds: { type: integer }
        observed_at: { type: string, format: date-time, description: When the title was first seen }
        confirmed_at: { type: [string, "null"], format: date-time, description: Latest successful poll of the station }
        method: { $ref: "#/components/schemas/Method" }
        poll_interval_seconds: { type: integer }
        similarity: { type: number, minimum: 0, maximum: 1, description: Search only }
        is_stale: { type: boolean, description: Batch only; confirmed_at older than max_age_seconds }
    PlayEvent:
      type: object
      required: [id, station, classification, match_grade, started_at, start_uncertainty_seconds, method, confidence]
      properties:
        id: { type: string, description: "pe_…" }
        station: { $ref: "#/components/schemas/StationRef" }
        raw_title: { type: [string, "null"] }
        artist: { type: [string, "null"] }
        title: { type: [string, "null"] }
        track: { oneOf: [{ $ref: "#/components/schemas/TrackRef" }, { type: "null" }] }
        classification: { $ref: "#/components/schemas/Classification" }
        match_grade: { $ref: "#/components/schemas/MatchGrade" }
        started_at: { type: string, format: date-time }
        start_uncertainty_seconds: { type: integer }
        ended_at: { type: [string, "null"], format: date-time }
        duration_seconds: { type: [integer, "null"] }
        method: { $ref: "#/components/schemas/Method" }
        confidence: { $ref: "#/components/schemas/Confidence" }
    StationMetadata:
      type: object
      required: [coverage]
      properties:
        method: { oneOf: [{ $ref: "#/components/schemas/Method" }, { type: "null" }] }
        poll_interval_seconds: { type: [integer, "null"] }
        lag_seconds: { type: [integer, "null"] }
        last_ok_at: { type: [string, "null"], format: date-time }
        coverage: { type: string }
    Station:
      type: object
      required: [id, name, tags, stream_url, recordability, metadata, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        country: { type: [string, "null"] }
        state: { type: [string, "null"] }
        language: { type: [string, "null"] }
        tags: { type: array, items: { type: string } }
        codec: { type: [string, "null"] }
        bitrate: { type: [integer, "null"] }
        is_hls: { type: boolean }
        stream_url: { type: string }
        homepage: { type: [string, "null"] }
        favicon_url: { type: [string, "null"], description: Text pass-through from the directory; Tuneping serves no logos }
        clickcount: { type: integer }
        recordability: { type: string }
        metadata: { $ref: "#/components/schemas/StationMetadata" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Me:
      type: object
      properties:
        key:
          type: object
          properties:
            id: { type: string }
            hint: { type: string }
            scopes: { type: array, items: { type: string } }
            livemode: { type: boolean }
            created_at: { type: string, format: date-time }
            last_used_at: { type: [string, "null"], format: date-time }
        owner:
          type: object
          properties:
            id: { type: string }
            tier: { type: string, enum: [free, developer, pro, business] }
            status: { type: string }
            current_period_start: { type: [string, "null"], format: date-time }
            current_period_end: { type: [string, "null"], format: date-time }
        limits:
          type: object
          properties:
            requests_per_second: { type: integer }
            burst: { type: integer }
            requests_per_day: { type: [integer, "null"] }
            requests_per_month: { type: [integer, "null"] }
            history_days: { type: integer }
            batch_station_ids: { type: integer }
            webhooks: { type: integer }
            wishlists: { type: integer }
            sse_connections: { type: integer }
            tightened_stations: { type: integer }
        usage:
          type: object
          properties:
            period_requests: { type: integer }
            today_requests: { type: integer }
            resets_at: { type: [string, "null"], format: date-time }
    Health:
      type: object
      properties:
        status: { type: string, enum: [ok, degraded, down] }
        version: { type: string }
        db: { type: string, enum: [ok, down] }
        poller:
          type: object
          properties:
            last_title_change_at: { type: [string, "null"], format: date-time }
            lag_seconds: { type: integer }
            oldest_poll_age_seconds: { type: [integer, "null"] }
            outbox_lag_seconds: { type: integer }
    HealthMinute:
      type: object
      properties:
        minute: { type: string, format: date-time }
        polls: { type: integer }
        ok: { type: integer }
        titles_changed: { type: integer }
        songs: { type: integer }
        bytes_in: { type: integer }
        bytes_out: { type: integer }
        inflight_max: { type: integer }
        ips_paused: { type: integer }
        queue_lag_seconds: { type: integer }
        close_rst: { type: [integer, "null"] }
        close_fin: { type: [integer, "null"] }
        latency_p50_ms: { type: [integer, "null"] }
        latency_p90_ms: { type: [integer, "null"] }
        by_method:
          type: object
          additionalProperties:
            type: object
            properties:
              polls: { type: integer }
              ok: { type: integer }
    Stats:
      type: object
      properties:
        generated_at: { type: string, format: date-time }
        stations_total: { type: integer }
        stations_active: { type: integer }
        stations_polled: { type: integer }
        stations_with_live_metadata: { type: integer }
        stations_polled_last_5m: { type: integer }
        titles_changed_last_hour: { type: integer }
        title_changes_per_minute: { type: number }
        songs_identified_share: { type: [number, "null"] }
        by_method: { type: object, additionalProperties: { type: integer } }
        by_country: { type: object, additionalProperties: { type: integer } }
        sources: { type: array, items: { type: string } }
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri, description: "https://tuneping.com/docs/errors/#<code>" }
        title: { type: string }
        status: { type: integer }
        detail: { type: [string, "null"] }
        instance: { type: string, description: "urn:tuneping:request:<request id>" }
        code: { type: string }
        errors:
          type: array
          description: validation_failed only
          items:
            type: object
            properties:
              field: { type: string }
              code: { type: string }
              message: { type: string }
        required_tier: { type: string, description: forbidden_tier only }
        upgrade_url: { type: string, format: uri }
        quota_resets_at: { type: string, format: date-time, description: quota_exceeded only }
