Automating cloning Emails with SSJS and the Content Builder API

I wrote about cloning an eDM a few weeks ago — fetch the JSON, strip the IDs, post it as new. Turns out that same fetch/edit/repost loop is useful for more than cloning. Two things worth calling out: automated QA on existing builds, and generating new emails from a template without opening Content Builder.

How cloning works (step-by-step)

1. Get access token

To call the SFMC REST API, you need an access token generated from your API client credentials (Client ID + Client Secret). If you don’t have these credentials, you must create them through an Installed Package on SFMC. The process documented by Nobuyuki Watanabe (Salesforce MVP).

When configuring the package, make sure the API Integration has the correct scopes, especially:

  • Read permission for Email assets
  • Write permission for Email assets

These permissions allow your REST calls to retrieve email JSON and create new email assets programmatically.

// API credentials
var authUrl = "https://YOUR_SUBDOMAIN.auth.marketingcloudapis.com/";
var clientId = "your client id";
var clientSecret = "your client secret";

var tokenReq = new Script.Util.HttpRequest(authUrl + "v2/token");
tokenReq.contentType = "application/json";
tokenReq.method = "POST";
tokenReq.postData = Stringify({
  grant_type: "client_credentials",
  client_id: clientId,
  client_secret: clientSecret
});

var tokenResp = tokenReq.send();
var tokenObj = Platform.Function.ParseJSON(tokenResp.content);Code language: PHP (php)

2. Retrieve source email JSON

Use SSJS + REST API to fetch the asset definition:

var getReq = new Script.Util.HttpRequest(restUrl + "asset/v1/content/assets/" + sourceAssetId);
getReq.setHeader("Authorization", "Bearer " + accessToken);
getReq.contentType = "application/json";
getReq.method = "GET";

var getResp = getReq.send();
var sourceAsset = Platform.Function.ParseJSON(getResp.content);
Code language: PHP (php)

This returns the full Content Builder asset including:

  • views.html.content
  • subjectline.content
  • preheader.content
  • assetType
  • category
  • customerKey
  • objectID

3. Strip unique identifiers

Remove fields that must not be reused. This ensures SFMC treats the payload as a new asset.

delete sourceAsset.id;
delete sourceAsset.customerKey;
delete sourceAsset.objectID;
delete sourceAsset.legacyData;
delete sourceAsset.createdDate;
delete sourceAsset.modifiedDate;
delete sourceAsset.thumbnail;Code language: JavaScript (javascript)

4. Modify metadata dynamically

You can programmatically update:

  • Email name
  • Subject line
  • Preheader
  • HTML content
  • Content blocks
  • Template ID
  • Folder/category
sourceAsset.name = newName;
sourceAsset.fileProperties = { fileName: newName };
sourceAsset.category = { id: newCategoryId };
sourceAsset.status = { id: 1, name: "Draft" };Code language: JavaScript (javascript)

Cateogry ID is the folder ID. Here is the guide for finding the category ID for your destination folder.

5. Create the new email asset

POST the modified JSON back to Content Builder:

var createReq = new Script.Util.HttpRequest(restUrl + "asset/v1/content/assets");
createReq.setHeader("Authorization", "Bearer " + accessToken);
createReq.contentType = "application/json";
createReq.method = "POST";
createReq.postData = Stringify(sourceAsset);
var createResp = createReq.send();Code language: JavaScript (javascript)

This creates a new cloned email with a fresh ID and customer key.

Here is the complete source code:

<script runat="server">
Platform.Load("core", "1.1.5");

try {
  var sourceAssetId = "9190"; // Email ID
  var newName = "New eDM Name"; // New Email name
  var newCategoryId = 5806; // target folder ID

  var authUrl = "https://YOUR_SUBDOMAIN.auth.marketingcloudapis.com/";
  var clientId = "your client id";
  var clientSecret = "your client secret";

  if (!authUrl || !clientId || !clientSecret) {
    Write("Error: credentials for 'SFMC_API' were not found in ENT.API_User. Check the API_Name value.");
  } else {
    // Normalize the auth URL so a missing trailing slash doesn't silently break the token request
    if (authUrl.charAt(authUrl.length - 1) != "/") {
      authUrl += "/";
    }

    var tokenReq = new Script.Util.HttpRequest(authUrl + "v2/token");
    tokenReq.contentType = "application/json";
    tokenReq.method = "POST";
    tokenReq.postData = Stringify({
      grant_type: "client_credentials",
      client_id: clientId,
      client_secret: clientSecret
    });

    var tokenResp = tokenReq.send();
    var tokenObj = Platform.Function.ParseJSON(tokenResp.content);

    if (!tokenObj || !tokenObj.access_token) {
      Write("Error fetching access token: " + Stringify(tokenResp.content));
    } else {
      var accessToken = tokenObj.access_token;
      var restUrl = tokenObj.rest_instance_url;

      // 1. Fetch the source asset
      var getReq = new Script.Util.HttpRequest(restUrl + "asset/v1/content/assets/" + sourceAssetId);
      getReq.setHeader("Authorization", "Bearer " + accessToken);
      getReq.contentType = "application/json";
      getReq.method = "GET";

      var getResp = getReq.send();

      if (getResp.statusCode != 200) {
        Write("Error fetching source asset " + sourceAssetId + ". Status: " + getResp.statusCode +
              ", Response: " + getResp.content);
      } else {
        var sourceAsset = Platform.Function.ParseJSON(getResp.content);

        // 2. Strip identifiers that must be unique to the new asset
        delete sourceAsset.id;
        delete sourceAsset.customerKey;
        delete sourceAsset.objectID;
        delete sourceAsset.legacyData;
        delete sourceAsset.createdDate;
        delete sourceAsset.modifiedDate;
        delete sourceAsset.thumbnail;

        sourceAsset.name = newName;
        sourceAsset.fileProperties = { fileName: newName };
        sourceAsset.category = { id: newCategoryId };
        sourceAsset.status = { id: 1, name: "Draft" };

        // 3. Post it as a new asset
        var createReq = new Script.Util.HttpRequest(restUrl + "asset/v1/content/assets");
        createReq.setHeader("Authorization", "Bearer " + accessToken);
        createReq.contentType = "application/json";
        createReq.method = "POST";
        createReq.postData = Stringify(sourceAsset);

        var createResp = createReq.send();

        if (createResp.statusCode == 201) {
          var created = Platform.Function.ParseJSON(createResp.content);
          Write("Asset created. New ID: " + created.id);
        } else {
          Write("Clone failed. Status: " + createResp.statusCode + ", Response: " + createResp.content);
        }
      }
    }
  }
} catch (e) {
  Write(Stringify(e));
}
</script>Code language: HTML, XML (xml)

