Reading time: 12 min read

Sitecore CLI or the Content Transfer API? Picking Your Package Manager Replacement

Two tools, two jobs. A head to head on the Sitecore CLI and the new Content Transfer APIs, plus the script that automates the whole pipeline.

Portrait photo of David Austin, article author

Sitecore CLI was only half the story

A few days ago I made the case that the Sitecore CLI is a versatile replacement for the Package Manager. I still stand by it. Serialized items in source control, a module file that says exactly what is getting captured or migrated and a series of commands that can pretty much run anywhere. That's a much better story than a .zip you emailed to someone. But you could still .zip up the folder structure you serialzed and hand it across if needed.

But let's be honest. The CLI is a developer tool at heart. Even the most seasoned content authors would be hesitant to use it. Every time I hand it to someone whose job is moving a campaign from QA to production, the .NET install, the tool manifest, the plugin list and the module JSON all land as friction. That's not what Package Manager was. Package Manager was a screen you opened, you could figure it out.

Back on July 1, 2026, Sitecore shipped the thing that was actually built for this job: the Content Transfer API and the Item Transfer API. So let's get into what they are, how they stack up against the CLI, and how I got the whole pipeline down to one command. And if you're in the industry you're likely more than aware that there are already GUIs available via the Marketplace or others that can bring back the romance of the good old Package Manager. To a degree.

What Sitecore actually shipped

Rather than a single, cohesive API set. Sitecore actually provided us two APIs that only make sense together:

  • Content Transfer API: creates a transfer, packages the items you asked for into binary chunks, serves those chunks from the source and receives them on the destination.
  • Item Transfer API: consumes the reassembled package and writes items into the destination's content tree.

Just like the Package Manager, using these two in combination, you can move everything but the code. No security accounts and no roles either.

There's one detail worth knowing early, because it explains the entire design. The Item Transfer API will happily take a .raif file you upload directly, up to 100 MB. That ceiling is low for a real content tree. The Content Transfer API exists to get around it by streaming your content across as a series of chunks that the destination reassembles. So chunking isn't an implementation detail you can skip past. It basically runs on chunks.

Three terms do all the work:

  • Chunk: a slice of your content, streamed as raw binary. Either encrypted content, or compressed media.
  • Chunk set: the group of chunks that reassemble into one package on the destination.
  • .raif file: the reassembled chunk set. The Item Transfer API consumes it to create items.

The Content Transfer API packages and delivers bytes. The Item Transfer API ingests those bytes into the content tree. They're not two names for the same operation.

When step 5 completes successfully, the bytes are sitting on the destination and not one item is in the tree yet. If you stop there because you got a 200, you'll go looking in Content Editor, find nothing, and assume the transfer failed. It didn't. You just haven't run step 6.

The six steps of a content transfer

  1. Source: POST /sitecore/api/content/transfer/v1/transfers
  2. Source: GET .../transfers/{id}/status, poll until State: Completed
  3. Source: GET .../transfers/{id}/chunksets/{csId}/chunks/{n}, which comes back as raw binary
  4. Destination: PUT .../transfers/{id}/chunksets/{csId}/chunks/{n}?isMedia={bool}
  5. Destination: POST .../transfers/{id}/chunksets/{csId}/complete, which builds the .raif
  6. Destination: POST /sitecore/shell/api/v3/ItemsTransfer/transfers/databases/{db}/sources?blobName={name}.raif

Notice steps 1 to 3 and 4 to 5 are the same API on different hosts. The Content Transfer API is symmetric. It has a read side and a write side, and you point one environment's read side at the other environment's write side. Only step 6 is a different API, and it lives on a completely different base path, the classic /sitecore/shell/api/v3 surface. That split is clearest as two lines of code:

const CT_BASE = (host) => `https://${host}/sitecore/api/content/transfer/v1`;
const IT_BASE = (host) => `https://${host}/sitecore/shell/api/v3/ItemsTransfer`;

Organize the content that you are migrating

This shape is easy to get wrong, so here it is exactly. DataTrees and Database live inside a Configuration wrapper, and TransferId is a sibling of Configuration, not a property inside it:

