How the integration works
Use JSON over HTTPS at https://partners.btrmusic.com/api/v1. Upload a ZIP containing the DDEX ERN XML, audio and artwork. The API responds 202; validation runs asynchronously. Poll the delivery or listen for webhooks, then monitor the resulting release through BTR review and publication.
Apply, then create a sandbox key in the portal.
One package through the Partner API.
Read issues and connect an existing artist when required.
BTR reviews publication; your team handles updates, takedowns and reports.
Two different lifecycles
Delivery: package intake and validation.
Other delivery outcomes are needs_artist, rejected (no release written) and failed (inspect and retry).
Release: artist resolution, review and BTR-side publication.
Releases may also be queued, publishing, publish_failed, takedown or rejected. Their state is separate from delivery state.
Partnering
Everything a distributor, aggregator or label needs before applying — commercial terms, reporting, onboarding, artist identity, content rules, contacts and catalogue scope. No email required to get started.
1. Commercial model
BTR pays the partner royalties on qualified streams of partner-delivered catalogue, settled monthly in USD from the usage report. The rate, basis (per-stream or revenue share), payment terms, minimum threshold and any adjustments are set in your Order Form. There is no integration or delivery fee.
2. Usage reporting
Monthly, per partner, in USD, as a DSR flat file (DSR-FlatFile/BasicAudioProfile/BTR-1.0). A usage is a qualified stream: 60 seconds of playback for a standard-length track (scaled for very short or very long tracks), counted per stream. A month closes 14 days after month-end and is then final; pull it from the API or the portal. See DSR_LAYOUT.md and the sample report.
3. Onboarding and certification
Apply, and BTR approves a sandbox account: deliver, validate and preview, but not submit. Certify against the certification checklist with a representative release — an ERN 4.3.x package with full metadata, audio and artwork, valid ISRC/UPC and duplicate handling, territories and deal windows, a redelivery, a takedown and one connected artist. Once the Order Form is executed, BTR switches the account on, you mint a live key, and you deliver. There is no waiting period after certification.
4. Artist connections and needs_artist
Every release attaches to an artist's BTR Music account. Request a connection in the portal or with POST /api/v1/artists using the artist's BTR username and account email; BTR approves it. Map a release in the portal or with POST /api/v1/releases/{upc}/artist, and the delivery re-queues automatically — no redelivery needed. Carry the BTR user ID as the artist PartyId (ProprietaryId Namespace="BTR") to avoid the needs_artist hold. Artists without an account create one at beatstorapon.com. There are no proxy or managed identities.
5. Content integrity and artificial streaming
Only qualified streams are reported and settled. Every track passes BTR's content-integrity scan and human moderation before publication, and partner catalogue is subject to the same rights, abuse and repeat-infringer controls as direct uploads. BTR may hold or remove content and withhold settlement on usage under investigation. See the Partner Terms.
6. Contacts
Partnerships and technical: malcolm.fitzgerald@btrmusic.com. Operations and takedown escalation: contact@btrmusic.com. Takedowns are self-serve and automated (API, PurgeReleaseMessage, portal) — removal from all BTR Music surfaces normally within five minutes, 24/7. Escalations are acknowledged within one business day (AEST).
7. Catalogue scope
Full back catalogue plus new releases. No size cap and no new-releases-only restriction — deliver at your own pace after go-live.
Quickstart
Create a sandbox key in the portal, give it deliveries:write and catalog:read, and set it in your environment. Test keys begin btrp_test_; live keys begin btrp_live_. Sandbox is for onboarding — deliver, validate and preview; submitting tracks for approval requires a live account with an executed agreement.
export BTR_API_KEY='btrp_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'1. Confirm your account
GET /me tells you which partner and scopes the key belongs to, plus current limits and accepted ERN versions.
curl -sS -H "Authorization: Bearer $BTR_API_KEY" \
https://partners.btrmusic.com/api/v1/mepackage main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/me", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/me", headers={
"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/me", {
headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` }
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/me");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());{
"partner": {"slug":"your-slug","name":"Your Distribution","status":"active","sandbox":true,"dpid":"PADPIDA…"},
"key": {"name":"ci","prefix":"btrp_test_abc","scopes":["deliveries:write","catalog:read"],"sandbox":true},
"limits": {"max_upload_bytes":2147483648,"api_rate_per_min":120,"upload_rate_per_min":20},
"ern": {"accepted":["4.3.x"],"recommended":"4.3.2","rejected":["3.x","4.0","4.1","4.2"]}
}
2. Upload a release
Send the ERN ZIP in multipart field file. This needs deliveries:write. Expect 202 with a delivery ref and Location header.
curl -sS -X POST https://partners.btrmusic.com/api/v1/deliveries \
-H "Authorization: Bearer $BTR_API_KEY" \
-F file=@release.zippackage main
import ("fmt"; "io"; "mime/multipart"; "net/http"; "os")
func main() {
f, err := os.Open("release.zip"); if err != nil { panic(err) }; defer f.Close()
r, w := io.Pipe()
form := multipart.NewWriter(w)
go func() {
part, e := form.CreateFormFile("file", "release.zip")
if e == nil { _, e = io.Copy(part, f) }
if e == nil { e = form.Close() }
w.CloseWithError(e)
}()
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/deliveries", r)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
req.Header.Set("Content-Type", form.FormDataContentType())
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode != http.StatusAccepted { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
with open("release.zip", "rb") as package:
r = requests.post("https://partners.btrmusic.com/api/v1/deliveries",
headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]},
files={"file": ("release.zip", package, "application/zip")}, timeout=300)
r.raise_for_status()
print(r.json()) # delivery.refimport { openAsBlob } from "node:fs";
const form = new FormData();
form.set("file", await openAsBlob("release.zip"), "release.zip");
const res = await fetch("https://partners.btrmusic.com/api/v1/deliveries", {
method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` }, body: form
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var form = new MultipartFormDataContent();
using var file = File.OpenRead("release.zip");
form.Add(new StreamContent(file), "file", "release.zip");
var res = await http.PostAsync("https://partners.btrmusic.com/api/v1/deliveries", form);
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());{"delivery":{"ref":"0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b","source":"api","file_name":"release.zip","status":"received","created_at":"2026-09-25T02:14:00Z"}}
3. Check validation, then watch publication
Poll /deliveries/{ref} until it reaches staged, needs_artist, rejected or failed. If staged, read the release separately until it reaches live. A delivery can be accepted while the release still awaits review.
curl -sS https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Authentication & scopes
Every route except /health needs an API key. Send Authorization: Bearer <key> or X-API-Key: <key>. Use HTTPS only; keys are stored as SHA-256 hashes. The full key appears once at creation; the portal then shows only its 15-character prefix.
Authorization: Bearer btrp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Alternative: X-API-Key: btrp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKey formats are btrp_live_<40> and btrp_test_<40>. Create, scope and revoke keys in the portal. To rotate a key, create a new one, deploy it, then revoke the old one; there is no rotate endpoint or key IP allowlist.
| Scope | Permission |
|---|---|
deliveries:write | Upload deliveries. |
catalog:read | Read deliveries, releases and artists. |
catalog:write | Request artist connections and map release artists. |
takedown:write | Take a release down. |
reports:read | Generate, list and download reports. |
/me and /reference/genres need any valid key. A 401 means the key is missing, unknown, revoked or expired; a 403 means a valid key is not permitted by scope, account state or environment.
Conventions
| Topic | Rule |
|---|---|
| Format | JSON UTF-8 for API objects; UTC ISO 8601 timestamps; territories use ISO 3166-1 alpha-2 or Worldwide. |
| ISRC | 12 characters without dashes on the wire; format CC-XXX-YY-NNNNN. |
| Release identifier | UPC/EAN (ICPN), 12–14 digits; GRid is accepted as an alternative. {upc} routes also accept the release id. |
| Artist identifier | btr_user_id is a 24-character hex music_app.users._id. |
| Pagination | Offset-based limit and offset. Deliveries: 50 default, 200 max. Releases: 100 default, 500 max. Artists and reports are not paginated. |
| Rate limits | 120 API requests and 20 uploads per minute per key. GET /me shows your actual limits. A 429 includes Retry-After in seconds; no X-RateLimit-* headers. |
Idempotency & retry
There is no Idempotency-Key header. The ERN MessageId identifies a delivery; re-sending an accepted message is skipped with an ERN.DUPLICATE warning. Use a new MessageId for corrected content. Takedown and artist mapping are idempotent. Back off on 429 and 5xx; inspect asynchronous delivery errors before resubmitting.
Errors
Failing calls use one JSON envelope. Branch on error.code, not the message. Some errors include details. There is no request-id response header; give support the route, UTC time and key prefix, never your full key.
{"error":{"code":"insufficient_scope","message":"this key lacks the catalog:read scope","details":{"scopes":["reports:read"]}}}
Statuses: 200 success; 201 artist request; 202 accepted delivery; 400 bad request; 401 authentication; 403 permission; 404 not found; 409 conflict; 415 unsupported media type; 429 rate limit; 5xx server. Oversized uploads currently return 400 upload_failed; 412, 413 and 422 are not used.
| HTTP | Error code | Meaning | Retry? |
|---|---|---|---|
| 400 | bad_request | Malformed body or invalid/missing field. | After correction |
| 400 | upload_failed | Unreadable upload or size cap exceeded. | After correction |
| 400 | bad_period | Invalid YYYY-MM period. | After correction |
| 400 | assign_failed | Artist mapping failed. | After correction |
| 400 | artist_create_failed | Account mismatch or artist connected elsewhere. | After correction |
| 401 | unauthenticated, invalid_key, expired_key | Missing/malformed, unknown/revoked or expired key. | After fixing key |
| 403 | insufficient_scope, sandbox_key | Wrong scope or environment. | After fixing access |
| 403 | partner_pending, partner_suspended | Partner not active. | After account review |
| 403 | partner_sandbox | Partner is in onboarding or has no executed agreement; submissions are not accepted. | After account review |
| 404 | not_found | Unknown route, delivery, release or report. | No |
| 409 | period_not_closed | Reporting period still open. | After close |
| 415 | unsupported_media_type | Unsupported upload Content-Type. | After correction |
| 429 | rate_limited | Limit exceeded. | Use Retry-After |
| 500 | internal | Unexpected server error. | Back off |
Deliveries
One delivery is one ERN package. 202 means accepted for processing, not accepted for publication.
Create a delivery
/deliveriesdeliveries:writeSend multipart field file or a raw application/zip body with optional X-File-Name. The examples use both supported upload methods. The account's max_upload_bytes defaults to 2 GB. Response 202 includes delivery and a Location header.
curl -sS -X POST https://partners.btrmusic.com/api/v1/deliveries \
-H "Authorization: Bearer $BTR_API_KEY" -F file=@release.zip
# Raw body alternative:
curl -sS -X POST https://partners.btrmusic.com/api/v1/deliveries \
-H "Authorization: Bearer $BTR_API_KEY" \
-H "Content-Type: application/zip" -H "X-File-Name: release.zip" \
--data-binary @release.zippackage main
import ("fmt"; "io"; "net/http"; "os")
func main() {
f, err := os.Open("release.zip"); if err != nil { panic(err) }; defer f.Close()
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/deliveries", f)
if err != nil { panic(err) }
req.Header.Set("Content-Type", "application/zip")
req.Header.Set("X-File-Name", "release.zip")
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode != 202 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
with open("release.zip", "rb") as package:
r = requests.post("https://partners.btrmusic.com/api/v1/deliveries", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, files={"file": ("release.zip", package, "application/zip")}, timeout=300)
r.raise_for_status()
print(r.json())import { openAsBlob } from "node:fs";
const form = new FormData();
form.set("file", await openAsBlob("release.zip"), "release.zip");
const res = await fetch("https://partners.btrmusic.com/api/v1/deliveries", {
method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` }, body: form
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var file = File.OpenRead("release.zip");
using var content = new StreamContent(file);
content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
using var req = new HttpRequestMessage(HttpMethod.Post, "https://partners.btrmusic.com/api/v1/deliveries") { Content = content };
req.Headers.TryAddWithoutValidation("X-File-Name", "release.zip");
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Retrieve a delivery
/deliveries/{ref}catalog:readReturns delivery and resulting releases. Issues are arrays of {code, message, where}; see delivery issue codes.
curl -sS https://partners.btrmusic.com/api/v1/deliveries/{ref} \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/deliveries/0a9e1f2c-4b6d-4e8a-9f10-1c2d3e4f5a6b");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());{
"delivery": {
"ref": "0a9e1f2c-…",
"status": "staged",
"source": "api",
"message_types": ["NewReleaseMessage"],
"ern_version": "4.3",
"errors": [],
"warnings": [{
"code": "REL.COVER_SIZE",
"message": "1400x1400 cover or larger is recommended",
"where": "Release[0634479384622]/ResourceGroup"
}],
"processed_at": "2026-09-25T02:14:03Z"
},
"releases": [{
"upc": "0634479384622",
"title": "Night Signals",
"status": "review"
}]
}
List deliveries
/deliveriescatalog:readFilters: status, since (RFC 3339; updated_at >=). limit defaults to 50, max 200; offset defaults to 0. Response includes total, limit and offset.
curl -sS "https://partners.btrmusic.com/api/v1/deliveries?status=rejected&limit=50" \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/deliveries?status=rejected&limit=50", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/deliveries?status=rejected&limit=50", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/deliveries?status=rejected&limit=50", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/deliveries?status=rejected&limit=50");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Releases
Releases come from delivered NewReleaseMessages. Read status, map an existing artist, or issue a takedown. Partners submit tracks for review; only BTR humans approve them.
Retrieve a release
/releases/{upc}catalog:read{upc} accepts UPC/ICPN or release id. Live tracks include a btr_url; mapped releases include artist_url.
curl -sS https://partners.btrmusic.com/api/v1/releases/0634479384622 \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/releases/0634479384622", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/releases/0634479384622", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/releases/0634479384622", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/releases/0634479384622");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());List releases
/releasescatalog:readFilters: status, upc, since. Pagination: limit defaults to 100, max 500; offset defaults to 0.
curl -sS "https://partners.btrmusic.com/api/v1/releases?status=live&limit=100" \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/releases?status=live&limit=100", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/releases?status=live&limit=100", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/releases?status=live&limit=100", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/releases?status=live&limit=100");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Map a release's artist
/releases/{upc}/artistcatalog:writeResolve needs_artist using the 24-character hex btr_user_id of an existing BTR account already approved and linked to your partner. The delivery re-queues; poll the ref in the response note.
curl -sS -X POST https://partners.btrmusic.com/api/v1/releases/0634479384622/artist \
-H "Authorization: Bearer $BTR_API_KEY" -H "Content-Type: application/json" \
-d '{"btr_user_id":"6512a1b2c3d4e5f60718293a"}'package main
import ("fmt"; "io"; "net/http"; "os"; "strings")
func main() {
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/releases/0634479384622/artist", strings.NewReader(`{"btr_user_id":"6512a1b2c3d4e5f60718293a"}`))
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.post("https://partners.btrmusic.com/api/v1/releases/0634479384622/artist", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, json={"btr_user_id":"6512a1b2c3d4e5f60718293a"}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/releases/0634479384622/artist", { method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({"btr_user_id":"6512a1b2c3d4e5f60718293a"}) });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.PostAsync("https://partners.btrmusic.com/api/v1/releases/0634479384622/artist", new StringContent("{\"btr_user_id\":\"6512a1b2c3d4e5f60718293a\"}", System.Text.Encoding.UTF8, "application/json"));
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Take down a release
/releases/{upc}/takedowntakedown:writeHides every track of the release on BTR Music and marks it takedown. Idempotent. reason is optional but recommended. A PurgeReleaseMessage has the same effect.
curl -sS -X POST https://partners.btrmusic.com/api/v1/releases/0634479384622/takedown \
-H "Authorization: Bearer $BTR_API_KEY" -H "Content-Type: application/json" \
-d '{"reason":"Rights reverted by the licensor"}'package main
import ("fmt"; "io"; "net/http"; "os"; "strings")
func main() {
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/releases/0634479384622/takedown", strings.NewReader(`{"reason":"Rights reverted by the licensor"}`))
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.post("https://partners.btrmusic.com/api/v1/releases/0634479384622/takedown", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, json={"reason":"Rights reverted by the licensor"}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/releases/0634479384622/takedown", { method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({"reason":"Rights reverted by the licensor"}) });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.PostAsync("https://partners.btrmusic.com/api/v1/releases/0634479384622/takedown", new StringContent("{\"reason\":\"Rights reverted by the licensor\"}", System.Text.Encoding.UTF8, "application/json"));
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Submit tracks for approval
/releases/{upc}/submitcatalog:writeSend staged tracks into BTR's human moderation queue. Body {"resource_ids":["…"]} submits those resources; omit it or send {} for every eligible track in the release. Returns 202 with {"queued":n,"release_id":"…"}; existing queued/pending/approved versions are skipped. Only a live partner with an executed agreement may submit — a sandbox or not-yet-executed account gets 403 partner_sandbox. A release whose tracks are already queued or awaiting moderation cannot be redelivered until those decisions finish. Poll GET /releases/{upc} for each track's status, production_status, rejection_reason and processing_error.
curl -sS -X POST https://partners.btrmusic.com/api/v1/releases/0634479384622/submit \
-H "Authorization: Bearer $BTR_API_KEY" -H "Content-Type: application/json" \
-d '{"resource_ids":["6512a1b2c3d4e5f60718293a"]}'package main
import ("fmt"; "io"; "net/http"; "os"; "strings")
func main() {
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/releases/0634479384622/submit", strings.NewReader(`{"resource_ids":["6512a1b2c3d4e5f60718293a"]}`))
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.post("https://partners.btrmusic.com/api/v1/releases/0634479384622/submit", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, json={"resource_ids":["6512a1b2c3d4e5f60718293a"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/releases/0634479384622/submit", { method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({"resource_ids":["6512a1b2c3d4e5f60718293a"]}) });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.PostAsync("https://partners.btrmusic.com/api/v1/releases/0634479384622/submit", new StringContent("{\"resource_ids\":[\"6512a1b2c3d4e5f60718293a\"]}", System.Text.Encoding.UTF8, "application/json"));
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Artists
Connect catalogue to an existing BTR Music account. An artist connection request requires BTR admin approval; this API does not create accounts.
Request a connection
/artistscatalog:writeSupply the artist's BTR username, the email on that account and confirmation of rights. Missing or empty btr_username or email returns 400 bad_request naming the field; rights_confirmed absent or not true returns 400 bad_request with "rights_confirmed must be true". Returns 201 for a pending request. name, country, bio and party_id are accepted but ignored; account details come from the matched BTR account. Only an account mismatch or an artist connected elsewhere returns 400 artist_create_failed.
| Field | Type | Required |
|---|---|---|
btr_username | string | Yes |
email | string | Yes |
rights_confirmed | boolean, true | Yes |
curl -sS -X POST https://partners.btrmusic.com/api/v1/artists \
-H "Authorization: Bearer $BTR_API_KEY" -H "Content-Type: application/json" \
-d '{"btr_username":"nova-lang","email":"artist@example.com","rights_confirmed":true}'package main
import ("fmt"; "io"; "net/http"; "os"; "strings")
func main() {
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/artists", strings.NewReader(`{"btr_username":"nova-lang","email":"artist@example.com","rights_confirmed":true}`))
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.post("https://partners.btrmusic.com/api/v1/artists", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, json={"btr_username":"nova-lang","email":"artist@example.com","rights_confirmed":true}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/artists", { method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({"btr_username":"nova-lang","email":"artist@example.com","rights_confirmed":true}) });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.PostAsync("https://partners.btrmusic.com/api/v1/artists", new StringContent("{\"btr_username\":\"nova-lang\",\"email\":\"artist@example.com\",\"rights_confirmed\":true}", System.Text.Encoding.UTF8, "application/json"));
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());List linked artists
/artistscatalog:readcurl -sS https://partners.btrmusic.com/api/v1/artists \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/artists", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/artists", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/artists", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/artists");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Reports
Monthly qualified usage as a DSR flat file. A month closes 14 days after month-end. Profile: DSR-FlatFile/BasicAudioProfile/BTR-1.0. Files: BTR_<SLUG>_<YYYY-MM>.tsv or CSV.
Generate or retrieve a finalized report
/reports/{period}reports:read{period} must be a real YYYY-MM month. Open periods return 409 period_not_closed. A finalized period returns its existing report.
curl -sS -X POST https://partners.btrmusic.com/api/v1/reports/2026-08 \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodPost, "https://partners.btrmusic.com/api/v1/reports/2026-08", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.post("https://partners.btrmusic.com/api/v1/reports/2026-08", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/reports/2026-08", { method: "POST", headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.PostAsync("https://partners.btrmusic.com/api/v1/reports/2026-08", null);
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Download a report
/reports/{period}reports:readDefault is the tab-separated DSR flat file. ?format=csv contains period, isrc, upc, track_title, artist, release_title, territory, qualified_streams, unique_listeners, btr_track_id.
curl -sS "https://partners.btrmusic.com/api/v1/reports/2026-08?format=csv" \
-H "Authorization: Bearer $BTR_API_KEY" -o usage.csvpackage main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/reports/2026-08?format=csv", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status); os.Exit(1) }
out, err := os.Create("usage.csv"); if err != nil { panic(err) }; defer out.Close()
if _, err := io.Copy(out, res.Body); err != nil { panic(err) }
}import os
import requests
with requests.get("https://partners.btrmusic.com/api/v1/reports/2026-08?format=csv", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, stream=True, timeout=60) as r:
r.raise_for_status()
with open("usage.csv", "wb") as output:
for chunk in r.iter_content(chunk_size=65536):
output.write(chunk)import { writeFile } from "node:fs/promises";
const res = await fetch("https://partners.btrmusic.com/api/v1/reports/2026-08?format=csv", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
await writeFile("usage.csv", Buffer.from(await res.arrayBuffer()));using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/reports/2026-08?format=csv");
res.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync("usage.csv", await res.Content.ReadAsByteArrayAsync());List reports
/reportsreports:readcurl -sS https://partners.btrmusic.com/api/v1/reports \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/reports", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/reports", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/reports", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/reports");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Genre taxonomy
/reference/genresany valid keyThe versioned taxonomy is cacheable for one hour. Send a current genre name as DDEX GenreText, and optionally a value from its subgenre groups as SubGenre. A missing/retired genre triggers REL.GENRE; an invalid subgenre triggers REL.SUBGENRE_UNSUPPORTED.
curl -sS https://partners.btrmusic.com/api/v1/reference/genres \
-H "Authorization: Bearer $BTR_API_KEY"package main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/reference/genres", nil)
if err != nil { panic(err) }
req.Header.Set("Authorization", "Bearer "+os.Getenv("BTR_API_KEY"))
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import os
import requests
r = requests.get("https://partners.btrmusic.com/api/v1/reference/genres", headers={"Authorization": "Bearer " + os.environ["BTR_API_KEY"]}, timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/reference/genres", { headers: { Authorization: `Bearer ${process.env.BTR_API_KEY}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("BTR_API_KEY"));
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/reference/genres");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());Me & health
Key and partner information
/meany valid keyPartner, key prefix and scopes, current limits and ERN support. See the quickstart response.
Liveness
/healthpublicUnauthenticated. Returns 200 when the database is reachable, 503 otherwise.
curl -sS https://partners.btrmusic.com/api/v1/healthpackage main
import ("fmt"; "io"; "net/http"; "os")
func main() {
req, err := http.NewRequest(http.MethodGet, "https://partners.btrmusic.com/api/v1/health", nil)
if err != nil { panic(err) }
res, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer res.Body.Close()
body, err := io.ReadAll(res.Body); if err != nil { panic(err) }
if res.StatusCode >= 300 { fmt.Fprintln(os.Stderr, res.Status, string(body)); os.Exit(1) }
fmt.Println(string(body))
}import requests
r = requests.get("https://partners.btrmusic.com/api/v1/health", timeout=30)
r.raise_for_status()
print(r.json())const res = await fetch("https://partners.btrmusic.com/api/v1/health", { headers: { } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
console.log(await res.json());using System.Net.Http;
using System.Net.Http.Headers;
using var http = new HttpClient();
using var res = await http.GetAsync("https://partners.btrmusic.com/api/v1/health");
res.EnsureSuccessStatusCode();
Console.WriteLine(await res.Content.ReadAsStringAsync());{"ok":true,"service":"btr-partners","time":"2026-09-25T02:14:00Z"}
Delivery issue codes
Validation results arrive after the initial 202. Issues have code, message and where (XML path or filename). An error rejects the entire package; a warning does not. For a rejected package, fix and redeliver with a new MessageId.
| Prefix | Codes (suffixes) |
|---|---|
PKG | OPEN · EXPIRED · NO_XML · READ · MISSING_AUDIO · MISSING_COVER · AUDIO_HASH · COVER_HASH |
ERN | PARSE · VERSION · MESSAGE_ID · MESSAGE_ID_DUP · DUPLICATE (warning) · SENDER (warning) · NO_RELEASE · PURGE_EMPTY · PURGE_ID · XSD |
REL | ID · UPC_FORMAT · GRID_FORMAT · ID_DUP · TITLE · ARTIST · PLINE (warning) · CLINE (warning) · COVER · COVER_HASH · COVER_QUALITY · COVER_SIZE (warning) · GENRE · SUBGENRE_UNSUPPORTED · NO_TRACKS |
DEAL | NONE · USETYPE · TERRITORY · TERRITORY_FORMAT · RANGE · START (warning) · INACTIVE (warning) |
TRK | ISRC · ISRC_FORMAT · ISRC_DUP · TITLE · ARTIST · AUDIO · AUDIO_FORMAT · AUDIO_HASH · DURATION (warning) · REMOVED (warning) |
| Other | SANDBOX (warning) · PURGE.UNKNOWN (warning) · PURGE.APPLY · SYS.* (internal; retry) |
REL.GENRE / REL.SUBGENRE_UNSUPPORTED: use current reference data. TRK.AUDIO_HASH: add a SHA-256 HashSum to every audio file. DEAL.USETYPE: grant OnDemandStream. PKG.MISSING_AUDIO / PKG.MISSING_COVER: check that the URI resolves inside the ZIP.Webhooks
Set an HTTPS callback in the portal. BTR sends signed JSON when delivery or release states change. Webhooks are best-effort: persist events before returning 2xx and keep polling as a fallback.
Event types
delivery.staged · delivery.needs_artist · delivery.rejected · delivery.failed · release.pending · release.live · release.takedown
Payload and headers
POST https://your-domain.example/webhooks/btr
X-BTR-Event: release.live
X-BTR-Timestamp: 1790302440
X-BTR-Signature: sha256=…
{"id":"e3f1c2a4-…","type":"release.live","created_at":"2026-09-25T02:14:00Z","partner":"your-slug","data":{…}}
Verify the HMAC-SHA256 signature over the exact bytes timestamp + "." + raw_request_body. Compare in constant time, reject timestamps more than five minutes old, then deduplicate by payload id. Never re-serialize JSON before checking the signature.
# curl can send a test webhook, but signature verification belongs in your server.
# Select Go, Python, Node.js or C# above for a verification example.// Imports: crypto/hmac, crypto/sha256, encoding/hex, time, strconv.
func verify(secret, ts, sig string, body []byte) bool {
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || dabs(time.Now().Unix()-n) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + ".")); mac.Write(body)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(sig))
}
func dabs(n int64) int64 { if n < 0 { return -n }; return n }import hashlib, hmac, time
def verify(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
try:
if abs(time.time() - int(timestamp)) > 300: return False
except ValueError:
return False
msg = timestamp.encode() + b"." + raw_body
want = "sha256=" + hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, signature)import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, timestamp, signature, rawBody) {
const ts = Number(timestamp);
if (!Number.isSafeInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret)
.update(timestamp + ".").update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}// Imports: System.Security.Cryptography, System.Text, System.Linq.
static bool Verify(string secret, string timestamp, string signature, byte[] rawBody) {
if (!long.TryParse(timestamp, out var ts) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var message = Encoding.UTF8.GetBytes(timestamp + ".").Concat(rawBody).ToArray();
var want = "sha256=" + Convert.ToHexString(mac.ComputeHash(message)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(want), Encoding.UTF8.GetBytes(signature));
}BTR makes three best-effort attempts with increasing backoff (approximately 5 seconds, then 20 seconds). Events are not ordered; there is no manual replay. Use the event id to deduplicate.
Data model
Delivery
| Field | Type | Meaning |
|---|---|---|
ref | UUID string | Public delivery reference. |
source | string | api or portal. |
status | string | received, processing, needs_artist, staged, rejected, failed. |
message_types | string[] | NewReleaseMessage or PurgeReleaseMessage. |
ern_version | string | e.g. 4.3. |
errors, warnings | Issue[] | {code, message, where}. |
upcs, release_ids | string[] | Affected releases. |
file_name | string | Uploaded package name. |
file_size | integer | Package size in bytes. |
file_sha256 | string | SHA-256 of the package. |
created_by | string | Key or user that delivered. |
attempts | integer | Processing attempts. |
processed_at | date-time | When processing finished. |
Release
| Field | Type | Meaning |
|---|---|---|
upc, grid | string | Release identifiers. |
title, display_artist, label | string | Display metadata. |
p_line, c_line | string | Copyright lines. |
genre, sub_genre | string | Current taxonomy. |
explicit | boolean | Advisory flag. |
release_date | string | YYYY-MM-DD. |
deals | Deal[] | Commercial terms and territories. |
id | string | Release ObjectId. |
release_type | string | Single, EP or Album. |
track_count | integer | Number of tracks. |
cover_url | string | Cover image URL, present when published. |
status | string | needs_artist, review, queued, publishing, publish_failed, pending, live, takedown, rejected. |
btr_user_id, btr_username | string | Mapped artist account. |
takedown_reason | string | Reason, when taken down. |
taken_down_at | date-time | When taken down. |
created_at, updated_at | date-time | Timestamps. |
Resource (track)
| Field | Type | Meaning |
|---|---|---|
isrc | string | Recording identifier. |
sequence | integer | Track order. |
title, display_artist | string | Display metadata. |
duration_sec | number | Duration. |
track_id, btr_url | string | Present when live. |
status | string | review, queued, publishing, publish_failed, pending, live, takedown, rejected. |
production_status | string | Moderation / publish sub-state. |
rejection_reason | string | Set when a track is rejected. |
processing_error | string | Set when publication failed. |
Deal
| Field | Type | Meaning |
|---|---|---|
commercial_model | string | Commercial model. |
use_types | string[] | Must include OnDemandStream or Stream. |
territories, excluded_territories | string[] | ISO alpha-2 or Worldwide. |
start, end | date-time | Validity window. |
Artist
| Field | Type | Meaning |
|---|---|---|
name, country | string | Country is ISO alpha-3. |
identity_status | string | pending_admin_link, linked, managed, declined, disconnected. |
btr_user_id, btr_username | string | Linked BTR account. |
profile_url | string | Artist page URL. |
Report
| Field | Type | Meaning |
|---|---|---|
period | string | YYYY-MM. |
profile | string | DSR-FlatFile/BasicAudioProfile/BTR-1.0. |
rows, usages, territories | integer | Report counts. |
file_name | string | BTR_<SLUG>_<YYYY-MM>.tsv. |
Versioning
The API version is in its path: /api/v1. New optional fields, endpoints, enum values and parameters may be added without changing that path, so clients should ignore unknown fields. Breaking changes use a new version path; the previous path continues during an announced transition. There are no sunset headers today.