API and webhooks
Everything here works on the artists your own account is a member of, within your role on each one. Requests and responses are JSON over HTTPS.
The API and webhooks come with Label, including the free 30-day Label trial. Make keys and webhooks in the dashboard under API.
https://listenthru.com/api/v1 · release.created release.live fan.confirmed
Authentication
Create a personal key in the dashboard (API). Choose read, or read and write. The key is shown once: keep it in a secret manager, never in a page or an app people download. It looks like lt_0123456789ab_….
Send it on every request as a bearer token. A key acts as you: it sees the artists you are a member of and can do what your role on each allows. Revoke a key in the dashboard at any time; it stops working at once. Each key shows when it was last used.
curl https://listenthru.com/api/v1/artists \
-H "Authorization: Bearer $LISTENTHRU_KEY"
Roles and scopes
| Role | Can |
|---|---|
| viewer | Read releases and insights. |
| editor | Also create and update releases and the artist page. |
| admin | Also members, ad measurement, fans and their export. |
| owner | Also billing, deleting releases and handing over ownership. |
A read key can only use GET. Changing anything needs a read and write key and the role for it.
Rate limits
Up to 60 requests a minute per key. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). Past the limit you get 429 with Retry-After.
Errors
Errors have an HTTP status and a stable code to branch on; message is for people and may change. field names the input at fault, when there is one.
HTTP/1.1 403 Forbidden
{ "error": { "code": "insufficient_scope", "message": "This key can only read. Make a read and write key to change things." } }| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No key was sent. |
invalid_key | 401 | The key is wrong or revoked. |
plan_required | 402 | Your Label (or trial) has ended, or the artist is not on Pro. |
insufficient_scope | 403 | A read key tried to change something. |
forbidden | 403 | Your role on this artist does not allow it. |
not_found | 404 | No such artist, release or endpoint. |
method_not_allowed | 405 | The path exists, the method does not. |
invalid_request | 400 | Something sent is not valid; see field. |
conflict | 409 | The slug is taken. |
unresolvable | 422 | The link, ISRC or UPC could not be found. |
rate_limited | 429 | Too many requests; wait for Retry-After. |
internal_error | 500 | Our fault. Try again. |
Artists
The artists your account is a member of, with your role on each.
/api/v1/artistsread key · viewercurl https://listenthru.com/api/v1/artists \
-H "Authorization: Bearer $LISTENTHRU_KEY"Answer
{
"data": [
{ "id": "8saidwait", "name": "8saidwait", "role": "owner", "plan": "pro",
"url": "https://listenthru.com/8saidwait", "createdAt": "2026-10-01T03:00:00.000Z" }
]
}/api/v1/artists/{artist}read key · viewerReleases
Create a release page from a store link (Spotify, Apple Music, YouTube, Deezer, a pre-save page), or from an isrc (a track) or upc (an album or EP). The title, artwork and a link for every service that has it are filled in; anything you send yourself wins. New pages are drafts unless you send "status": "live". Optional: title, subtitle, releaseDate (YYYY-MM-DD), kind (single, ep, album, video), slug, links, artworkUrl, country (the store to look in, default JP).
Before the release date the page shows Pre-save buttons; on release day listenthru switches them to the release itself (that is the release.live webhook).
/api/v1/artists/{artist}/releasesread key · viewercurl https://listenthru.com/api/v1/artists/8saidwait/releases \
-H "Authorization: Bearer $LISTENTHRU_KEY"/api/v1/artists/{artist}/releases/{slug}read key · viewercurl https://listenthru.com/api/v1/artists/8saidwait/releases/say-it-again \
-H "Authorization: Bearer $LISTENTHRU_KEY"Answer
{
"data": {
"slug": "say-it-again", "url": "https://listenthru.com/8saidwait/say-it-again",
"title": "say it again", "subtitle": "", "status": "live", "releaseDate": "2026-10-16",
"kind": "single", "isrc": "JPAB01234567", "upc": "",
"artwork": "https://listenthru.com/media/a/8saidwait/…webp",
"links": [{ "id": "a1b2c3d4", "service": "spotify", "label": "Spotify", "url": "https://open.spotify.com/track/…" }],
"createdAt": "…", "updatedAt": "…", "upgradedAt": ""
}
}
/api/v1/artists/{artist}/releasesread and write key · editorFrom a link
curl -X POST https://listenthru.com/api/v1/artists/8saidwait/releases \
-H "Authorization: Bearer $LISTENTHRU_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://open.spotify.com/track/…", "releaseDate": "2026-11-20"}'From an ISRC
curl -X POST https://listenthru.com/api/v1/artists/8saidwait/releases \
-H "Authorization: Bearer $LISTENTHRU_KEY" \
-H "Content-Type: application/json" \
-d '{"isrc": "JPAB01234567", "status": "live"}'
/api/v1/artists/{artist}/releases/{slug}read and write key · editorcurl -X PATCH https://listenthru.com/api/v1/artists/8saidwait/releases/say-it-again \
-H "Authorization: Bearer $LISTENTHRU_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "live", "subtitle": "out now"}'
Insights
The same numbers as the dashboard: visits, listens (taps through to a service), where visits came from, and audience. days is 1–90 on Pro and Label (up to 7 on Free).
/api/v1/artists/{artist}/insights?days=28&release={slug}read key · viewer/api/v1/artists/{artist}/releases/{slug}/insights?days=28read key · viewercurl "https://listenthru.com/api/v1/artists/8saidwait/insights?days=28" \
-H "Authorization: Bearer $LISTENTHRU_KEY"
curl "https://listenthru.com/api/v1/artists/8saidwait/releases/say-it-again/insights?days=7" \
-H "Authorization: Bearer $LISTENTHRU_KEY"Answer
{
"data": {
"days": 28, "from": "20261001", "to": "20261028",
"totals": { "views": 1520, "clicks": 610, "rate": 0.401, "presaves": 42, "reminders": 18 },
"series": [{ "day": "20261001", "views": 40, "clicks": 15 }, …],
"byService": [{ "service": "spotify", "label": "Spotify", "clicks": 380 }, …],
"bySource": [{ "source": "instagram", "views": 700, "clicks": 300, "rate": 0.43 }, …],
"byRelease": […], "byCountry": […], "byDevice": […], "byApp": […], "byCampaign": […],
"countryEstimated": true
}
}
Fans
Confirmed fan sign-ups (double opt-in), oldest first. Admins and owners only. Use since to fetch only new ones.
/api/v1/artists/{artist}/fans?since={ISO time}read key · admincurl "https://listenthru.com/api/v1/artists/8saidwait/fans?since=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $LISTENTHRU_KEY"Answer
{
"data": [
{ "email": "fan@example.com", "confirmedAt": "2026-10-02T11:04:00.000Z",
"source": "instagram", "release": "say-it-again", "lang": "ja" }
]
}
Webhooks
Add an https address in the dashboard (API) and choose events. Each event on an artist where you are an admin or owner is POSTed to it as JSON. The signing secret is shown once.
| Event | When |
|---|---|
release.created | A release page is made, in the dashboard or through the API. |
release.live | Release day: the Pre-save buttons switch to the release on each service. |
fan.confirmed | A fan confirms their sign-up. Contains their email address. |
Verifying the signature
X-Listenthru-Signature is t=<unix time>,v1=<hex HMAC-SHA256 of "t.body"> keyed by your secret. Check it against the raw body and refuse old timestamps. Answer with any 2xx within 10 seconds; do slow work afterwards. The same event can arrive twice: use id to skip repeats.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: listenthru-webhooks/1 (+https://listenthru.com/developers)
X-Listenthru-Event: release.live
X-Listenthru-Delivery: dlv_6f0c…
X-Listenthru-Signature: t=1792900000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_0b6c…", "type": "release.live", "created": "2026-11-20T00:05:12.000Z",
"artist": { "id": "8saidwait", "name": "8saidwait" },
"data": { "release": { "slug": "say-it-again", "status": "live", "links": […], … } }
}Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
// raw: the request body exactly as received (a string or Buffer), before any JSON parsing
export function verify(raw, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const want = createHmac('sha256', secret).update(`${t}.${raw}`).digest();
const got = Buffer.from(parts.v1 || '', 'hex');
return got.length === want.length && timingSafeEqual(got, want);
}
Retries
A delivery that does not get a 2xx (or times out) is tried again about 1, 2, 4, 8 and 16 hours later (6 tries in all). After 10 failures in a row the webhook is switched off; the dashboard shows it, and one click turns it back on. Redirects are not followed. Addresses on private, loopback or link-local networks are refused.