# BTR Music — Distributor Integration Guide

BTR Music (btrmusic.com) is a DDEX delivery destination. You deliver **ERN 4.3.x** packages;
we return **DSR-style usage reports** monthly. Nothing bespoke: if your pipeline already
delivers to any modern DSP, it delivers to BTR.

Base URL: `https://partners.btrmusic.com` · API: `https://partners.btrmusic.com/api/v1`

## 1. Accounts and access

| Thing | How |
|---|---|
| Portal login | Set by the one-time link BTR (or your account owner) sends you |
| API key | Portal → **API keys** → Create. Shown once. Format `btrp_live_…` / `btrp_test_…` |
| Sandbox | New partners start in **sandbox** (onboarding): every delivery is validated and recorded exactly as live, but nothing is published and tracks cannot be submitted. Use Tracks → Staged to preview. Submission into human moderation requires a live account with an executed agreement |
| Auth header | `Authorization: Bearer btrp_live_…` (or `X-API-Key`) |
| Rate limits | 120 req/min per key, 20 deliveries/min per key. `429` with `Retry-After` when exceeded |

Scopes: `deliveries:write`, `catalog:read`, `catalog:write`, `takedown:write`, `reports:read`.

## 2. What to deliver

One **zip** per delivery containing:

```
<anything>.xml                 ERN 4.3.x NewReleaseMessage (or PurgeReleaseMessage)
resources/<file>.flac|wav|…    every audio file referenced by File/URI
resources/cover.jpg            front cover referenced by the Image resource
```

* `File/URI` is resolved relative to the XML's folder, then the zip root, then by suffix — so `resources/x.flac`, `./resources/x.flac` and `0634479123456/resources/x.flac` all work.
* One zip may contain several XML messages; each is processed independently but **any error in any message rejects the whole zip**. Nothing partial is written.
* Audio: WAV, FLAC, AIFF (lossless preferred) or MP3/M4A. Cover: JPEG/PNG, square, ≥600 px (1400+ recommended).
* Max zip 2 GB, max unzipped 4 GB.
* Supply `HashSum` on files (SHA-256, SHA-1 or MD5). We verify it; an audio mismatch rejects.
* Everything is extracted and checked before anything is written, so a rejected package leaves no trace.

### ERN versions

| Version | Result |
|---|---|
| 4.3.x | **Recommended.** Full support |
| 4.1 / 4.2 | Rejected for new deliveries; migrate the sender to 4.3.x |
| 3.8.x / 4.0 | **Rejected** with `ERN.VERSION`. DDEX retired the 3.x line (March 2025); it is not schema-compatible with 4.x |

Reference package: `ern_432_sample.xml`. Takedown: `purge_432_sample.xml`.

### Supported genres

Call `GET /api/v1/reference/genres` with your API key before building a release
editor or delivery. Send `genres[].name` in DDEX `Release/Genre/GenreText` and,
optionally, one value from that genre's `groups[].subgenres` in
`Release/Genre/SubGenre`. Group names are display headings only. The response is
versioned so it can be cached. A genre is **required** and must be a current BTR
Music genre — an unknown genre rejects the delivery (error `REL.GENRE`).
`SubGenre` is optional, but when supplied it must be valid for its genre or the
delivery is rejected (error `REL.SUBGENRE_UNSUPPORTED`).

## 3. Transport

**REST (preferred)**
```
POST /api/v1/deliveries
Content-Type: multipart/form-data;  file=@package.zip
   — or —
Content-Type: application/zip       (raw body, optional X-File-Name header)
→ 202 {"delivery":{"ref":"…","status":"received",…}}   Location: /api/v1/deliveries/{ref}
```
**Portal**: drag-and-drop on **Upload package** (same validation, same limits).

Delivery `ref` is your handle for everything after. `MessageId` is the idempotency key: a
message we have already processed is skipped with warning `ERN.DUPLICATE`, never applied twice.