The part worth paying attention to is step 1 on its own. You now have programmatic, on-demand access to the complete rendered structure of any email in Content Builder, including every content block, every slot, and every attribute, as a plain JSON object you can inspect, diff, or rebuild from, without opening the drag-and-drop editor.

QA automation: check a build’s source without opening it

Once you can GET the same JSON the editor renders from, QA becomes a question you can answer with code instead of a person scrolling through a preview. A few concrete checks this opens up, using the same GET call from step 1 above (skip the strip/rename/POST steps entirely, since QA only needs to read):

  • Broken or placeholder links. Walk the HTML in views.html (or the individual block content) and flag anything pointing at localhost, a staging domain, #, or a URL that hasn’t been swapped from the template default.
  • Unresolved merge fields or personalisation strings. Search block content for %%= / %%[ patterns that look like they were left un-populated, or for literal placeholder text like [FIRST NAME] that should have been replaced with real personalisation syntax. Flag anything that still reads like a template instruction rather than finished copy.
  • Missing alt text or oversized images, by walking image blocks and checking alt attributes and dimensions against a house style rule.
  • Structural drift from the approved template. Compare the slot/block structure of a new build against a known-good reference version of the same template, and flag if a block was deleted, duplicated, or reordered unexpectedly.

None of this requires anything beyond the GET half of the script above, run against whatever sourceAssetId you want to check, on a schedule or as a pre-send gate in an automation. The output is exactly the input a QA checklist needs, just delivered as structured data instead of a page you have to read.

Programmatic email generation from a template

The clone script’s real shape is “start from a known-good asset, modify specific fields, post as new,” and nothing about that logic requires the modifications to be limited to name/folder/description. Once you’re already parsing the JSON structure, you can reach into it and change whatever’s addressable:

  • Subject line and preheader. Set directly on the asset object, the same way the script already sets name/description.
  • A specific content block, swapped from a library. If a slot references a block by contentBlockId or similar, replace that reference (or the block’s inner content) with a different asset from your content library. For example, you could swap in this month’s promo banner without touching the rest of the layout.
  • Dynamically generated copy or data-driven content. Build a string (from a data extension query, an external feed, or generated text) and write it directly into a block’s HTML content before the POST, the same way sourceAsset.name = newName works today.

Put together, this turns a single approved template into a factory: fetch it, swap in this send’s subject line, promo block, and dynamic content, and POST it as a new asset. This can run entirely unattended, from an Automation Studio script activity or a scheduled job, with no one opening Content Builder at all.

A few things worth knowing if you build on this

  • Nested blocks may carry their own IDs. If the source asset’s views.html.slots..blocks. structure includes id/customerKey/objectID on individual blocks (common for freeform blocks with saved content), Content Builder usually re-keys these automatically on creation. If you ever see a conflict error on POST, that’s the first place to check. You may need to walk the nested structure and strip those recursively.
  • Reference blocks stay shared, not duplicated. If the source eDM uses referenceblock types pointing at reusable content blocks, the clone will point at the same shared blocks, not copies. This is usually what you want for shared footers or signoffs, but don’t be surprised when editing the clone’s footer also changes the original’s.
  • Repeated execution of this operation may produce unintended results. Since Content Builder allows duplicate names, running this script twice with the same newName creates two separate assets, not an update to the first. If this runs as part of a repeatable automation, consider checking for an existing asset with that name first (via the query endpoint from an earlier post) before deciding whether to clone or skip.
  • QA read-access and generation write-access are different risk profiles. If you’re building the QA use case, the API-user credential only needs read scope on Content Builder assets. If you’re building the generation use case, it needs write/create scope too. Worth provisioning these as separate Installed Packages with separate scopes rather than one broad credential used for both, so a QA script can’t accidentally create or modify content.

Wrapping up

The clone script was never really about cloning, it’s a minimal example of “read an asset’s full structure, change what you need, write it back.” Once that pattern is in place, QA and generation aren’t separate tools you’d build from scratch; they’re the same three-step shape pointed at different fields. If your team is still doing either of these by hand, this is usually a smaller lift than it looks like from the outside.

Similar Posts