Skip to main content

Transfer Service

The Transfer Service moves a user's music library from the service they already use into the service you run. It is a hosted, white-label flow: your users open a page, log in to both sides, choose what to move and watch it happen. You never write the wizard, the matching engine or the retry logic.

You configure and observe the whole thing from MusicAPI Dashboard. Your backend learns what happened through webhooks.

The Hosted Flow

Send your user to your transfer entry URL:

https://app.musicapi.com/[your-account-slug]/transfer

If you set up a custom domain, the same page answers on your own host, and your user never sees ours.

The flow has five steps:

  1. Choose a source - the user picks the music service they are leaving. The list holds every music integration you configured, minus your destination service and anything you marked private.
  2. Connect both accounts - the user logs in to the source, then to your destination service. Both legs use the same Music SSO described in User Authentication, with your own API keys and your own consent screen.
  3. Pick the content - playlists, liked (followed) playlists, liked songs, albums, artists and podcasts, limited to the entity types you turned on for that source.
  4. Confirm - the user reviews the selection and starts the transfer.
  5. Progress and results - a live progress page, then a results page with match rates, unmatched items and a CSV export. If the source service gave us the user's address, we also mail them the results when the transfer finishes.

Every page in the flow is anonymous. The session lives in a short-lived token in the visitor's browser, so a user can start a transfer without holding an account with you first.

Partner Configuration

The transfer service is turned on per account. Until we enable it for yours, the transfer pages stay hidden in the dashboard and the hosted flow refuses to start. Ask us to turn it on before you configure anything below.

Transfer Destination (required)

The Transfer Branding page (in the account dropdown) holds a single Transfer destination: the service every transfer moves into. It is the one setting the hosted flow cannot run without. Until you pick one, the entry URL opens no session and every visitor gets an error.

Entity Types

Each music integration has an Entity types offered to users setting. Your users see only the kinds of content you turn on. Anything the source service's own API cannot expose stays off and says so.

Transfer Branding

The Transfer Branding page skins the hosted pages with your logo and a set of theme tokens: light or dark mode, accent, background and surface colours, button radius and a heading font. A custom CSS box is appended to every hosted page. Markup is refused there, so no tag can be opened.

A live preview on the same page shows the result before you publish it.

Custom Domain

Point a hostname of yours at our app with a CNAME record:

transfer.example.com.  CNAME  app.musicapi.com.

Then set the same hostname as the Custom domain in the dashboard.

The host replaces the slug we trust, but it does not replace the path. The page still lives under the slug segment, so your entry URL becomes:

https://transfer.example.com/[your-account-slug]/transfer

The root of your domain is not a transfer entry point. https://transfer.example.com/ serves nothing — link your users to the full path.

Your custom domain is accepted automatically

The music services redirect the user back to the host he started on. Your custom domain counts as an accepted Return URL on its own, so you do not have to add it again on the Return URLs list. Any other host you redirect to still needs an entry there.

Webhooks

Webhooks are how your backend follows a transfer. In the top navigation, open API & webhooks (/admin/account/apiAccess), add your endpoint, tick the events you want and copy the signing secret. The secret is shown once, at creation.

One endpoint per account. Editing its URL moves every event you have subscribed to; deleting it stops them all at once, along with its delivery log.

Events

EventWhen it fires
transfer.createdThe transfer started and the total item count is known.
transfer.progressProgress crossed a 10% boundary. At most ten per transfer.
transfer.completedThe transfer finished. Carries the full summary.
transfer.failedThe transfer stopped and cannot continue.
transfer.item_failedOne batch of items could not be written, reported per group run, up to 50 items.
user.authorizedA user attached an account to one side of a session.
user.auth_failedA running transfer lost an account and needs the user to log in again.
match.report_readyThe match report of a finished transfer is ready.

Envelope

Every delivery is a POST with the same envelope:

transfer.completed
{
"event": "transfer.completed",
"createdAt": "2026-08-13T10:15:42.101Z",
"data": {
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"status": "completedWithErrors",
"totalItems": 1204,
"processedItems": 1204,
"matchedItems": 1131,
"unmatchedItems": 31,
"skippedItems": 42,
"failedItems": 0,
"sourceIntegrationType": "spotify",
"targetIntegrationType": "tidal"
}
}

A transfer that reached the end reports its status as completed, completedWithErrors or failed. The example above is the middle case: the user got their library, 31 songs found no match, and the event is still transfer.completed. Read the status rather than the event name alone.

matchedItems counts what we ADDED and skippedItems what the destination already had — a re-run of a finished transfer reports almost everything as skipped and almost nothing as matched, having changed nothing. Both are on the destination, so "how much of the library arrived" is the two added together; matchedItems alone is "how much of it we put there".

transfer.failed comes in two shapes. A transfer that ran to the end and failed on its counters carries the same summary as above. A transfer that a job could not finish at all carries the reason instead, and no status field:

transfer.failed after an infrastructure error
{
"event": "transfer.failed",
"createdAt": "2026-08-13T10:04:11.552Z",
"data": {
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"stage": "reading the source library",
"error": "Request timed out",
"errorType": "FetchError"
}
}

Treat transfer.failed as terminal in both shapes, and read data.status only when it is there.

errorType carries a stable value for the causes worth branching on, and the thrown error's class name for everything else. Branch on the stable ones only:

errorTypeWhat happened
rateLimitedA music service asked us to slow down more often than the transfer's allowance permits. Nothing is wrong with the request; the same transfer would work later.
notAuthenticatedAn account was logged out and re-authentication did not recover it. The visitor has to connect it again.
reauthRequired:source / reauthRequired:targetNot a failure — the status is waitingForReauth and the named side needs a fresh login. The transfer keeps everything it has already moved.
anything elseThe class name of whatever was thrown (FetchError, TypeError, …). Useful in a bug report, not something to branch on: it changes when we change a library.

