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

RoleCan
viewerRead releases and insights.
editorAlso create and update releases and the artist page.
adminAlso members, ad measurement, fans and their export.
ownerAlso 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." } }
CodeStatusMeaning
unauthorized401No key was sent.
invalid_key401The key is wrong or revoked.
plan_required402Your Label (or trial) has ended, or the artist is not on Pro.
insufficient_scope403A read key tried to change something.
forbidden403Your role on this artist does not allow it.
not_found404No such artist, release or endpoint.
method_not_allowed405The path exists, the method does not.
invalid_request400Something sent is not valid; see field.
conflict409The slug is taken.
unresolvable422The link, ISRC or UPC could not be found.
rate_limited429Too many requests; wait for Retry-After.
internal_error500Our fault. Try again.

Artists

The artists your account is a member of, with your role on each.

GET/api/v1/artistsread key · viewer
curl 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" }
  ]
}
GET/api/v1/artists/{artist}read key · viewer

Releases

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).

GET/api/v1/artists/{artist}/releasesread key · viewer
curl https://listenthru.com/api/v1/artists/8saidwait/releases \
  -H "Authorization: Bearer $LISTENTHRU_KEY"
GET/api/v1/artists/{artist}/releases/{slug}read key · viewer
curl 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": ""
  }
}
POST/api/v1/artists/{artist}/releasesread and write key · editor

From 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"}'
PATCH/api/v1/artists/{artist}/releases/{slug}read and write key · editor
curl -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).

GET/api/v1/artists/{artist}/insights?days=28&release={slug}read key · viewer
GET/api/v1/artists/{artist}/releases/{slug}/insights?days=28read key · viewer
curl "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.

GET/api/v1/artists/{artist}/fans?since={ISO time}read key · admin
curl "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.

EventWhen
release.createdA release page is made, in the dashboard or through the API.
release.liveRelease day: the Pre-save buttons switch to the release on each service.
fan.confirmedA 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.