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:
- 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.
- 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.
- Pick the content - playlists, liked (followed) playlists, liked songs, albums, artists and podcasts, limited to the entity types you turned on for that source.
- Confirm - the user reviews the selection and starts the transfer.
- 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.
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
| Event | When it fires |
|---|---|
transfer.created | The transfer started and the total item count is known. |
transfer.progress | Progress crossed a 10% boundary. At most ten per transfer. |
transfer.completed | The transfer finished. Carries the full summary. |
transfer.failed | The transfer stopped and cannot continue. |
transfer.item_failed | One batch of items could not be written, reported per group run, up to 50 items. |
user.authorized | A user attached an account to one side of a session. |
user.auth_failed | A running transfer lost an account and needs the user to log in again. |
match.report_ready | The match report of a finished transfer is ready. |
Envelope
Every delivery is a POST with the same envelope:
{
"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:
{
"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:
errorType | What happened |
|---|---|
rateLimited | A 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. |
notAuthenticated | An account was logged out and re-authentication did not recover it. The visitor has to connect it again. |
reauthRequired:source / reauthRequired:target | Not a failure — the status is waitingForReauth and the named side needs a fresh login. The transfer keeps everything it has already moved. |
| anything else | The 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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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/jsonX-MusicAPI-Event: the event name, so you can route before you parseX-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.
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.