What each event carries

Every data shape, in one place. Fields not listed here are not sent — read defensively, but do not wait for a field that never comes.

transfer.created
{
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"sourceIntegrationType": "spotify",
"targetIntegrationType": "tidal",
"totalItems": 1204
}

Fired once the source library has been read, so totalItems is a real number rather than an estimate. It is the first event of a transfer.

transfer.progress
{
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"processedItems": 602,
"totalItems": 1204,
"pct": 50
}

Sent when progress crosses a 10% boundary, so at most ten per transfer however large the library. A transfer that finishes in one batch may send none at all.

transfer.item_failed
{
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"groupName": "Road trip",
"groupType": "playlist",
"newlyFailedCount": 3,
"items": [{ "name": "Song name", "artist": "The Band", "error": "Not available in your region" }]
}

Batched per group run rather than per item: a playlist the destination refuses wholesale would otherwise be one delivery per track. items carries at most 50 of them; newlyFailedCount is the real total for that run. groupName and groupType are null for a group that no longer exists.

match.report_ready
{
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"matchedItems": 1173,
"unmatchedItems": 31,
"failedItems": 0
}

Sent immediately after the terminal transfer.completed or transfer.failed, to say the unmatched-items report is queryable. It repeats the three counters so a listener that only cares about the report needs no second call.

It follows a transfer that RAN to the end. A transfer that failed on infrastructure — the transfer.failed with stage and errorType rather than a summary — has no report behind it and no match.report_ready after it. Do not wait for one before you close out a transfer.

user.authorized
{
"sessionUuid": "0f1e2d3c-4b5a-4968-8776-655443322110",
"role": "source",
"integrationType": "spotify",
"integrationUserUuid": "9c8b7a65-4321-4f0e-9d8c-7b6a59483726"
}

The only event that is about a SESSION rather than a transfer, and the only one without a transferUuid — it fires while the visitor is still connecting accounts, before a transfer exists. role is source or target. integrationType is null if the side was detached.

user.auth_failed
{
"transferUuid": "6a002d4b-f95b-471f-a27d-609a42e10cdb",
"side": "target",
"reason": "The target account needs re-authentication"
}

A RUNNING transfer lost one of its accounts and is now waiting. It is not terminal: the transfer resumes where it stopped once the user logs in again, so do not treat it as a failure. side is source or target.

Three headers come with each delivery:

  • Content-Type: application/json
  • X-MusicAPI-Event: the event name, so you can route before you parse
  • X-MusicAPI-Signature: the signature, described below

Signature Verification

The signature header holds a timestamp and a hash:

X-MusicAPI-Signature: t=1786176942,v1=8f4c1e...

v1 is the HMAC-SHA256 of the string <t>.<raw request body>, keyed with your endpoint's signing secret and printed as hex. The construction is the same one Stripe uses, so an existing verifier needs only a new header name.

Sign the raw body. A body that was parsed and re-serialized no longer matches.

Verifying a delivery in Node
const crypto = require('crypto');

const verifyMusicApiSignature = (header, rawBody, secret) => {
if (typeof header !== 'string') {
return false;
}

const parts = {};
for (const part of header.split(',')) {
const index = part.indexOf('=');
if (index > 0) {
parts[part.slice(0, index).trim()] = part.slice(index + 1).trim();
}
}

const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false; // missing, unreadable or too old: refuse a replayed delivery
}

const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');

// timingSafeEqual throws on a length mismatch, and Buffer.from(undefined)
// throws outright, so a hostile header must never reach it. Compare bytes,
// not characters: one multi-byte character makes a string of the right
// length into a buffer of the wrong one, and the throw would crash your
// endpoint instead of refusing the delivery.
if (typeof parts.v1 !== 'string' || Buffer.byteLength(parts.v1) !== expected.length) {
return false;
}

return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
};

The timestamp is part of the signed string, so a captured delivery cannot be replayed later under a fresh header.

Retries

We wait 10 seconds for your endpoint. Any 2xx answer means delivered. Anything else - an error status, a timeout, a refused connection - is a failed attempt.

A failed delivery is retried up to 5 attempts in total, with an exponential backoff of 2^attempt × 30s: 60 s, 120 s, 240 s, then 480 s. After the fifth attempt the delivery is marked failed and left alone. Turning the endpoint off stops the retries at once.

Make your handler idempotent. A retry sends the identical body, and a slow answer can arrive after we already retried.

Testing an Endpoint

The Send test event button on the API & webhooks page queues a real transfer.completed delivery through the real path: same signature, same delivery log. Its payload is a fixed sample — "test": true, a zeroed transfer uuid, and a made-up 100/97/3 summary — so nothing on your side mistakes it for a transfer that happened.

Two things differ from a real delivery. It is attempted once, not five times: one press of a button should not become five requests at your host. And it is rate limited to one test per endpoint per minute. The delivery log below the form shows every attempt, its status code and its response time.

Do Not Poll

The hosted flow has progress and results endpoints, but they belong to the user's browser session and are not a partner integration point.

Watch transfers through webhooks and the dashboard instead:

  • Webhooks tell your backend what happened, when it happened.
  • The Transfers page lists every transfer with its status and match rate, and exports to CSV.
  • The Analytics page draws the user flow funnel, from the first page view to a finished transfer, so you can see where users drop out.
  • The Reliability page shows error rates per service, and holds the alert rules that mail or page you when one goes bad.

Your users need no polling either. The hosted progress page is live, and the completion email brings them back to their results.