{
  "Configuration": {
    "DataTrees": [
      { 
      "ItemPath": "/sitecore/content/MySite/Home", 
      "Scope": "ItemAndDescendants", 
      "MergeStrategy": "OverrideExistingItem"
      },
      { "ItemPath": "/sitecore/content/MySite/Home/Settings",
      "Scope": "SingleItem",
      "MergeStrategy": "OverrideExistingItem"
      }
    ],
    "Database": "master"
  },
  "TransferId": "00000000-0000-0000-0000-000000000000"
}

Scope is either SingleItem or ItemAndDescendants.

MergeStrategy is how the destination sorts out conflicts:

  • OverrideExistingItem: incoming item wins, field by field
  • KeepExistingItem: destination wins, so anything that already exists gets skipped
  • LatestWin: most recently updated version wins
  • OverrideExistingTree: replace the whole subtree

Read that last one twice. OverrideExistingTree is the only merge strategy that can delete existing items. Everything else is additive or a no-op. Reach for it casually on a production destination and you will remove content nobody asked you to remove.

One more thing that bites people. The parent chain has to already exist on the destination with matching IDs. Transfer /sitecore/content/MySite/Home/Campaigns/Spring into an environment that has no Campaigns item, and the transfer reports success while the items never show up in the tree. No error, nothing to go on.

Head to head: which is better, CLI or API?

 Sitecore CLIContent + Item Transfer APIs
Built forDevelopers, source control, deploymentEnvironment-to-environment content moves
What movesSerialized items on disk (.yml), packaged to .itempackageContent items streamed as binary chunks
Media at volumeWorkable, but awkwardFirst class. isMedia per chunk, compressed in transit
Where it runsYour machine or a build agent, needs .NET, a tool manifest, pluginsAny HTTP client, nothing to install
Config lives in.module.json, committed to the repoA JSON body per request
Conflict handlingallowedPushOperationsFour merge strategies, including full tree replace
Authdotnet sitecore cloud login, or client credentialsOAuth client credentials, then a bearer token
Audit trailGit historyTransfer history endpoint, plus whatever you log
Who can run itDevelopersAnything that can make an HTTP call
Size ceilingPractical limits on very large treesChunked streaming, no practical ceiling

Where each one wins

This isn't a contest, and I'd be a little suspicious of anyone who tells you one of these makes the other obsolete.

Reach for the CLI when the content is structure that belongs to your codebase. Templates, renderings, placeholder settings, site definitions. The things that break your build if they disagree with your code. You want those serialized, reviewed in a pull request, and deployed by the same pipeline that deploys the code depending on them. A .module.json in the repo is documentation of what a feature owns, and that's worth a lot.

Reach for the Transfer APIs when you're moving authored content between environments after the fact. A campaign an editor finished in QA. A media library folder. Several thousand pages someone needs in preprod by Thursday. None of that belongs in git, none of it is code adjacent, and serializing it to disk just to push it back up is a detour. This is the Package Manager use case, and it finally has a tool built for it.

In a mature setup you'll use both. CLI for the repo shaped things, Transfer APIs for the content promotions. They overlap far less than it first looks.

Doing it by hand is where it falls apart

Here's the honest part. The six steps are clean on paper. In a REST client they're miserable.

I built the whole flow out in a Bruno collection first, and it does work. You can authenticate, create a transfer, poll status, and the collection's scripts grab the transfer ID, chunk set ID and media flag for you automatically. But it hits a hard wall at steps 3 and 4, and that isn't Bruno's fault. Bruno and Postman can't pipe a binary response body into the next request's body, can't loop a request over a chunk index, and have no in memory binary handoff at all.

So for every single chunk, by hand, you would have to:

  1. Set the chunk index and run the download request
  2. Read the Content-Disposition header to find the IsMedia flag
  3. Save the response body to disk as raw binary (chunk-0.bin, chunk-1.bin, and so on)
  4. Switch to the upload request and re-select that exact file in the body tab
  5. Confirm ?isMedia= matches what you saw in step 2 for this chunk
  6. Run it, then repeat

Twenty-two chunks means twenty-two rounds of that, with a corrupt destination waiting at the end of any one you get wrong. Go grab a coffee first. The collection is a genuinely good way to learn the API and see what each call gives back. It is not a way to do the work.

Automating it: one file, no dependencies

So I wrote a script. One file, migrate.mjs, roughly 355 lines, that runs the entire pipeline end to end with no manual steps. Chunks stream through memory and never touch disk. It loops every chunk of every chunk set, polls status for you, completes each set and consumes every .raif.