## 4. Lifecycle

```
received → processing → staged           validated in private quarantine; awaiting BTR admin review
                      → needs_artist     parsed OK; map the artist, completes automatically
                      → rejected         validation failed; see errors[], fix, redeliver
                      → failed           our fault; retried automatically (3x), then visible for re-run
```
Release statuses: `needs_artist` → `review` → `pending` (BTR review) → `live`, plus `queued` · `publishing` · `publish_failed` during publication and `takedown` · `rejected`.

Poll `GET /api/v1/deliveries/{ref}` (or `?since=` on the list) or use a webhook (§7).
BTR's editorial review applies to every track on the platform; DSP tracks are not exempt but
are prioritised. A rejection by BTR review is shown as release status `rejected`.

## 5. Artists

Every track publishes under a real BTR artist account in `music_app.users`. The
artist creates their normal BTR account; the partner requests a connection with
just the artist's **BTR Music username and account email** — `POST /api/v1/artists`
`{"btr_username":"…","email":"…","rights_confirmed":true}` (name/country/bio/party_id
are optional and ignored; the matched account supplies them). A partner cannot
create, claim or self-assign an account — BTR admin approves the connection. The
account is matched directly by username (case-insensitive) and account email;
there is no separate bridge. Missing or empty `btr_username` or `email` returns
`400 bad_request` naming the field; `rights_confirmed` not `true` returns
`400 bad_request "rights_confirmed must be true"`. Only a username+email that
matches no live account, or an artist already actively connected to another
distributor, is rejected with `400 artist_create_failed` (e.g. "This artist is
already connected to another distributor. Contact contact@btrmusic.com."). A connection moves
`pending_admin_link` → `linked` (or `managed`); an admin can decline a request
(`declined`) or disconnect a live connection (`disconnected`).

The approved `btr_user_id` is exactly the artist's `music_app.users._id` ObjectId.
Assign it with `POST /api/v1/releases/{upc}/artist`
`{"btr_user_id":"<24-hex ObjectId>"}`. It is accepted only when already present in
the authenticated partner's admin-approved `artist_user_ids`. Unmapped releases
remain safely in `needs_artist`.

**PartyId in the ERN.** Carry the artist identity on the main-artist `Party`. The
required form for the BTR Artist ID is a BTR-namespaced proprietary id:

```xml
<PartyId><ProprietaryId Namespace="BTR">…btr_user_id…</ProprietaryId></PartyId>
```

This `PartyId` is the partner's stable artist key: once the connection is approved,
every future delivery for the same artist maps automatically. If you send several
`PartyId`s on the main-artist party, the `Namespace="BTR"` `ProprietaryId` is preferred;
otherwise the first `PartyId` is used. Before the account is known, deliver with your own
stable proprietary id in your namespace; the release parks in `needs_artist` until BTR
admin links it.

## 6. Updates and takedowns

| You send | We do |
|---|---|
| NewReleaseMessage, same UPC | Metadata and audio are updated and the whole release re-enters BTR review — tracks return to `review` and must be resubmitted for approval (a live release returns to review until re-approved); changed audio is re-staged by hash. ISRCs missing from the new message are taken down (warning `TRK.REMOVED`). Refused while any track on that release is still queued or awaiting moderation — wait for those decisions first |
| Deal `EndDate` in the past / `TakeDown=true` on every deal | Release taken down on receipt |
| Deal `EndDate` in the future | Honoured: taken down automatically the hour it passes |
| PurgeReleaseMessage | Whole release taken down on receipt |
| `POST /api/v1/releases/{upc}/takedown` or portal button | Immediate takedown, audited |

"Taken down" means every track is hidden from all BTR surfaces at once. Redelivering the UPC
with an active deal reinstates it through review.

## 7. Webhooks (optional)

Set an **https** URL in **Settings** (see the URL rules below). Seven events are sent:

| Event | When | `data` |
|---|---|---|
| `delivery.staged` | a delivery validated and was quarantined | `{delivery}` |
| `delivery.needs_artist` | parsed OK, artist not mapped yet | `{delivery}` |
| `delivery.rejected` | validation failed; nothing written | `{delivery}` |
| `delivery.failed` | internal error; retried automatically | `{delivery}` |
| `release.pending` | BTR staff published a staged release into moderation | `{catalog_id, status, tracks, at}` |
| `release.live` | a published release has a track approved and live | `{catalog_id, status, tracks, at}` |
| `release.takedown` | a release was taken down | `{upc, reason, at}` |

`delivery.received`/`delivery.processing` are **not** delivered as webhooks — poll for those.

```
POST <your url>
Content-Type: application/json
User-Agent: btr-partners-webhook/1
X-BTR-Event: delivery.staged
X-BTR-Timestamp: 1790131303                (unix seconds)
X-BTR-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + raw_body)>
{"id":"evt_…","type":"delivery.staged","created_at":"…","partner":"kentunez","data":{"delivery":{…}}}
```

Delivery is best-effort: **3 attempts**, backing off ~5 s then ~20 s. A non-2xx or
timeout counts as a failure. Treat webhooks as hints and the API as truth.

**Verify every webhook.** Recompute the signature over `timestamp + "." + raw_body`,
compare in constant time, reject timestamps outside a tolerance (±5 min recommended),
and deduplicate by event `id`.

```python
import hmac, hashlib, time
def verify(secret, timestamp, raw_body, signature, tolerance=300):
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest("sha256=" + mac.hexdigest(), signature)
```

```js
const crypto = require('crypto');
function verify(secret, timestamp, rawBody, signature, toleranceSec = 300) {
  if (Math.abs(Math.floor(Date.now()/1000) - Number(timestamp)) > toleranceSec) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Webhook URL rules.** The URL must use `https://` and must not point at (or resolve to) a
private, loopback, link-local or CGNAT address — the destination IP is re-checked at delivery
time, so DNS rebinding is defeated. Saving an invalid URL is rejected with, e.g.,
`webhook URL must use https://`, `webhook URL points at a private or loopback address`,
`webhook URL resolves to a private or loopback address`, or `webhook URL host could not be resolved`.

## 8. Usage reports

Monthly, per partner, generated on demand and frozen once a month closes (month-end +
14 days). Use a **closed** month — generating a month that is still open returns
`409 period_not_closed`.

```
POST /api/v1/reports/2026-08                  build (closed months only)
GET  /api/v1/reports/2026-08?format=tsv       DDEX-style flat file (see DSR_LAYOUT.md)
GET  /api/v1/reports/2026-08?format=csv       same data for spreadsheets
```
A usage is a qualified stream: 60 seconds of playback for a standard-length track (scaled for very short or very long tracks), counted once per stream; the report also carries the distinct-listener count.
Rows are ISRC × listener territory (ISO 3166-1 alpha-2; `ZZ` unknown). Reports are usage only;
settlement follows the commercial agreement.

## 9. Errors

Every error has a stable `code` (see CERTIFICATION_CHECKLIST.md), a human `message` and a
`where` pointer (file + XML path). Warnings never block. HTTP errors:

```json
{"error":{"code":"insufficient_scope","message":"this key lacks the takedown:write scope","details":{"scopes":["catalog:read"]}}}
```

HTTP `error.code` catalogue:

| HTTP | code | When |
|---|---|---|
| 400 | `bad_request` | Malformed JSON/multipart, no `file` field, or a missing/invalid required field (e.g. `btr_username`, `email`, `rights_confirmed`) |
| 400 | `upload_failed` | The zip could not be accepted |
| 400 | `bad_period` | Report period is not `YYYY-MM` |
| 400 | `assign_failed` | `btr_user_id` not approved, or no linked mapping |
| 400 | `artist_create_failed` | No matching account, or artist connected to another distributor |
| 401 | `unauthenticated` | Missing key, or not a `btrp_` key |
| 401 | `invalid_key` | Key is unknown or revoked |
| 401 | `expired_key` | Key has expired |
| 403 | `insufficient_scope` | Key lacks the required scope (`details.scopes`) |
| 403 | `sandbox_key` | A `btrp_test_` key was used on a live account |
| 403 | `partner_pending` / `partner_suspended` | Partner account is not active |
| 404 | `not_found` | Unknown endpoint, release, delivery or report |
| 409 | `period_not_closed` | Report month has not closed yet |
| 415 | `unsupported_media_type` | Body is not `multipart/form-data` or `application/zip` |
| 429 | `rate_limited` | Per-key rate limit exceeded (`Retry-After`) |
| 500 | `internal` | Unexpected server error |

## 10. Certification

1. Sandbox key → deliver `ern_432_sample.xml` package → `staged`.
2. Deliver your own single, EP and album → all `staged` (warnings reviewed).
3. Update: redeliver a release with changed metadata → `staged`, no duplicates.
4. Takedown: PurgeReleaseMessage → release `takedown`.
5. Negative test: deliver with a wrong ISRC checksum / missing file → `rejected` with the expected codes.
6. Generate a usage report for the current month.
7. Submit a non-test release for human approval from Tracks or POST /api/v1/releases/{upc}/submit.

## 11. Sandbox walkthrough

A first end-to-end run on a sandbox account — nothing reaches the public catalogue:

1. **Apply.** Submit `/apply`. BTR Music reviews and approves a sandbox account.
2. **Set your password.** The account owner gets a one-time link; set a password and sign in.
3. **Mint a sandbox key.** Portal → **API keys** → Create. Sandbox accounts issue `btrp_test_…` keys.
4. **Connect your artists.** Portal → **Artists**, or `POST /api/v1/artists` with `btr_username` + account `email` + `rights_confirmed`. BTR Music admin approves; the approved account's `_id` is your BTR Artist ID. Carry it in the ERN as the main-artist `PartyId` (`ProprietaryId Namespace="BTR"`).
5. **Deliver.** Upload `ern_432_sample.xml` with your audio's real SHA-256 and the connected BTR Artist ID — portal **Upload package** or `POST /api/v1/deliveries`.
6. **Staged.** A valid package validates in private quarantine and reports `staged`. On a sandbox account it also records a `SANDBOX` notice; the reference package produces no other warnings. An unmapped artist parks the delivery at `needs_artist` until you connect it.
7. **Takedown.** `POST /api/v1/releases/{upc}/takedown`, deliver a `PurgeReleaseMessage`, or use the portal button.
8. **Report.** Generate a closed month (`POST /api/v1/reports/{YYYY-MM}`) and download the TSV/CSV.

Staged / Submitted in Tracks is a view selector. Explicit submission requires a live (non-sandbox) partner with an executed agreement and an approved artist connection; a sandbox or not-yet-executed account cannot submit, and submission never auto-approves the music.

Support: technical contact is on your **Settings** page; BTR: contact@btrmusic.com.

## Track submission and feedback

Preview music privately in **Tracks → Staged**, select one or more tracks and
submit for approval. **Submit all ready tracks** queues the catalogue; the
release page provides the same controls for one release. **Tracks → Submitted** shows
submitted tracks, approvals, rejection reasons and processing issues.

API: `POST /api/v1/releases/{upc}/submit` with `catalog:write`. Body:
`{"resource_ids":["resource-id"]}` or `{}` for all ready tracks in the release.
HTTP 202 returns `queued`; a sandbox account, or one without an executed agreement, is refused with `403 partner_sandbox`. Poll the release's `tracks` for `status`,
`production_status`, `rejection_reason`, `processing_error` and `publish_error`.
Rejected music needs a corrected delivery with a new MessageId before resubmission.
