Make the destination write idempotent per item, not per run. An idempotent playlist transfer replays safely because every write is keyed to one source item and checked against what already landed, so a second attempt skips the work it already finished. Without that, the first production incident on any import feature is predictable. A 200,000 item run dies at item 40,000, the listener taps retry, and the destination now holds two copies of the playlist plus 40,000 duplicate likes.
Transfers break. That is not the bug. The bug is that your retry button means "start over": read the source again from offset zero, create the playlist again, write every item again. Three layers stop it. Classify the failure, key the work, and keep resume bookkeeping that records what the destination confirmed.
Retry duplicates by default
HTTP gives you no protection here. Per RFC 9110, OPTIONS, HEAD, GET, PUT and DELETE are idempotent while POST and PATCH are not, and repeating a request multiple times can result in duplication or incorrect updates. Clients retry anyway, because some requests can be automatically retried by a client in the event of an underlying connection failure. A playlist write that times out is the worst version of this: you do not know whether the destination accepted it.
The destination APIs do exactly what their references say. Spotify's add items endpoint accepts a maximum of 100 items in one request, and if position is omitted, the items will be appended to the playlist. Appended, not merged. Replay a batch of 100 URIs after a timeout and the playlist grows by 100 rows. YouTube's playlistItems.insert has a quota cost of 50 units per call, and its documented failures include a playlist that already contains the maximum allowed number of items, a request that is not properly authorized to insert the specified playlist item, and a video that you are trying to add to the playlist that cannot be found. There is no "already there" answer in that list to lean on.
So the deduplication is yours to build.
Layer one: classify the failure, then decide the retry
A retry policy keyed off "the call failed" is the thing that creates duplicates. Key it off the status code. The MusicAPI rate limits and errors reference maps each status to an action, and those actions translate straight into resume policy.
| Status | What it means | Resume policy |
|---|---|---|
| 400 | The request is not valid | Fix the request. Do not retry it unchanged. Mark the item failed, leave the cursor alone. |
| 403 | The credential is valid but not allowed here, or an evaluation account reached its cap | Not resumable by replay. internalCode 20 means the user must sign in on the service's own site first, 21 means the user must do something in the service first, for example accept new terms. Pause and ask. |
| 404 | The connected user, item or transfer does not exist in your account | Mark the item failed. A replay will not conjure it. |
| 412 | The stored sign-in no longer works | Pause the run. Resume after a new sign-in, with no items replayed. |
| 418 | NotSupportedMethod: the service has no such operation | Skip the item permanently and record the reason. |
| 422 | The item is already in the collection | Treat it as done. For a retry this is a success, not an error. |
| 429 | Rate limit | Wait the Retry-After seconds, then continue from the same cursor. |
| 5xx | The call failed on our side, or the music service failed | Retry later with a growing pause. Cursor unchanged. |
Two rows carry most of the weight.
A 429 is a resumable state. Over the limit, the API answers 429 Too Many Requests with Retry-After: 60, and that header gives the window length in seconds, not the time left in the window. Each partner account has one rate limit for all its keys and credentials together, so a pool of parallel workers shares one budget and a burst of retries makes the next retry worse. Upstream services behave the same way: the header of a Spotify 429 will normally include a Retry-After header with a value in seconds. Nothing about a 429 invalidates completed work. No item changes state, and the cursor does not move.
A 412 is not resumable by replay. It means the music service refused the stored sign-in and MusicAPI could not renew it. Before it answers 412, MusicAPI already tried to renew the tokens once, so do not retry the call in a loop. Mark the connection broken, show a reconnect button, and hold the run where it is. The id you stored survives the reconnect: when the same music account signs in again, it keeps its integrationUserUUID. The same logic applies to a 403 that carries internalCode 20 or 21. A human has to act before any further write can succeed. For the wider taxonomy, see our write-up on retry strategies, rate limits and graceful degradation.
Layer two: one key per transfer, one key per item
An idempotency key is a unique value generated by the client which the resource uses to recognize subsequent retries of the same request. Generate two of them.
The transfer key is created once, when the listener taps Start, and written to your database before the first destination call goes out. The retry button reuses it. The key MUST be unique and MUST NOT be reused with another request with a different request payload, so if the listener changes the selection, that is a new transfer with a new key. A UUID or a similar random identifier is the recommended shape. Do not build the key out of an email address or another personal identifier.
The item key is derived rather than random, because it has to come out the same on every attempt:
import { createHash } from 'node:crypto';
// Same transfer, same destination collection, same source item, same key.
const itemKey = (transferId, destinationCollectionId, sourceItemId) =>
createHash('sha256')
.update(`${transferId}:${destinationCollectionId}:${sourceItemId}`)
.digest('hex');
Store the key with the result of the write, not with the attempt. For API v1, Stripe saves the status code and body of the first request made with a given idempotency key after endpoint execution begins, and retries with the same key return the saved response, including 500 errors. Copy the shape: your item record holds the destination id that the write produced, so a replay answers from the record instead of calling the API again.
Concurrency needs the same guard. If the request is retried while the original request is still being processed, the resource SHOULD reply with an HTTP 409 status code. A listener double tapping retry is exactly that case. At run level, MusicAPI Managed Transfers already enforces it: transfers of one person run one after the other, never side by side, and a second transfer waits until the first one ends, then starts by itself.
Layer three: resume bookkeeping
A resume needs three pieces of state and no more. A source cursor, the last confirmed destination write, and a per-item status.
const run = {
transferId: '6f1c9a2e',
sourceCursor: 'offset:40000',
lastConfirmedWrite: { itemKey: 'a93f...', at: '2026-11-02T09:14:22Z' },
items: {
'a93f...': { status: 'written', destinationTrackId: 'tr_8812', isrc: 'GBAYE0601498' },
'b01c...': { status: 'unmatched', reason: 'lowConfidence' },
'c77d...': { status: 'skipped', reason: 'notSupportedMethod' },
},
};
Resume from item status, not from the source. Replaying a 200,000 item source read to find out where you stopped costs a full paging run and tells you nothing the item table does not already know. The source read is also the part most likely to drift under you. On Managed Transfers that drift is removed by design: when a user connects an account, MusicAPI reads the library once and keeps a snapshot, and a transfer reads the source as it was in the snapshot that the user picked from. A run that resumes four hours later writes the same set it was going to write.
Counting is a separate job from resuming. Use the item table for "what do I still owe the destination" and a reconciliation pass for "did every source item get accounted for", which is covered in playlist transfer reconciliation.
Dedupe on the destination: id first, ISRC second, never the display name
Before each write, check in this order. First, your own item record: if it holds a destination id for this item key, skip. Second, the destination's id for the resolved track against the collection you last read back. Third, ISRC. Stop there.
Display name is the wrong key, and it fails in both directions. Two different recordings of the same title look identical, so you drop a track the listener wanted. One recording spelled differently across two providers looks like two tracks, so you write it twice. Fuzzy comparison belongs to matching, not to deduplication. For each source item, MusicAPI searches the destination first by ISRC where the destination supports it, then by name, artists and album. A candidate must score at least the match threshold of your account (80 out of 100 by default), below which the item is unmatched with the reason lowConfidence and the closest candidate is reported as the best guess. That scoring answers "which track is this". It must never answer "did I already write this". More on identifier quality in our notes on ISRCs, track IDs and cross-platform matching.
Reading the destination back gets cheap once you track versions. Spotify's playlist APIs expose a snapshot_id that corresponds to the version of the playlist that you are working with. Store it after each batch. If it has not changed, your record is still accurate and you can skip the read.
This is the layer MusicAPI removes. Per-provider OAuth clients and token refresh, ISRC matching with a fuzzy fallback, per-service 429 handling, per-item results with a reason and a best guess, and paging a 200,000 item library, all behind one API instead of eleven integrations you keep patched yourself.
Webhooks tell you where the run stopped
Polling tells you where a run was when you asked, and every poll spends the same budget as your writes: the limit counts calls to /public/* and /api/*, while the hosted sign-in pages and webhooks do not count. So let state changes push to you. The states in how Managed Transfers works map onto resume decisions. waitingForReauth means one account needs a new sign-in, nothing is lost, and the transfer continues after the user signs in again or after your backend pushes a new token. completedWithErrors means the transfer ended, and some items did not match or failed. completed means every item reached the destination or was already there, which is the only honest definition of done for a run that may have been retried.
Rate limiting is handled at run level too. When a music service asks MusicAPI to slow down, the transfer waits and continues by itself. Per-item detail arrives through webhooks for your backend, and the Match Results API for the unmatched items, which is what your item table should be written from. If you are also sizing the retry window, our post on playlist transfer time covers the completion estimates.
FAQ
Can I send an Idempotency-Key header to the streaming services?
Plan on no. Spotify's add items body takes a JSON array of the Spotify URIs to add, and YouTube's insert takes a playlistItem resource in the request body. The guard lives in your backend, between your item record and the call you are about to make.
How long should I keep idempotency keys?
Longer than a run can stay paused. A resource MAY require time based idempotency keys to be able to purge or delete a key upon its expiry, and Stripe removes API v1 keys automatically once they are at least 24 hours old. That is too short for a transfer key, because a run can sit waiting for a reauth until the listener signs in again. Expire item keys with the transfer, not on a clock.
How do I key liked songs and albums, which have no playlist id?
Use the collection type in place of the collection id, so likes, albums and artists each get their own key space inside the transfer. The key must not be reused with a different request payload, and a liked track write and a playlist track write are different payloads even for the same track.
Does a retry have to recreate the playlist?
No. Record the destination collection id the moment creation succeeds, keyed by the transfer key, and let every later attempt read it back. In the hosted flow the listener decides this up front: for a playlist that already exists on the destination, the user picks how to handle it.
Skip months of OAuth and per-provider SDK work. Sandbox keys are free and instant, and one API moves libraries between 11 streaming services. Start with how Managed Transfers works and the webhook states your resume logic will hang off.