# Pre-authorized sessions
URL: https://musicapi.com/docs/managed-transfers/preauthorized-sessions.md

> Your users are already signed in to your service in your app. Send us their token, open the launchUrl, and they connect only the source. Create, push a new token, end.

Product: MusicAPI Managed Transfers. Section: Managed Transfers.

Use a pre-authorized session when your users are already signed in to your music service inside your own app. Your backend sends us the user's access token, we connect that account as the destination, and the user connects only the source. The wizard shows the destination as already connected.

1. **Your backend**: `POST /public/transfer-sessions` with the user's token
2. **MusicAPI**: Checks the token at the service and answers a `launchUrl`
3. **Your app**: Opens the `launchUrl`. The user connects the source.
4. **Transfer**: Runs. You push a new token when `user.auth_failed` asks for one.

## Requirements

- Managed Transfers is on for your account, and your **Transfer destination** is set.
- The destination is one of: **Spotify, Deezer, Tidal, YouTube, SoundCloud, Boomplay**. These services take a plain OAuth access token. For any other destination the call answers `400`.
- The token has the scopes your destination integration asks for in a normal sign-in: read the library, create and edit playlists, save tracks, albums and artists.
- Call the three endpoints from your backend only. They take **Basic** (Client Secret) or a **DevToken without `sub`**. A Client ID token, a one-time token, a DevToken with `sub` and a docs session get `403`.

## Create a session

```bash
curl -X POST "https://api.musicapi.com/public/transfer-sessions" \
  -u "$MUSICAPI_CLIENT_ID:$MUSICAPI_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "target": {
      "authData": {
        "accessToken": "BQD...user-access-token",
        "refreshToken": "AQB...user-refresh-token",
        "expiresAt": "2026-10-16T15:00:00Z"
      }
    },
    "externalUserId": "your-user-8841",
    "returnUrl": "https://app.example.com/library"
  }'
```

- `target.authData.accessToken` (string, required): The user's access token for your destination service.
- `target.authData.refreshToken` (string): The refresh token. We use it only when your destination integration has OAuth client credentials that can renew this token.
- `target.authData.expiresAt` (string | number): When the access token expires: an ISO 8601 date, or a Unix time in seconds or in milliseconds.
- `externalUserId` (string): Your own id for the user, up to 255 characters. We send it back in the webhooks of this session.
- `returnUrl` (string): The results page shows a "Back to …" button with this URL. It must match your Accepted Return URLs. With no return URLs on your account, it must be an `https` URL.
- `locale` (string): The user's language, such as `en` or `pt-BR`. Accepted now so your call does not change when the wizard gets more languages.

```json
{
  "sessionUuid": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "launchUrl": "https://app.musicapi.com/acme/transfer#session=0f1e2d3c-4b5a-4968-8776-655443322110&launch=8b1Xv...",
  "launchExpiresAt": "2026-10-16T14:15:00.000Z",
  "expiresAt": "2026-10-17T14:00:00.000Z",
  "externalUserId": "your-user-8841",
  "target": {
    "integrationType": "spotify",
    "integrationUserUuid": "9c8b7a65-4321-4f0e-9d8c-7b6a59483726",
    "displayName": "Mica Nowak"
  }
}
```

A second call for the same music account updates the stored token of that account. It makes no second account. Each call makes a new session, and sends `user.authorized` with `"preauthorized": true`.

| Status | Why                                                                                          |
| ------ | -------------------------------------------------------------------------------------------- |
| `400`  | The body is not valid, no destination is set, or the destination does not support this flow. |
| `401`  | No credential.                                                                               |
| `403`  | The credential is not secret, or Managed Transfers is off for your account.                  |
| `422`  | The music service refused the access token. We store nothing.                                |
| `503`  | We could not reach the music service to check the token. Try again later. We store nothing.  |

## The launch URL

Open the `launchUrl` in your app as it is: a web view or the browser. It works **once**, and only for **15 minutes** (`launchExpiresAt`). Create the session when the user taps "Transfer", not in advance. The session itself lasts 24 hours (`expiresAt`).

The single-use code sits in the URL fragment, after `#`. A browser never sends the fragment to a server, so the code stays out of access logs and `Referer` headers. The wizard trades the code for a session and removes it from the address bar.

With a verified [custom domain](https://musicapi.com/docs/managed-transfers/turn-on-and-brand.md#custom-domain), the `launchUrl` uses your domain.

## Token lifetime

We store the token encrypted, and never send it back in an answer or a webhook. When the destination refuses it during a transfer, we try to renew it once:

- When your destination integration has OAuth client credentials that can renew this token, we renew it with `refreshToken` and the transfer continues.
- Otherwise, or when the renewal fails, the transfer stops with status `waitingForReauth` and we send `user.auth_failed`. The transfer keeps everything it already moved.

## Push a new token

```bash
curl -X PUT "https://api.musicapi.com/public/transfer-sessions/0f1e2d3c-4b5a-4968-8776-655443322110/target-auth" \
  -u "$MUSICAPI_CLIENT_ID:$MUSICAPI_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "authData": {
      "accessToken": "BQE...new-access-token",
      "refreshToken": "AQB...user-refresh-token",
      "expiresAt": 1792170000
    }
  }'
```

```json
{
  "sessionUuid": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "target": {
    "integrationType": "spotify",
    "integrationUserUuid": "9c8b7a65-4321-4f0e-9d8c-7b6a59483726",
    "displayName": "Mica Nowak"
  },
  "transferResumed": true
}
```

- The token must open the **same** account as the session's destination. A token of another account answers `409`.
- We replace the stored token. Leave out `refreshToken`, and we store none.
- A transfer of this session that waits for the destination continues at once: `transferResumed: true`.
- You can push a token at any time, also before the old one expires.
- A session of another partner answers `404`. A session you ended answers `409`.

The loop for a long transfer:

1. You get `user.auth_failed` with `"side": "target"`, `"preauthorized": true` and the `sessionUuid`.
2. Your backend gets a fresh token for that user and calls `PUT …/target-auth`.
3. The answer has `"transferResumed": true`. The user's progress page continues without any action from the user.

While the transfer waits, the progress page tells the user that your app renews the connection. It does not ask the user to sign in to the destination.

## End a session

```bash
curl -X DELETE "https://api.musicapi.com/public/transfer-sessions/0f1e2d3c-4b5a-4968-8776-655443322110" \
  -u "$MUSICAPI_CLIENT_ID:$MUSICAPI_CLIENT_SECRET"
```

```json
{
  "sessionUuid": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "transferCancelled": true,
  "credentialsErased": true
}
```

Ending a session:

- closes the wizard of this session. A browser that started a transfer can still read its progress and results for 30 days;
- cancels its transfer when it still runs (you get `transfer.cancelled`);
- erases the stored token, name, email and library copy of the destination account, unless another live session or a running transfer still uses that account. Then `credentialsErased` is `false`.

A second call answers the same way and changes nothing.

## Security

- The access token and your Client Secret never go into your app's code.
- The `launchUrl` is a credential for 15 minutes. Do not log it, and do not send it by email.
- We check every token with the music service before we store it. A token of another account cannot replace the destination of a session.
- The wizard cannot detach or replace a pre-authorized destination.
- Call `DELETE` when the user signs out of your app or deletes their account. Outside a session, use [Disconnect users](https://musicapi.com/docs/connect-users/disconnect-users.md).
