REST API

Tracklists as JSON.

Read every public set, artist and track on ryser.id with a free key. Plain HTTPS, JSON in and out, any origin, no SDK needed. Scanning new sets is a separate, paid door at the bottom.

Authentication

Create a key under Account → API. Any account can hold up to five keys; no plan is needed to read public data. A key starts with rysk_, is shown once at creation and stored as a hash. Send it as a bearer token on every request.

http
GET /api/public/search?q=cercle HTTP/1.1
Host: ryser.id
Authorization: Bearer rysk_your_key_here

A missing or malformed header returns 401 with a message that says where to get a key. A revoked key returns the same status. The read key never spends minutes; only the scan endpoints do.

Conventions

  • Base URL https://ryser.id. All read endpoints live under /api/public/.
  • Responses are JSON with snake_case fields. Times are ISO 8601 in UTC; durations and positions are seconds; start is also given as m:ss or h:mm:ss for display.
  • Every object carries its public url on ryser.id. Show it when you show the data.
  • CORS is open (Access-Control-Allow-Origin: *), so a browser can call the API directly. Keep the key server-side anyway; a key in client code is a key anyone can copy.
  • Responses are cached for five minutes at the edge (Cache-Control: public, max-age=300). Polling faster than that returns the same body.
  • Rate limit: 60 requests per minute per key. Over it you get 429; wait for the next minute.
  • Only public, finished analyses are visible. A set someone kept private is a 404 here, exactly as on the site.
GET/api/public/sets/{id}free key

