# Set up and verify webhooks
URL: https://musicapi.com/docs/webhooks/set-up-and-verify.md

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

Product: MusicAPI Managed Transfers. Section: Webhooks and alerts.

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](https://musicapi.com/docs/webhooks/retries-and-testing.md).

## The events

| Event                                                                                        | When it fires                                                                                                                                                    |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`transfer.created`](https://musicapi.com/docs/reference/events/transfer.created.md)         | The transfer started, and the total item count is known. The first event of a transfer.                                                                          |
| [`transfer.progress`](https://musicapi.com/docs/reference/events/transfer.progress.md)       | Items moved. At most one every 15 seconds per transfer, plus each 10% step.                                                                                      |
| [`transfer.completed`](https://musicapi.com/docs/reference/events/transfer.completed.md)     | The transfer ended. Carries the full summary and `resultsUrl`.                                                                                                   |
| [`transfer.failed`](https://musicapi.com/docs/reference/events/transfer.failed.md)           | The transfer stopped and cannot continue.                                                                                                                        |
| [`transfer.cancelled`](https://musicapi.com/docs/reference/events/transfer.cancelled.md)     | The transfer was cancelled, for example when you [ended its session](https://musicapi.com/docs/managed-transfers/preauthorized-sessions.md#end-a-session).       |
| [`transfer.item_failed`](https://musicapi.com/docs/reference/events/transfer.item_failed.md) | Items of one collection could not be written. Up to 50 items per delivery.                                                                                       |
| [`transfer.synced`](https://musicapi.com/docs/reference/events/transfer.synced.md)           | A sync run of a synced transfer ended with news: what it wrote, or what waits.                                                                                   |
| [`user.authorized`](https://musicapi.com/docs/reference/events/user.authorized.md)           | An account was connected to one side of a session.                                                                                                               |
| [`user.auth_failed`](https://musicapi.com/docs/reference/events/user.auth_failed.md)         | 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`](https://musicapi.com/docs/reference/events/match.report_ready.md)     | 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/json`
- `X-MusicAPI-Event`: the event name, so you can route before you parse
- `X-MusicAPI-Signature`: `t=<unix seconds>,v1=<hex>`

```json
{
  "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.

```json
{
  "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:

| `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.

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.

```js
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);
});
```

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

TOLERANCE_SECONDS = 300
app = Flask(__name__)


def verify_musicapi_signature(header: str | None, raw_body: bytes, secret: str) -> bool:
    if not header:
        return False
    parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))


@app.post("/webhooks/musicapi")
def musicapi_webhook():
    raw_body = request.get_data()  # the raw bytes, before any JSON parsing
    if not verify_musicapi_signature(
        request.headers.get("X-MusicAPI-Signature"), raw_body, os.environ["MUSICAPI_WEBHOOK_SECRET"]
    ):
        abort(400)
    delivery = request.get_json()
    enqueue(delivery)  # answer fast; a retry sends the same body again
    return "", 204
```

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

func VerifyMusicAPISignature(header string, rawBody []byte, secret string) bool {
	parts := map[string]string{}
	for _, part := range strings.Split(header, ",") {
		if key, value, ok := strings.Cut(strings.TrimSpace(part), "="); ok {
			parts[key] = value
		}
	}
	timestamp, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-timestamp)) > toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(parts["t"] + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}

func Handler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "read error", http.StatusBadRequest)
		return
	}
	if !VerifyMusicAPISignature(r.Header.Get("X-MusicAPI-Signature"), rawBody, os.Getenv("MUSICAPI_WEBHOOK_SECRET")) {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}
	enqueue(r.Header.Get("X-MusicAPI-Event"), rawBody) // answer fast; a retry sends the same body
	w.WriteHeader(http.StatusNoContent)
}
```

## 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](https://musicapi.com/docs/webhooks/retries-and-testing.md).
