Package Designer Is Gone. Moving Content with the Transfer APIs

Alex Lentz

Title card: Package Designer Is Gone. Moving Content with the Transfer APIs. SitecoreAI post on Sitecore Saga.

If your team’s “copy content from QA to prod” process involves Package Designer, a .zip file and a prayer, this one’s for you.

Starting July 7th, 2026, Sitecore is retiring the Package Designer in SitecoreAI. It rolls out to non-production environments first, you can promote it to production yourself through July 15th, and on July 15th it’s automatically applied to production.

The replacement for moving content between SitecoreAI environments arrived just before that: the Content Transfer API and the Item Transfer API.

What replaces what

Package Designer did a few different jobs. Here’s where each one goes now:

  • Moving content between SitecoreAI environments – the Content Transfer API (source side) plus the Item Transfer API (destination side)
  • Installing modules, DLLs or config – your CI/CD pipeline. SitecoreAI environments are immutable, so that was never really a great idea anyway
  • Migrating from on-prem XM/XP – the migration tooling (and Pathway), not packages
  • Small ad-hoc item loads – the Item Transfer API can consume a .raif file uploaded directly (100 MB limit)

How the two APIs fit together

The mental model:

  1. Source environment: you ask the Content Transfer API to package one or more content trees. It builds the package as encrypted, compressed chunks.
  2. Your script downloads each chunk from the source and uploads it, untouched, to the destination.
  3. Destination environment: completing each chunk set produces a .raif file. The Item Transfer API then consumes that file into the target database.

Each content tree you send gets three settings:

  • ItemPath – where to start
  • Scope – SingleItem or ItemAndDescendants
  • MergeStrategy – OverrideExistingItem, KeepExistingItem or OverrideExistingTree

Be careful with OverrideExistingTree. It’s the only strategy that deletes things – it removes the item and all its descendants in the destination before writing the transferred items. Great for “make QA’s /insights look exactly like dev’s”. Terrible if you point it at the wrong path.

Before you start

  • You need to be an Organization Admin or Organization Owner
  • Get the environment host name for both environments from SitecoreAI Deploy (Projects > your project > Authoring environments > your environment > Details)
  • Create automation client credentials for both environments – each side gets its own token

The script

Here’s the end-to-end flow as a single Node script. It creates the transfer on the source, waits for it, streams every chunk across, completes the chunk sets, consumes the resulting .raif files on the destination, waits for the sync to finish and cleans up after itself.

// transfer-content.ts - move a content tree between SitecoreAI environments
// Run with: node --experimental-strip-types transfer-content.ts   (Node 22.6+)
import { randomUUID } from 'node:crypto';

const SOURCE_HOST = process.env.SOURCE_HOST!; // e.g. xmc-myorg-myproj-dev.sitecorecloud.io
const DEST_HOST = process.env.DEST_HOST!;
// The Item Transfer API base path is in the API spec - set it once here
const ITEM_TRANSFER_BASE = process.env.ITEM_TRANSFER_BASE!; // e.g. https://<dest-host>/<item-transfer-path>
const DATABASE = 'master';

const DATA_TREES = [
  { ItemPath: '/sitecore/content/MySite/Home/Insights', Scope: 'ItemAndDescendants', MergeStrategy: 'OverrideExistingItem' },
];

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

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

