Public API.
Read-only HTTP endpoints for our weekly charts, public artist profiles, and a drop-in JavaScript embed. No authentication, no tokens, no signup. Just call the URLs.
1. Authentication
None. Every endpoint documented here is fully public. There is no API key, no bearer token, no OAuth. Cross-origin browser requests are allowed (see CORS below).
We may introduce optional bearer tokens later for partners who want higher rate-limit ceilings. When that ships, the same endpoints will continue to work without auth at the documented limits — the token will only ever raise the cap, never make an existing call fail.
2. CORS posture
Public GETs send Access-Control-Allow-Origin: * and Vary: Origin. OPTIONS preflight is handled where any non-simple header (e.g. a custom Content-Type) is sent.
POST endpoints elsewhere on the site (newsletter signup, DMCA intake, content reports) use a strict Origin allowlist; cross-origin POSTs return 403. Those endpoints are not part of the public API.
3. Rate limits
Every public endpoint is rate-limited per source IP. Exceeding the limit returns 429 Too Many Requests with a Retry-After header and a structured body (see Error shape). Every response — including 200s — carries the X-RateLimit-* headers so you can pace yourself.
| Endpoint | Policy | Limit | Window |
|---|---|---|---|
| GET /api/charts/[genre] | chartPublic | 60 req | per minute / IP |
| GET /api/public/charts/[genre] | chartEmbed | 300 req | per minute / IP |
| GET /api/artists/[handle] | chartPublic | 60 req | per minute / IP |
| GET /embed/charts/[genre]/embed.js | cached, 1-week immutable | n/a | cached at the edge |
Response headers (every call)
X-RateLimit-Limit: 60 X-RateLimit-Remaining: 59 X-RateLimit-Reset: 1714329600 # unix seconds Retry-After: 30 # only on 429
The default policy is the higher-throughput route; the embed JSON endpoint at /api/public/charts/[genre] is the one we recommend for third-party traffic. The internal /api/charts/[genre] exists for first-party callers.
4. Error shape
Every non-2xx response returns this exact JSON shape. Never plain-text, never HTML, never a stack trace.
{
"code": "rate_limited",
"message": "Too many requests. Retry after 30s."
}| Status | code | When |
|---|---|---|
| 404 | not_found | Genre or handle does not exist (or is non-public). |
| 429 | rate_limited | Source IP exceeded the bucket. Honour Retry-After. |
| 403 | forbidden | IP appears on the abuse blocklist. |
5. GET /api/charts/[genre]
The first-party chart JSON. Returned from a snapshot computed every Friday at 00:00 UTC. Cached at the edge for 60 seconds with a 5-minute stale-while-revalidate window.
Genre: kebab-case slug, e.g. ambient, house, electronic. The full list is available via the marketing site at /charts.
Response 200
{
"genre": "Ambient",
"genreSlug": "ambient",
"weekNumber": 17,
"year": 2026,
"snapshotId": "ckxxxxxxxxxxx",
"snapshotAt": "2026-04-25T00:00:00.000Z",
"weights": { "plays": 0.6, "saves": 0.25, "completion": 0.15 },
"tracks": [
{
"rank": 1,
"id": "trk_xxxxxxxxxxx",
"title": "Slow Light",
"artist": "Iris Vale",
"artistHandle": "iris-vale",
"genre": "Ambient",
"plays": 12480,
"movement": 3,
"coverUrl": null,
"duration": 248,
"components": { "plays": 0.62, "saves": 0.21, "completion": 0.17 }
}
]
}Response headers
Cache-Control: public, max-age=60, s-maxage=60, stale-while-revalidate=300 X-Chart-Snapshot-Id: ckxxxxxxxxxxx X-RateLimit-Limit: 60 X-RateLimit-Remaining: 59 X-RateLimit-Reset: 1714329600
curl
curl -i https://element59.app/api/charts/ambient
6. GET /api/public/charts/[genre]
Same data shape as /api/charts/[genre], but tuned for third-party traffic: explicit CORS, longer cache, and a higher rate-limit ceiling (300 rpm/IP). Use this endpoint when calling from another origin.
Response headers
Cache-Control: public, s-maxage=300, stale-while-revalidate=86400 Access-Control-Allow-Origin: * Vary: Origin X-Chart-Snapshot-Id: ckxxxxxxxxxxx X-RateLimit-Limit: 300 X-RateLimit-Remaining: 299 X-RateLimit-Reset: 1714329600
curl
curl -i https://element59.app/api/public/charts/ambient
Browser fetch
const r = await fetch(
"https://element59.app/api/public/charts/ambient",
{ headers: { Accept: "application/json" } },
);
const chart = await r.json();7. GET /api/artists/[handle]
Public artist profile data. Returns 404 for any artist whose visibility is not public— we do not leak the existence of private accounts.
Handle: the artist handle as it appears in /artist/<handle> on the marketing site. Lowercase, kebab-case, max 64 chars.
Response 200
{
"handle": "iris-vale",
"name": "Iris Vale",
"bio": "Ambient + slow techno. Berlin.",
"pfpUrl": "https://cdn.element59.app/...",
"coverUrl": "https://cdn.element59.app/...",
"visibility": "public",
"trackCount": 12,
"totalPlays": 248120,
"chartAppearances": 4,
"socialLinks": [
{ "platform": "tiktok", "url": "https://tiktok.com/@iris.vale" },
{ "platform": "youtube", "url": "https://youtube.com/@irisvale" }
],
"releases": [
{
"id": "rel_xxxxxxxxxxx",
"title": "Slow Light",
"releasedAt": "2026-04-19T00:00:00.000Z",
"coverUrl": null,
"streamCount": 12480,
"chartRankCurrent": 1
}
]
}Response headers
Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400 Access-Control-Allow-Origin: * Vary: Origin X-RateLimit-Limit: 60 X-RateLimit-Remaining: 59 X-RateLimit-Reset: 1714329600
curl
curl -i https://element59.app/api/artists/iris-vale
8. Chart embed
Drop the top-10 chart for any genre into a third-party page with one script tag. The script injects a sandboxed <iframe>pointing at our embed widget — no NavBar, no Footer, dark by default with a prefers-color-scheme: light override.
Script tag (recommended)
<div id="e59-chart"></div> <script src="https://element59.app/embed/charts/ambient/embed.js" async ></script>
The script looks for an element with id="e59-chart"; if none is present it falls back to the script tag's parent. The injected iframe carries a strict sandbox attribute (allow-scripts and allow-popups only — no allow-same-origin) so it can never reach your host page's cookies.
Iframe (alternative)
If you prefer to skip the script tag, embed the iframe directly:
<iframe src="https://element59.app/embed/charts/ambient" style="width:100%;max-width:640px;border:0;background:#0a0a0a" height="640" loading="lazy" title="ELEMENT/59 ambient chart" ></iframe>
The embed page sends Content-Security-Policy: frame-ancestors * so any origin can frame it. It is noindex— the canonical surface for an artist click-through is /charts/<genre>.
postMessage protocol (forward-compatible)
The script-tag loader listens for e59.resize events from the iframe and updates its height. Future events follow the same shape:
{ "source": "e59-embed", "type": "e59.ready", "version": 1, "genre": "ambient", "weekNumber": 17, "year": 2026 }
{ "source": "e59-embed", "type": "e59.resize", "version": 1, "height": 612 }
{ "source": "e59-embed", "type": "e59.track.click", "version": 1, "trackId": "trk_...", "artistHandle": "iris-vale", "rank": 1 }9. Versioning & changes
The current shape is implicit v1. Backwards-incompatible changes ship under /api/v2/*. When v1 enters sunset, the existing endpoints will return:
Deprecation: true Sunset: Sat, 31 Oct 2026 00:00:00 GMT Link: </api/v2/charts/ambient>; rel="successor-version"
We commit to a 6-month minimum sunset window for any breaking change. Subscribe to the newsletter from the footer for advance notice.
Bug reports: hello@element59.com.