- Docs
- Webhooks and alerts
Set up and verify webhooks
Webhooks tell your backend what happens in Managed Transfers: a transfer started, progressed, finished or failed, a synced transfer ran, an account connected or lost its sign-in, a match report is ready.
Add an endpoint
- Open API & webhooks
In the dashboard, open API & webhooks and add an endpoint.
- Enter the URL
A public
httpsURL of your backend. URLs that point at private or internal addresses are refused, at save time and again before every delivery. - Pick the events
Tick the events you want. An endpoint gets only those.
- Copy the signing secret
The secret shows once. Store it in your backend secrets. Lost it? Delete the endpoint and add a new one.
- Send a test event
Press Send test event and check your logs. See Retries and testing.
The events
| Event | When it fires |
|---|---|
transfer.created | The transfer started, and the total item count is known. The first event of a transfer. |
transfer.progress | Items moved. At most one every 15 seconds per transfer, plus each 10% step. |
transfer.completed | The transfer ended. Carries the full summary and resultsUrl. |
transfer.failed | The transfer stopped and cannot continue. |
transfer.cancelled | The transfer was cancelled, for example when you ended its session. |
transfer.item_failed | Items of one collection could not be written. Up to 50 items per delivery. |
transfer.synced | A sync run of a synced transfer ended with news: what it wrote, or what waits. |
user.authorized | An account was connected to one side of a session. |
user.auth_failed | A running transfer lost an account and waits for a new sign-in (status waitingForReauth). Not terminal. There is no separate transfer.reauth_required event. |
match.report_ready | The unmatched-items report of a finished transfer is ready. |
Each event page in the reference has the full data shape.
The delivery
Every delivery is a POST with a JSON body and three headers:
Content-Type: application/jsonX-MusicAPI-Event: the event name, so you can route before you parseX-MusicAPI-Signature:t=<unix seconds>,v1=<hex>
{
"id": "evt_3d1f6c0e-8a52-4c1b-9a0e-2f4b7c9d1e33",
"event": "transfer.completed",
"createdAt": "2026-10-16T10: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",
"resultsUrl": "https://api.musicapi.com/public/transfers/6a002d4b-f95b-471f-a27d-609a42e10cdb/unmatched",
"endUserId": "your-user-8841",
"batch": null
}
}
id is the id of the delivery, evt_<uuid>. A retry sends the same id, so dedupe by it. Two endpoints get two different ids for the same event. Deliveries queued before 2026-10-16 have no id.
Read data.status, not only the event name: a transfer.completed can be completed or completedWithErrors.
Route an event to your user
Every event that names a transfer (every event with a transferUuid) also carries these fields:
endUserId: your own id for the person. It is theexternalUserIdyou sent with the session, else themerchantUserIdof the source login.nullwhen you sent neither.batch: the batch of the transfer, ornull. A batch is one create call that made several transfers.sourceIntegrationTypeandtargetIntegrationType: the services of the two ends, astransfer.creatednames them.
{
"uuid": "2b7e1f0c-9a4d-4c3e-8f21-6d5a4b3c2e10",
"size": 3,
"endedTransfers": 3,
"complete": true,
"notifyWhenDone": true,
"completedTransfers": 2,
"failedTransfers": 1,
"matchedItems": 410,
"totalItems": 432,
"failedItems": 2,
"progress": { "processedItems": 432, "totalItems": 432, "pct": 100 }
}
size: how many transfers the batch holds.endedTransfers: how many of them ended (completed, completed with errors, failed or cancelled).complete:truewhen every transfer of the batch ended. The event is then the last one of the batch: send your one notice for the batch here. Two transfers that end at the same moment can both saytrue, so dedupe your notice bybatch.uuid.notifyWhenDone: thenotifyWhenDoneoption of the create call, ornullwhen the call did not send it.completedTransfersandfailedTransfers: the transfers that ended well, and the ones that failed or were cancelled.matchedItems,totalItemsandfailedItemsare the sums over the transfers that ended well.progress: how far the whole batch got, over every transfer of it (running ones too).pctis 0 to 100, rounded down. Use it for one progress bar per batch.
The fields are additive. No field that an event had before changed.
Why a transfer failed
transfer.failed comes in two shapes. When the transfer ran to the end and the destination refused its playlists or items, the data is the full summary with status. When a step failed before the end, the data has stage, error, errorType, errorName (the class name of the error, when it has one) and no status. Treat transfer.failed as terminal in both shapes.
Both shapes carry the counts (totalItems, processedItems, matchedItems, unmatchedItems, skippedItems, failedItems) and two fields you may branch on:
failureReason | What happened |
|---|---|
notAuthenticated | An account was signed out and could not be recovered. The user has to connect it again. |
rateLimited | A music service asked us to slow down too often. The same transfer would work later. |
targetRefused | The transfer ran to the end, but the destination refused its playlists or items. |
unknown | Anything else. Show a general message; errorType helps in a bug report. |
failureSide is source, target or null (we cannot tell). Show these to your users instead of error: error is our internal text, it can change, and it is not written for end users. Like an unstable errorType, errorName is for a bug report or a dashboard, not for a branch.
transfer.cancelled carries the same counts.
Verify the signature
v1 is the HMAC-SHA256 of the string t + "." + raw body, keyed with the endpoint's signing secret, as lowercase hex. It is the same construction as Stripe's signature, with another header name.
- Read the raw request body. A body that you parsed and serialized again no longer matches.
- Split the header into
tandv1. - Refuse a
tthat is more than 5 minutes away from your clock. This stops a replayed delivery. - Compute the HMAC and compare it with
v1in constant time. - Answer
2xxonly after the check passed.
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 300;
export function verifyMusicApiSignature(header, rawBody, secret) {
if (typeof header !== "string") return false;
const parts = Object.fromEntries(
header.split(",").map((part) => {
const index = part.indexOf("=");
return [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) > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
if (typeof parts.v1 !== "string" || Buffer.byteLength(parts.v1) !== expected.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
const app = express();
// The raw body, not express.json(): the signature covers the exact bytes.
app.post("/webhooks/musicapi", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyMusicApiSignature(req.get("X-MusicAPI-Signature"), rawBody, process.env.MUSICAPI_WEBHOOK_SECRET)) {
return res.status(400).send("bad signature");
}
const delivery = JSON.parse(rawBody);
// Queue the work, answer fast. A retry sends the same body again.
res.status(204).end();
handleDelivery(delivery).catch(console.error);
});
Handle deliveries well
- Answer fast. We wait 10 seconds. Queue the work and answer
2xxat once. - Make the handler idempotent. A retry sends the identical body with the same
id, and a slow answer can arrive after we already retried. Store theidof each delivery you handled, and skip anidyou already saw. - Do not trust order. Deliveries can arrive out of order. Read the
statusin the data, and usecreatedAtto drop an older state. - Do not answer with a redirect. We never follow one, and a
3xxcounts as a failed delivery.
Next: Retries and testing.