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

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

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

Sent after a sync run of a synced transfer, when the run has news: every run in `planOnly` mode (the difference is worked out and nothing is written), a `full` run that wrote or failed to write something, and a `full` run that cleared the counts that an earlier `planOnly` run left. A `full` run with nothing to do sends nothing.

## 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.synced. |
| `createdAt` | date-time | yes |  |
| `data` | object | yes |  |
| `data.transferUuid` | uuid | yes |  |
| `data.status` | string | yes | The status of the transfer after the run. |
| `data.syncMode` | string | yes | `full` writes the difference to the destination. `planOnly` works out the difference and writes nothing. One of: full, planOnly. |
| `data.name` | string \| null | yes | The name of the first playlist of the transfer. |
| `data.applied` | object | yes | What the run wrote at the destination. |
| `data.applied.added` | number | yes |  |
| `data.applied.removed` | number | yes |  |
| `data.applied.moved` | number | yes |  |
| `data.applied.failed` | number | yes |  |
| `data.pending` | object | yes | What waits because the run wrote nothing. All zeros in `full` mode. |
| `data.pending.adds` | number | yes |  |
| `data.pending.removals` | number | yes |  |
| `data.pending.moves` | number | yes |  |
| `data.sourceIntegrationType` | string | no |  |
| `data.targetIntegrationType` | string | 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. |

## Example payload

```json
{
  "id": "evt_3d1f6c0e-8a52-4c1b-9a0e-2f4b7c9d1e33",
  "event": "transfer.synced",
  "createdAt": "2026-10-16T09:41:07.000Z",
  "data": {
    "transferUuid": "5b0c6f0e-2c8a-4d0e-9a51-7e3f1c2d9b44",
    "status": "completed",
    "syncMode": "full",
    "name": "Road trip",
    "applied": {
      "added": 0,
      "removed": 0,
      "moved": 0,
      "failed": 0
    },
    "pending": {
      "adds": 0,
      "removals": 0,
      "moves": 0
    },
    "sourceIntegrationType": "spotify",
    "targetIntegrationType": "tidal",
    "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
      }
    }
  }
}
```

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

## 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))
}
```