The constraint I set myself was zero dependencies. Native fetch, Node 24+, no npm install, no node_modules, no lockfile. This is a tool that holds production client secrets and writes to production content trees. Every package I add is something I have to trust, and keep trusting. For a script this size, going dependency free isn't minimalism for its own sake. It's the thing that makes me comfortable running it against a live environment.

Two inputs, cleanly separated

FileHoldsCommitted?
.env.localWHERE + WHO. Source and destination hosts, plus client ID and secret for eachNo, secrets
*.items.jsonWHAT. The dataTrees[] to moveYes, paths only

Splitting it this way is what makes the thing reusable. The items file describes content and nothing else, so the same file runs QA to preprod, preprod to prod, or a completely different client's environments, just by swapping the active block in .env.local. Real shell environment variables win over the file, so CI can override everything without editing anything.

An items file is as boring as it should be:

{
  "database": "master",
  "dataTrees": [
    {
    "itemPath": "/sitecore/content/MySite/Home", 
    "scope": "ItemAndDescendants",
    "mergeStrategy": "OverrideExistingItem"
    }
  ]
}

Authentication

Standard OAuth client credentials against the Sitecore Cloud Portal:

async function getToken(env, label) {
  const res = await fetch(`https://${authHost}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      audience, grant_type: 'client_credentials',
      client_id: env.clientId, client_secret: env.clientSecret,
    }),
  });
  if (!res.ok) throw new Error(`${label} token request failed: ${res.status} ${await res.text()}`);
  return (await res.json()).access_token;
}

The authority is auth.sitecorecloud.io, the audience is https://api.sitecorecloud.io, and tokens last about 24 hours. There's no refresh logic in here, because no run gets anywhere close.

The script mints two independent tokens, one for the source and one for the destination. That's deliberate, since they may be different environments with different automation clients. If you're using a single Organization scoped automation client that reaches every environment in the org, just put the same credentials in both blocks and move on. You'll need Organization Admin or Organization Owner in the Cloud Portal to create one.

Polling the progress

The second step is all about polling the progress of the chunk build.

const deadline = Date.now() + pollTimeoutMs;
for (;;) {
  if (Date.now() > deadline) throw new Error('Timed out waiting for transfer to complete.');
  const res = await jsonReq('GET', `${CT_BASE(src.host)}/transfers/${transferId}/status`, srcToken);
if (res.status === 404) {
    log('  status 404 (not ready yet - known CFW-9663 window), retrying ...');
  } else if (res.ok) {
    const d = await res.json();
    const state = d.State || 'Unknown';
    if (state === 'Completed') { chunkSets = d.ChunkSetsMetadata || []; break; }
    if (/fail|error/i.test(state)) throw new Error(`Transfer entered state '${state}'`);
    log(`  State=${state} ...`);
  }
  await new Promise((r) => setTimeout(r, pollIntervalMs));
}

The loop is what you can't do in Postman or Bruno without a lot of extra effort

This is the part that justified writing a script. You're basically downloading from the source and then uploading to the destination all chunks in their correct order.

for (let idx = 0; idx < count; idx++) {
  // Step 3 - download from source
  const dlUrl = `${CT_BASE(src.host)}/transfers/${transferId}/chunksets/${csId}/chunks/${idx}`;
  const dl = await fetch(dlUrl, { headers: { Authorization: `Bearer ${srcToken}` } });
  if (!dl.ok) throw new Error(`Download chunk ${idx} failed: ${dl.status} ${await dl.text()}`);
  const isMedia = parseIsMedia(dl);
  const bytes = Buffer.from(await dl.arrayBuffer());
  // Step 4 - upload to destination (binary held in memory)
  const upUrl = `${CT_BASE(dst.host)}/transfers/${transferId}/chunksets/${csId}/chunks/${idx}?isMedia=${isMedia}`;
  const up = await fetch(upUrl, {
    method: 'PUT',
    headers: { Authorization: `Bearer ${dstToken}`, 'Content-Type': 'application/octet-stream' },
    body: bytes,
  });
  if (!up.ok) throw new Error(`Upload chunk ${idx} failed: ${up.status} ${await up.text()}`);
}

Twelve lines replacing the entire manual grind. The chunk arrives as an arrayBuffer, becomes a Buffer, and goes straight back out as application/octet-stream. Never parsed, never written to disk, never touched. The docs are explicit that you must not decompress, decrypt or modify the binary in transit, and the easiest way to respect that is to never give yourself the chance.

The loop is strictly sequential, so chunk N uploads before chunk N+1 downloads. The API does allow parallel chunk retrieval and I may revisit that, but the destination reassembles by index, and even a transfer big enough to need two dozen chunks finishes in well under a minute. Determinism beat throughput.

Completing the chunk set on the destination

The last two calls are almost anticlimactic after the chunk loop. Step 5 tells the destination the set is complete, so it reassembles the chunks into a .raif. Step 6 hands that file to the Item Transfer API, which finally writes items into the tree. Between them is the gap that catches people out, because everything up to and including step 5 can succeed while the content tree stays exactly as it was. You can see it here:

const res = await jsonReq('POST', `${CT_BASE(dst.host)}/transfers/${transferId}/chunksets/${csId}/complete`, dstToken);
if (!res.ok) throw new Error(`Complete chunk set ${csId} failed: ${res.status} ${await res.text()}`);

Followed by:

const blobName = `contentTransfer-${transferId}-${csId}.raif`;
const url = `${IT_BASE(dst.host)}/transfers/databases/${database}/sources?blobName=${encodeURIComponent(blobName)}`;
const res = await jsonReq('POST', url, dstToken);
if (!res.ok) throw new Error(`Consume RAIF ${blobName} failed: ${res.status} ${await res.text()}`);
const loc = res.headers.get('location');   // the only real proof it landed

Run it

If you build something like this, add a "dry run" mode. It makes things safer when you want to test that you're pointing at the right environments with the right content, and it validates all your paths.

node migrate.mjs .\home.items.json --dry-run   # source-side only, destination untouched
node migrate.mjs .\home.items.json             # live

The dry run is more than a syntax check. It authenticates to both environments, creates the real transfer, polls it to completion and downloads every single chunk. So it proves your credentials, your permissions, your item paths, the chunk count, and that the source is readable. Then it skips upload, complete and consume. Nothing is written to the destination. Run it first, read the summary, then run it for real.

Output you can hand to someone else

Every stage prints a timestamped line, there's a progress bar across all chunks in the run, and it finishes with a summary:

[18:22:01] Loaded .env.local
[18:22:01] OK   Auth SOURCE token
[18:22:02] OK   Auth DESTINATION token
[18:22:03] OK   Create transfer (SOURCE) - transferId=4e3c8106-...
[18:22:14] OK   Poll status -> Completed - 1 chunk set(s), 4 chunk(s), ~120 item(s)
[18:22:14] Chunk set 1/1 (9b10f2d4-...)  chunks=4  items=120
[18:22:15]   [#####---------------]  25% (1/4)  set 1 chunk 1/4 moved (1048576 bytes, isMedia=false)
...
[18:22:41]   [####################] 100% (4/4)  set 1 chunk 4/4 moved (9004 bytes, isMedia=true)
============ MIGRATION SUMMARY ============
  [OK  ] Auth SOURCE token
  [OK  ] Auth DESTINATION token
  [OK  ] Create transfer (SOURCE)                     transferId=4e3c8106-...
  [OK  ] Poll status -> Completed                     1 chunk set(s), 4 chunk(s), ~120 item(s)
  [OK  ] Chunk set 1/1 (9b10f2d4-...): download       4/4 chunks
  [OK  ] Chunk set 1/1 (9b10f2d4-...): upload         4/4 chunks
  [OK  ] Chunk set 1/1 (9b10f2d4-...): complete
  [OK  ] Chunk set 1/1 (9b10f2d4-...): consume RAIF   source=https://.../contentTransfer-4e3c8106-...raif
  [OK  ] Verify blobs (DESTINATION)                   0 blob(s), 0 with errors
  -------------------------------------------
  9 ok, 0 failed, 0 skipped
===========================================
[18:22:42] Migration finished.

Every step is tagged OK, FAIL or SKIP. On any error it stops, records the failing step, prints the summary it has so far, and exits non-zero: 1 for a failed step, 2 for bad configuration. A report is generated at the end of each run covering what was migrated.

Just one approach to using the APIs

This is one approach to using the Content Transfer and Item Transfer APIs. GUIs are already available through the Marketplace and elsewhere, and this isn't infallible. It still requires a basic understanding of the APIs and the content structure. That said, with good documentation, a seasoned QA person or author could use this to move things around more confidently. It also makes it fast to pull content down from production and install it on a QA or preprod server.