# transfer.failed
URL: https://musicapi.com/docs/reference/events/transfer.failed.md

A webhook event: we POST it to your endpoint. Product: MusicAPI Managed Transfers.

Sent with the headers `X-MusicAPI-Event: transfer.failed` and `X-MusicAPI-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "t.body">`.

## Headers

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-MusicAPI-Event` | The event name, for example `transfer.completed`. |
| `X-MusicAPI-Signature` | `t=<seconds>,v1=<hex>`. The hex is the HMAC-SHA256 of `<t>.<raw body>` with your endpoint secret. |

## Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | no | The id of this 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`. |
| `event` | string | yes | One of: transfer.failed. |
| `createdAt` | date-time | yes |  |
| `data` | object | yes |  |
| `data.transferUuid` | uuid | yes |  |
| `data.status` | string | no |  |
| `data.totalItems` | number | no |  |
| `data.processedItems` | number | no |  |
| `data.matchedItems` | number | no |  |
| `data.unmatchedItems` | number | no |  |
| `data.skippedItems` | number | no |  |
| `data.failedItems` | number | no |  |
| `data.sourceIntegrationType` | string | no |  |
| `data.targetIntegrationType` | string | no |  |
| `data.resultsUrl` | uri | no | The Match Results API address of the unmatched items. |
| `data.stage` | string | no | Only when the transfer failed before it finished: the stage that failed. |
| `data.error` | string | no | Our internal error text. It can change and is not written for end users: show `failureReason` instead. |
| `data.errorType` | string \| null | no |  |
| `data.endUserId` | string \| null | no | Your own id for the person: the `externalUserId` of the session, else the `merchantUserId` of the source login. Null when you sent neither. |
| `data.batch` | TransferWebhookBatch \| null | no | The batch of the transfer. Null for a transfer that was made alone. |
| `data.batch.uuid` | uuid | yes |  |
| `data.batch.size` | number | yes | How many transfers the batch holds. |
| `data.batch.endedTransfers` | number | yes | How many of them ended (completed, completed with errors, failed or cancelled). |
| `data.batch.complete` | boolean | yes | True when every transfer of the batch ended. The event is then the last one of the batch. Two transfers that end at the same moment can both say true, so dedupe your batch notice by `uuid`. |
| `data.batch.notifyWhenDone` | boolean \| null | yes | The `notifyWhenDone` option of the create call. Null when the call did not send it. |
| `data.batch.completedTransfers` | number | yes | The transfers that ended well (completed or completed with errors). |
| `data.batch.failedTransfers` | number | yes | The transfers that failed or were cancelled. |
| `data.batch.matchedItems` | number | yes | The sum over the transfers that ended well. |
| `data.batch.totalItems` | number | yes | The sum over the transfers that ended well. |
| `data.batch.failedItems` | number | yes | The sum over the transfers that ended well. |
| `data.batch.progress` | TransferWebhookBatchProgress | yes | How far the whole batch got, over every transfer of it (running ones too). |
| `data.batch.progress.processedItems` | number | yes | Items processed, summed over every transfer of the batch. |
| `data.batch.progress.totalItems` | number | yes | Items to process, summed over every transfer of the batch. |
| `data.batch.progress.pct` | number | yes | 0 to 100, rounded down. 100 only when every item was processed. |
| `data.errorName` | string | no | Only when the transfer failed before it finished: the class name of the error. For a bug report or a dashboard, not for a branch. |
| `data.failureReason` | string | no | Why the transfer failed, in a value you may branch on. `notAuthenticated`: an account was signed out and could not be recovered. `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. One of: notAuthenticated, rateLimited, targetRefused, unknown. |
| `data.failureSide` | string \| null | no | The end of the transfer that failed. Null when we cannot tell. One of: source, target, null. |

## Example payload

```json
{
  "id": "evt_3d1f6c0e-8a52-4c1b-9a0e-2f4b7c9d1e33",
  "event": "transfer.failed",
  "createdAt": "2026-10-16T09:41:07.000Z",
  "data": {
    "transferUuid": "5b0c6f0e-2c8a-4d0e-9a51-7e3f1c2d9b44",
    "status": "completed",
    "totalItems": 0,
    "processedItems": 0,
    "matchedItems": 0,
    "unmatchedItems": 0,
    "skippedItems": 0,
    "failedItems": 0,
    "sourceIntegrationType": "spotify",
    "targetIntegrationType": "tidal",
    "resultsUrl": "https://example.com",
    "stage": "string",
    "error": "string",
    "errorType": "string",
    "endUserId": "your-user-8841",
    "batch": {
      "uuid": "5b0c6f0e-2c8a-4d0e-9a51-7e3f1c2d9b44",
      "size": 0,
      "endedTransfers": 0,
      "complete": true,
      "notifyWhenDone": true,
      "completedTransfers": 0,
      "failedTransfers": 0,
      "matchedItems": 0,
      "totalItems": 0,
      "failedItems": 0,
      "progress": {
        "processedItems": null,
        "totalItems": null,
        "pct": null
      }
    },
    "errorName": "FetchError",
    "failureReason": "notAuthenticated",
    "failureSide": "source"
  }
}
```

## Delivery and retries

Answer with a 2xx status. We try other answers again, up to 5 attempts in total, with waits of 60 s, 120 s, 240 s, 480 s.

A transfer ends with one of `transfer.completed`, `transfer.failed`, `transfer.cancelled`.

## Verify the signature

Compute the HMAC-SHA256 of `<t>.<raw body>` with your endpoint secret, compare it with `v1` in constant time, and reject a `t` older than 5 minutes.

Node:

```js
import crypto from "node:crypto";

// header: "t=<sec>,v1=<hex>"
export function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((p) => p.split("="))
  );
  // Reject an old timestamp, for example after 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  if (expected.length !== String(v1).length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

Python:

```python
import hashlib
import hmac
import time

# header: "t=<sec>,v1=<hex>"
def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(item.split("=", 1) for item in header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    # Reject an old timestamp, for example after 5 minutes.
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(
        secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, v1)
```

Go:

```go
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"math"
	"strconv"
	"strings"
	"time"
)

// header: "t=<sec>,v1=<hex>"
func Verify(rawBody []byte, header, secret string) bool {
	var t, v1 string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(part, "=")
		if key == "t" {
			t = value
		} else if key == "v1" {
			v1 = value
		}
	}
	sec, err := strconv.ParseInt(t, 10, 64)
	// Reject an old timestamp, for example after 5 minutes.
	if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(v1))
}
```
