Skip to the content
  1. Docs
  2. Webhooks and alerts

Set up and verify webhooks

Managed TransfersGuide · 5 min
Add a webhook endpoint, pick the events, and check the X-MusicAPI-Signature of every delivery. Verification code in Node, Python and Go.

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

  1. Open API & webhooks

    In the dashboard, open API & webhooks and add an endpoint.

  2. Enter the URL

    A public https URL of your backend. URLs that point at private or internal addresses are refused, at save time and again before every delivery.

  3. Pick the events

    Tick the events you want. An endpoint gets only those.

  4. Copy the signing secret

    The secret shows once. Store it in your backend secrets. Lost it? Delete the endpoint and add a new one.

  5. Send a test event

    Press Send test event and check your logs. See Retries and testing.

The events

EventWhen it fires
transfer.createdThe transfer started, and the total item count is known. The first event of a transfer.
transfer.progressItems moved. At most one every 15 seconds per transfer, plus each 10% step.
transfer.completedThe transfer ended. Carries the full summary and resultsUrl.
transfer.failedThe transfer stopped and cannot continue.
transfer.cancelledThe transfer was cancelled, for example when you ended its session.
transfer.item_failedItems of one collection could not be written. Up to 50 items per delivery.
transfer.syncedA sync run of a synced transfer ended with news: what it wrote, or what waits.
user.authorizedAn account was connected to one side of a session.
user.auth_failedA 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_readyThe 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/json
  • X-MusicAPI-Event: the event name, so you can route before you parse
  • X-MusicAPI-Signature: t=<unix seconds>,v1=<hex>
The envelope
{
  "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 the externalUserId you sent with the session, else the merchantUserId of the source login. null when you sent neither.
  • batch: the batch of the transfer, or null. A batch is one create call that made several transfers.
  • sourceIntegrationType and targetIntegrationType: the services of the two ends, as transfer.created names them.
batch
{
  "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: true when 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 say true, so dedupe your notice by batch.uuid.
  • notifyWhenDone: the notifyWhenDone option of the create call, or null when the call did not send it.
  • completedTransfers and failedTransfers: the transfers that ended well, and the ones that failed or were cancelled. matchedItems, totalItems and failedItems are the sums over the transfers that ended well.
  • progress: how far the whole batch got, over every transfer of it (running ones too). pct is 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:

failureReasonWhat happened
notAuthenticatedAn account was signed out and could not be recovered. The user has to connect it again.
rateLimitedA music service asked us to slow down too often. The same transfer would work later.
targetRefusedThe transfer ran to the end, but the destination refused its playlists or items.
unknownAnything 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.

  1. Read the raw request body. A body that you parsed and serialized again no longer matches.
  2. Split the header into t and v1.
  3. Refuse a t that is more than 5 minutes away from your clock. This stops a replayed delivery.
  4. Compute the HMAC and compare it with v1 in constant time.
  5. Answer 2xx only 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 2xx at 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 the id of each delivery you handled, and skip an id you already saw.
  • Do not trust order. Deliveries can arrive out of order. Read the status in the data, and use createdAt to drop an older state.
  • Do not answer with a redirect. We never follow one, and a 3xx counts as a failed delivery.

Next: Retries and testing.