# user.auth_failed
URL: https://musicapi.com/docs/reference/events/user.auth_failed.md

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

Sent with the headers `X-MusicAPI-Event: user.auth_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: user.auth_failed. |
| `createdAt` | date-time | yes |  |
| `data` | object | yes |  |
| `data.transferUuid` | uuid | yes |  |
| `data.side` | string | yes | One of: source, target. |
| `data.reason` | string | yes |  |
| `data.sessionUuid` | uuid \| null | yes |  |
| `data.preauthorized` | boolean | yes |  |
| `data.externalUserId` | string \| null | no |  |
| `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": "user.auth_failed",
  "createdAt": "2026-10-16T09:41:07.000Z",
  "data": {
    "transferUuid": "5b0c6f0e-2c8a-4d0e-9a51-7e3f1c2d9b44",
    "side": "source",
    "reason": "string",
    "sessionUuid": "5b0c6f0e-2c8a-4d0e-9a51-7e3f1c2d9b44",
    "preauthorized": true,
    "externalUserId": "string",
    "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))
}
```
