Published on May 13, 2026

Building a music app that works across Spotify, Apple Music, YouTube Music, Tidal, Deezer, and more means dealing with 10+ different APIs, authentication flows, and data formats. Or it means using one API that handles all of them.
This post walks through building a cross-platform music app from scratch using a single API integration. You will set up authentication, fetch playlists and tracks, handle favorites, and ship a working app that connects to 12 streaming services without writing 12 separate integrations.
Every streaming service has its own API, its own OAuth flow, its own data shapes, and its own rate limits. Building a music app that supports even three services means maintaining three sets of credentials, three token refresh cycles, three response parsers, and three error-handling paths.
Here is what that looks like in practice:
| Concern | Per-Service Approach | Single API (MusicAPI) |
|---|---|---|
| Dev time to first integration | 2-4 weeks per service | 1-2 days for all services |
| Ongoing maintenance | Fix breakage per service API update | One integration, maintained upstream |
| Service coverage | 1-3 services (realistically) | 12+ services out of the box |
| Auth complexity | Separate OAuth app per service, each with its own scopes and token formats | One auth flow, one token format |
| Rate limit handling | Different thresholds, headers, and backoff rules per service | Single predictable rate limit |
| Response normalization | You build and maintain the mapping layer | Normalized by default |
The math is simple. If you support six services directly, you maintain six integrations. When Spotify changes their OAuth scopes (they did in 2024), you fix your Spotify integration. When Apple Music updates their token format, you fix your Apple integration. When YouTube deprecates an endpoint, you fix your YouTube integration. Each fix ships separately. Each one can break independently.
A single API layer absorbs these changes. You write your app logic once. The API provider handles the per-service differences.
The architecture is straightforward. Your app talks to MusicAPI. MusicAPI talks to the streaming services. Your app never touches a service-specific API directly.
┌─────────────────────┐
│ Your Music App │
│ (Web / Mobile / │
│ Desktop) │
└────────┬────────────┘
│ One API, one token format,
│ one response shape
▼
┌─────────────────────┐
│ MusicAPI │
│ (Unified Layer) │
└────────┬────────────┘
│ Handles OAuth, token refresh,
│ normalization, rate limits
▼
┌────┬────┬────┬────┬────┬────┐
│ SP │ AM │ YT │ TI │ DZ │ +7 │
└────┴────┴────┴────┴────┴────┘
Spotify Apple YouTube Tidal Deezer ...
Your frontend or backend makes requests to MusicAPI endpoints. The response format is identical regardless of whether the user connected Spotify, Apple Music, or Tidal. Your rendering code, your state management, your caching layer: all of it works with one data shape.
This means you can add a new streaming service to your app by enabling it in your MusicAPI dashboard. No new code. No new OAuth registration. No new parser.
Start by creating a MusicAPI account and grabbing your API credentials. You need two things: an API key for server-side requests and a client configuration for the user-facing auth flow.
Your API key authenticates your server. The app ID identifies your application to the user-facing auth widget. Both are required before you make your first API call.
Check the intro docs for the full setup walkthrough with screenshots.
User authentication is the part that takes the longest when you build direct integrations. Each service has its own OAuth 2.0 implementation (or in Apple Music's case, a token-based approach that is not standard OAuth). Scopes differ. Token lifetimes differ. Refresh mechanisms differ.
MusicAPI wraps all of this into one authentication flow.
Here is the process:
// Initialize auth for any supported service
const response = await fetch('https://api.musicapi.com/api/v1/auth/initialize', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
service: 'spotify', // or 'apple', 'youtube', 'tidal', 'deezer', etc.
redirectUri: 'https://yourapp.com/callback'
})
});
const { authUrl } = await response.json();
// Redirect user to authUrl
window.location.href = authUrl;
// In your /callback route handler
const { code } = req.query;
const tokenResponse = await fetch('https://api.musicapi.com/api/v1/auth/callback', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
code,
redirectUri: 'https://yourapp.com/callback'
})
});
const { connectionId, service } = await tokenResponse.json();
// Store connectionId — this represents the user's linked account
connectionId is your handle for all future requests on behalf of this user. MusicAPI stores and refreshes the underlying service tokens automatically. You never touch a Spotify refresh token or an Apple Music developer token directly.The same three steps work for every supported service. Change the service parameter, and the rest of the flow is identical.
With the user connected, you can fetch their playlists and tracks. The API returns the same response shape whether the data comes from Spotify, Apple Music, or any other service.
Get the user's playlists:
const playlists = await fetch(
`https://api.musicapi.com/api/v1/playlists?connectionId=${connectionId}`,
{
headers: { 'Authorization': 'Bearer YOUR_API_KEY' }
}
);
const { data } = await playlists.json();
// data is an array of playlist objects, normalized across all services
Each playlist object contains the same fields regardless of service: id, name, description, trackCount, artwork, and owner. No conditional logic for "if Spotify, check images[0].url; if Apple Music, check attributes.artwork.url."
Get tracks from a specific playlist:
const tracks = await fetch(
`https://api.musicapi.com/api/v1/playlists/${playlistId}/tracks?connectionId=${connectionId}`,
{
headers: { 'Authorization': 'Bearer YOUR_API_KEY' }
}
);
const { data: trackList } = await tracks.json();
// Each track: { id, title, artist, album, duration, artwork, isrc, ... }
Want to see the exact response shapes? Check the endpoint reference for playlists. You can also test these endpoints interactively: try fetching playlists from Apple Music or getting playlist tracks from Spotify.
Favorites (liked/saved tracks) follow the same pattern. One endpoint, one response format, every service.
Get a user's favorite tracks:
const favorites = await fetch(
`https://api.musicapi.com/api/v1/favorites?connectionId=${connectionId}`,
{
headers: { 'Authorization': 'Bearer YOUR_API_KEY' }
}
);
const { data: favoriteTracks } = await favorites.json();
You can also write data back. Creating a playlist works across services with the same request body:
const newPlaylist = await fetch(
'https://api.musicapi.com/api/v1/playlists',
{
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
connectionId,
name: 'My Cross-Platform Playlist',
description: 'Created via MusicAPI'
})
}
);
This creates a playlist on whatever service the user connected. Create a Spotify playlist, an Apple Music playlist, or a Deezer playlist with the exact same code.
Here is a complete Express.js backend that handles authentication for any streaming service and exposes playlist/track endpoints. This is a working starting point you can build on.
import express from 'express';
const app = express();
app.use(express.json());
const MUSICAPI_BASE = 'https://api.musicapi.com/api/v1';
const API_KEY = process.env.MUSICAPI_KEY;
const REDIRECT_URI = process.env.REDIRECT_URI || 'http://localhost:3000/callback';
const headers = {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
};
// Store connections in memory (use a database in production)
const userConnections = new Map();
// Step 1: Start the auth flow for any service
app.get('/connect/:service', async (req, res) => {
const { service } = req.params; // 'spotify', 'apple', 'youtube', etc.
const response = await fetch(`${MUSICAPI_BASE}/auth/initialize`, {
method: 'POST',
headers,
body: JSON.stringify({ service, redirectUri: REDIRECT_URI })
});
const { authUrl } = await response.json();
res.redirect(authUrl);
});
// Step 2: Handle the OAuth callback
app.get('/callback', async (req, res) => {
const { code } = req.query;
const response = await fetch(`${MUSICAPI_BASE}/auth/callback`, {
method: 'POST',
headers,
body: JSON.stringify({ code, redirectUri: REDIRECT_URI })
});
const { connectionId, service } = await response.json();
// In production, associate this with the authenticated user
const userId = req.session?.userId || 'demo-user';
if (!userConnections.has(userId)) {
userConnections.set(userId, {});
}
userConnections.get(userId)[service] = connectionId;
res.redirect(`/dashboard?connected=${service}`);
});
// Step 3: Get playlists from any connected service
app.get('/api/playlists/:service', async (req, res) => {
const userId = req.session?.userId || 'demo-user';
const connectionId = userConnections.get(userId)?.[req.params.service];
if (!connectionId) {
return res.status(400).json({ error: 'Service not connected' });
}
const response = await fetch(
`${MUSICAPI_BASE}/playlists?connectionId=${connectionId}`,
{ headers }
);
const data = await response.json();
res.json(data);
});
// Step 4: Get tracks from a specific playlist
app.get('/api/playlists/:playlistId/tracks', async (req, res) => {
const { playlistId } = req.params;
const { connectionId } = req.query;
const response = await fetch(
`${MUSICAPI_BASE}/playlists/${playlistId}/tracks?connectionId=${connectionId}`,
{ headers }
);
const data = await response.json();
res.json(data);
});
// Step 5: Get user's favorite tracks
app.get('/api/favorites/:service', async (req, res) => {
const userId = req.session?.userId || 'demo-user';
const connectionId = userConnections.get(userId)?.[req.params.service];
if (!connectionId) {
return res.status(400).json({ error: 'Service not connected' });
}
const response = await fetch(
`${MUSICAPI_BASE}/favorites?connectionId=${connectionId}`,
{ headers }
);
const data = await response.json();
res.json(data);
});
// Step 6: Get user profile from any service
app.get('/api/profile/:service', async (req, res) => {
const userId = req.session?.userId || 'demo-user';
const connectionId = userConnections.get(userId)?.[req.params.service];
if (!connectionId) {
return res.status(400).json({ error: 'Service not connected' });
}
const response = await fetch(
`${MUSICAPI_BASE}/user/profile?connectionId=${connectionId}`,
{ headers }
);
const data = await response.json();
res.json(data);
});
app.listen(3000, () => console.log('Music app running on port 3000'));
This is roughly 100 lines of code. It supports every streaming service MusicAPI connects to. Adding a new service means zero code changes: users visit /connect/tidal or /connect/deezer, and the same auth flow, the same playlist endpoint, and the same response format handle everything.
Compare this to building the same functionality with direct integrations. Spotify alone requires registering an OAuth app, implementing PKCE or authorization code flow, handling token refresh with their specific expiration rules, and parsing their nested response format. Multiply that by each additional service. The code above replaces all of it.
A working prototype and a production app differ in three areas: rate limiting, caching, and error handling.
Each streaming service enforces different rate limits. Spotify uses sliding windows with Retry-After headers. Apple Music returns 429 responses with no standard backoff header. YouTube has per-project daily quotas.
When you build direct integrations, you write separate rate-limiting logic for each service. With MusicAPI, you work against one rate limit. Your retry logic is a single function:
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || 1;
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
return response;
}
throw new Error('Rate limit exceeded after retries');
}
One retry function. Works for every service behind MusicAPI. No per-service branching.
Cache aggressively. Playlist metadata changes infrequently. Track metadata almost never changes. User profiles update rarely.
A sensible caching strategy:
Use Redis, an in-memory Map for small apps, or your framework's built-in cache. Key by connectionId + endpoint so each user's data is cached independently.
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 300 }); // 5 min default
async function cachedFetch(cacheKey, url, options, ttl) {
const cached = cache.get(cacheKey);
if (cached) return cached;
const response = await fetchWithRetry(url, options);
const data = await response.json();
cache.set(cacheKey, data, ttl);
return data;
}
Errors fall into three categories:
Auth errors (401/403). The user's connection expired or was revoked. Prompt them to reconnect. MusicAPI handles token refresh automatically, so these are rare, but users can revoke access from their streaming service settings.
Not found (404). The playlist or track was deleted on the source service. Handle gracefully in your UI.
Service unavailable (502/503). The upstream service is down. Show a "try again later" message. This is out of your control regardless of whether you integrate directly or through an API layer.
async function handleApiResponse(response) {
if (response.ok) return response.json();
if (response.status === 401) {
throw new Error('CONNECTION_EXPIRED');
}
if (response.status === 404) {
throw new Error('RESOURCE_NOT_FOUND');
}
if (response.status >= 500) {
throw new Error('SERVICE_UNAVAILABLE');
}
const error = await response.json();
throw new Error(error.message || 'Unknown error');
}
A basic integration (auth, playlists, tracks, favorites) takes one to two days. The code walkthrough in this post is a functional starting point. Most of the time goes into your app's UI and business logic, not the streaming service integration. Compare this to two to four weeks per service when building direct integrations.
MusicAPI connects to 12+ streaming services, including Spotify, Apple Music, YouTube Music, Tidal, Deezer, Amazon Music, and others. New services are added regularly. When a new service launches, your app supports it without code changes.
No. MusicAPI handles the OAuth complexity for each service. You register one application with MusicAPI, configure your redirect URI, and the unified auth flow works for every service. You never manage Spotify client secrets, Apple Music developer tokens, or YouTube API keys directly.
Yes. If you need the original access tokens from a connected service (for features MusicAPI does not cover), you can request the original auth tokens through the API. This gives you a fallback path for edge cases without abandoning the unified integration.
MusicAPI normalizes responses into a consistent JSON format. A playlist from Spotify and a playlist from Apple Music return identical field names, data types, and structure. Duration is always in milliseconds. Artwork always includes URL, width, and height. Artist references always include both ID and name. Check the supported features page for a field-by-field breakdown.
MusicAPI maintains the integrations upstream. When Spotify changes their API (as they did with their OAuth scopes update), MusicAPI's team updates the integration. Your app continues to work without changes. This is one of the main advantages over direct integrations: you shift the maintenance burden to a dedicated team.
Yes. Fetch tracks from a playlist on one service, then create a new playlist and add those tracks on another service. MusicAPI's normalized track data (including ISRC codes) makes matching songs across platforms straightforward. See the playlist creation endpoints and playlist generator tutorial for implementation details.
Ready to skip months of OAuth and SDK work? Start your free MusicAPI trial and connect 10+ streaming services with one unified API.