async function api(url: string, token: string, init: RequestInit = {}) {
  const res = await fetch(url, {
    ...init,
    headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
  });
  if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${url} -> ${res.status} ${await res.text()}`);
  return res;
}

async function main() {
  const srcToken = await getToken(process.env.SOURCE_CLIENT_ID!, process.env.SOURCE_CLIENT_SECRET!);
  const dstToken = await getToken(process.env.DEST_CLIENT_ID!, process.env.DEST_CLIENT_SECRET!);

  const transferId = randomUUID();
  const srcBase = `https://${SOURCE_HOST}/sitecore/api/content/transfer/v1/transfers`;
  const dstBase = `https://${DEST_HOST}/sitecore/api/content/transfer/v1/transfers`;

  // 1. Create the transfer on the source
  await api(srcBase, srcToken, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ TransferId: transferId, Configuration: { DataTrees: DATA_TREES, Database: DATABASE } }),
  });
  console.log(`Created transfer ${transferId}`);

  // 2. Wait for the source to finish packaging
  let status: any;
  do {
    await sleep(5000);
    status = await (await api(`${srcBase}/${transferId}/status`, srcToken)).json();
    console.log(`  source state: ${status.State}`);
    if (status.State === 'Failed') throw new Error('Source transfer failed - create a new one');
  } while (status.State !== 'Completed');

  const raifFiles: string[] = [];

  // 3-5. Stream every chunk from source to destination, then complete each chunk set
  for (const set of status.ChunkSetsMetadata) {
    for (let chunk = 0; chunk < set.ChunkCount; chunk++) {
      const path = `${transferId}/chunksets/${set.ChunkSetId}/chunks/${chunk}`;
      const src = await api(`${srcBase}/${path}`, srcToken);
      const isMedia = /IsMedia=true/i.test(src.headers.get('content-disposition') ?? '');

      await api(`${dstBase}/${path}?isMedia=${isMedia}`, dstToken, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/octet-stream' },
        body: Buffer.from(await src.arrayBuffer()), // as-is: no decompressing, no decrypting
      });
    }
    const done = await (
      await api(`${dstBase}/${transferId}/chunksets/${set.ChunkSetId}/complete`, dstToken, { method: 'POST' })
    ).json();
    raifFiles.push(done.ContentTransferFileName);
    console.log(`  chunk set ${set.ChunkSetId} -> ${done.ContentTransferFileName}`);
  }

  // 6. Clean up the source side
  await api(`${srcBase}/${transferId}`, srcToken, { method: 'DELETE' });

  // 8-9. Consume each .raif file on the destination and wait for the sync to finish
  for (const blobName of raifFiles) {
    const res = await api(
      `${ITEM_TRANSFER_BASE}/transfers/databases/${DATABASE}/sources?blobName=${encodeURIComponent(blobName)}`,
      dstToken,
      { method: 'POST' }
    );
    const consumeId = res.headers.get('location')!.split('/').pop()!;

    let state: string;
    do {
      await sleep(5000);
      state = (await (await api(`${ITEM_TRANSFER_BASE}/transfers/${consumeId}`, dstToken)).json()).TransferState;
      console.log(`  ${blobName}: ${state}`);
      if (state === 'Failed') throw new Error(`Consuming ${blobName} failed - check ValidationErrors and retry`);
    } while (state !== 'Finished');

    // 10. Remove the blob once it's in
    await api(`${ITEM_TRANSFER_BASE}/sources/blobs/${encodeURIComponent(blobName)}`, dstToken, { method: 'DELETE' });
  }

  console.log('Done.');
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Run it with your credentials in environment variables:

export SOURCE_HOST=xmc-myorg-myproj-dev.sitecorecloud.io
export DEST_HOST=xmc-myorg-myproj-qa.sitecorecloud.io
export ITEM_TRANSFER_BASE=https://...   # from the Item Transfer API spec
export SOURCE_CLIENT_ID=... SOURCE_CLIENT_SECRET=...
export DEST_CLIENT_ID=... DEST_CLIENT_SECRET=...

node --experimental-strip-types transfer-content.ts

A few honest notes about the script:

  • It’s sequential to keep it readable. Chunks can be fetched and saved in parallel, so for big trees, add a small concurrency pool around the chunk loop.
  • Don’t touch the chunk bytes. No decompressing, decrypting or re-encoding – upload exactly what you downloaded.
  • Check the IsMedia handling against the spec. The value comes from the Content-Disposition header on the chunk download, and it has to be passed through as the isMedia query parameter when you save the chunk.
  • Set ITEM_TRANSFER_BASE from the Item Transfer API spec. The Content Transfer endpoints live under /sitecore/api/content/transfer/v1 on each environment’s host.

Gotchas to watch for

Items transfer but don’t show up in the tree. If you use ItemAndDescendants on /sitecore/content/MySite/Home/Insights, the parent chain (MySite, Home) has to already exist in the destination with the same item IDs. If it doesn’t, the items land but aren’t attached to anything visible. Transfer from a common ancestor, or transfer the parents first.

“TransferredWithErrors” isn’t a crash. It’s a terminal partial success. Call GET /transfers/{id} on the destination and read the ValidationErrors list to see what didn’t make it.

Items show up before the sync finishes. The destination makes transferred items available right away while the database sync runs in the background. Keep polling until TransferState is Finished before you call it done – if the sync fails, items that weren’t synced yet disappear and you have to retry.

Where this fits in our workflow

Content still mostly lives where authors create it, and code still goes through the pipeline. But for the “seed this environment with realistic content” and “promote this section after UAT” jobs, this is now a scripted, repeatable step instead of a manual one. Wire the script into a pipeline with an approval gate and it becomes a button instead of a chore.

Resources

Alex Lentz

Alex Lentz

Senior Solutions Architect at Americaneagle.com and two-time Sitecore Technology MVP. Building on Sitecore since 2013, mostly SitecoreAI and headless Next.js these days.

LinkedIn    GitHub

Keep reading