One public set with its full tracklist. The id is the uuid from search or from a ryser.id URL (https://ryser.id/s/<id>). Rows come in playback order; unidentified segments are present with status unknown so the timeline stays complete.

bash
curl https://ryser.id/api/public/sets/9c2e… -H "Authorization: Bearer rysk_…"

{ "id": "9c2e…", "url": "https://ryser.id/s/9c2e…", "title": "…", "uploader": "…",
  "source_url": "…", "duration_seconds": 5412, "identified_tracks": 31, "origin": "library",
  "finished_at": "2026-09-20T03:41:12.000Z",
  "tracks": [
    { "index": 0, "start_seconds": 0, "end_seconds": 318, "start": "0:00",
      "artist": "Da Capo", "title": "Kelaya", "status": "matched",
      "bpm": 122, "key": "F min",
      "artist_url": "https://ryser.id/artists/da-capo",
      "track_url": "https://ryser.id/tracks/da-capo-kelaya",
      "soundcloud_url": "https://soundcloud.com/search?q=Da%20Capo%20Kelaya",
      "beatport_search_url": "https://www.beatport.com/search?q=Da%20Capo%20Kelaya" },
    { "index": 1, "start_seconds": 318, "end_seconds": 611, "start": "5:18",
      "artist": "", "title": "", "status": "unknown", "bpm": 124, "key": null, … },
    …
  ] }

404 { "error": "not a public set" } when the id does not exist, is private, or is still running.

GET/api/public/artists/{slug}free key

A canonical artist. The slug is the lowercase hyphenated name (black-coffee, amelie-lens); the same slug is in every artist_url a tracklist row carries. Spelling variants and aliases resolve to the same artist, so the counts here include every way the name was heard.

bash
curl https://ryser.id/api/public/artists/black-coffee -H "Authorization: Bearer rysk_…"

{ "slug": "black-coffee", "name": "Black Coffee", "url": "https://ryser.id/artists/black-coffee",
  "links": { "instagram": "https://www.instagram.com/…", "spotify": "…", "soundcloud": "…", "youtube": null, "website": null },
  "identified_tracks": 142, "played_in_sets": 37,
  "top_tracks": [ { "artist": "Black Coffee", "title": "Wish You Were Here", "plays": 9 }, … ],
  "sets_performed": [ { …set summary… }, … ],   // sets this artist played (max 20)
  "played_in":      [ { …set summary… }, … ] }  // sets where their records were heard (max 20)

404 { "error": "unknown artist" } for a slug that is not a canonical artist yet.

GET/api/public/tracks/{slug}free key

A canonical record: the versions that were heard (original, remixes, edits, extended mixes) and every public set it appears in, with the position. Slug is artist-title without the version tag, as in every track_url a tracklist row carries.

bash
curl https://ryser.id/api/public/tracks/da-capo-kelaya -H "Authorization: Bearer rysk_…"

{ "slug": "da-capo-kelaya", "title": "Kelaya", "artist": "Da Capo",
  "url": "https://ryser.id/tracks/da-capo-kelaya",
  "artist_url": "https://ryser.id/artists/da-capo",
  "versions": [
    { "artist": "Da Capo", "title": "Kelaya", "version": "Original", "plays": 12 },
    { "artist": "Da Capo", "title": "Kelaya (Extended Mix)", "version": "Extended Mix", "plays": 4 } ],
  "appears_in": [
    { …set summary…, "at_seconds": 2711, "at": "45:11" },
    …
  ] }

404 { "error": "unknown track" } for a slug that is not a canonical track yet.

GET/api/public/tracklist?url={set link}no key

The one keyless endpoint, built for the browser extension: given the link of the page a person is on, is there a public tracklist? It returns a compact list without the per-row links and is meant for showing a tracklist under a player, not for crawling. For anything else use the keyed endpoints above.

bash
curl "https://ryser.id/api/public/tracklist?url=https://www.youtube.com/watch?v=…"

200 { "found": true, "run_id": "9c2e…", "url": "https://ryser.id/s/9c2e…", "title": "…", "uploader": "…",
      "duration": 5412, "identified": 31, "total": 34,
      "tracks": [ { "start": 0, "artist": "Da Capo", "title": "Kelaya", "bpm": 122, "key": "F min" }, … ] }
404 { "found": false, "analyse_url": "https://ryser.id/?url=…" }

Objects

The set summary is the same shape everywhere it appears: search results, an artist's sets, a track's appearances.

idstring (uuid)Stable id of the analysis. Use it with /api/public/sets/{id}.
urlstringPublic page on ryser.id.
title / uploaderstring | nullTitle and channel or profile as published on the source platform.
source_urlstring | nullCanonical link of the set on YouTube, SoundCloud or Mixcloud.
duration_secondsinteger | nullLength of the audio.
identified_tracksintegerRows with status matched.
originlibrary | communitylibrary: scanned automatically by ryser.id from a followed channel, first pass with one engine. community: analysed by a person and shared.
finished_atISO datetime | nullWhen the analysis completed.

A tracklist row:

indexintegerPosition in the set, from 0.
start_seconds / end_secondsnumber | nullWhere the segment starts and ends in the audio. end_seconds can be null on the last row.
startstringstart_seconds formatted for display.
artist / titlestringEmpty strings when the row is unknown.
statusmatched | unknown | uncertainmatched: identified and corroborated. uncertain: one source named it and nothing confirmed it; show it as a guess. unknown: heard, not identified.
bpm / keynumber | null, string | nullTempo and musical key measured from the audio, per segment; present on unknown rows too when measurable.
artist_url / track_urlstringCanonical artist and track pages on ryser.id.
soundcloud_url / beatport_search_urlstring | nullSearch links for the record on those platforms.

Code examples

Read the key from the environment; never ship it to a browser.

javascript
// Node 18+, Deno, Bun, or a Next.js server component
const KEY = process.env.RYSER_API_KEY;

async function tracklist(setId) {
  const res = await fetch(`https://ryser.id/api/public/sets/${setId}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (res.status === 404) return null;            // private, running or unknown
  if (!res.ok) throw new Error(`ryser.id ${res.status}: ${(await res.json()).error}`);
  return res.json();
}

const set = await tracklist("9c2e…");
for (const t of set.tracks) {
  console.log(t.start, t.status === "matched" ? `${t.artist} – ${t.title}` : "(unknown)");
}
python
import os, requests

KEY = os.environ["RYSER_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}

def find_by_link(url: str):
    r = requests.get("https://ryser.id/api/public/search", params={"url": url}, headers=H, timeout=20)
    if r.status_code == 404:
        return None                      # r.json()["analyse_url"] sends someone to analyse it
    r.raise_for_status()
    return r.json()["set"]

def artist(slug: str):
    r = requests.get(f"https://ryser.id/api/public/artists/{slug}", headers=H, timeout=20)
    r.raise_for_status()
    return r.json()

s = find_by_link("https://www.youtube.com/watch?v=…")
if s:
    print(s["title"], s["identified_tracks"], "tracks", s["url"])
bash
# A one-liner for the terminal: every matched row of a set as "start  artist – title"
curl -s https://ryser.id/api/public/sets/9c2e… -H "Authorization: Bearer $RYSER_API_KEY" \
  | jq -r '.tracks[] | select(.status=="matched") | "\(.start)\t\(.artist) – \(.title)"'

Scan API (Studio)

Starting a new analysis from code uses the same key and the same minutes as the app. It is part of the Studio plan; a free key gets 403 here. A 60-minute set takes about 17 minutes; poll, do not hammer.

POST/api/v1/runsStudio key

Queue a YouTube, SoundCloud or Mixcloud link. Costs one minute of balance per minute of audio, deducted when the analysis starts. If a public analysis of the same link already exists you get a free copy back at once with duplicate: true and status done.

bash
curl -X POST https://ryser.id/api/v1/runs \
  -H "Authorization: Bearer rysk_…" -H "Content-Type: application/json" \
  -d '{"url": "https://www.youtube.com/watch?v=…"}'

201 { "run_id": "1f0c…", "status": "queued" }
201 { "run_id": "7a90…", "status": "done", "duplicate": true }
GET/api/v1/runs/{id}Studio key

Status and the setlist so far. Poll every 30 to 60 seconds until status is done or failed. Rows stream in while it runs, so a partial tracklist is normal before completion.

bash
{ "id": "1f0c…", "status": "done", "title": "…", "duration_seconds": 3612,
  "matched_tracks": 24, "total_tracks": 27,
  "tracks": [ { "index": 0, "start_seconds": 0, "end_seconds": 312, "artist": "…", "title": "…",
                "status": "matched", "bpm": 128, "key": "A min", "provider": "fingerprint" }, … ] }
GET/api/v1/runsStudio key

Your 50 most recent runs, newest first, with status and counts.

Limits and errors

Read endpoints60 requests per minute per key. Cached five minutes. Max 50 results per call.
Scan endpoints30 runs per 10 minutes per account, shared with the web app. Studio plan.
KeysUp to five active keys per account. Revoke and recreate under Account at any time.
Extension endpointNo key; meant for one request per page view from the extension. Use the keyed endpoints for anything else.

Errors are JSON with one field:

json
{ "error": "Slow down: 60 requests per minute per key." }
400bad requestMissing url, invalid JSON, or a link that is not YouTube, SoundCloud or Mixcloud.
401unauthorisedNo key, malformed header, or a revoked key.
402payment requiredScan only: not enough minutes for this set. The message says how many it needs.
403forbiddenScan only: the key's account is not on Studio.
404not foundNot a public set, unknown artist or track, or no tracklist for that link yet.
429too many requestsRate limit hit. Wait for the next minute (read) or ten minutes (scan).
503worker offlineScan only: the analysis worker is unreachable. Retry in a minute.

Beta. Endpoints and field names are stable; new fields may appear without notice, so tolerate unknown keys. Questions, wishes, higher limits: [email protected]. Want an assistant to do this for you instead? See the MCP server.