Inventory of Data Extensions and their data sources

It’s very likely that at some point in your career you will inherit or take over a Salesforce Marketing Cloud instance that has been set up and used by someone else in the past. In case it hasn’t been well documented, it can be a struggle to understand all the data related processes and clean up all the unused Data Extensions.

Gregory Gifford has written a very useful blog post, where he explains how to create a Data Extension Inventory for your SFMC Business Unit. Using the code snippets he provided, you will be able to populate a complete list of all the Data Extensions in your Business Unit, along with their properties:

Source: https://gortonington.com/data-extension-inventory-for-your-sfmc-business-unit/

But where does the data in those Data Extensions come from? Of course, it won’t be possible to track everything – things like manual data imports, data uploaded through scripts or coming in through API won’t leave any trace in the system. Fortunately, we can track data-related activities that are scheduled in Automation Studio: Imports and Queries. We will use two SOAP API objects to obtain all the information we need: ImportDefinition and QueryDefinition.

But before we do that, let’s first create a Data Extension where we will populate the results of our calls. In the script, we are going to retrieve the following data:

  • Data Extension’s Name,
  • Data Extension’s External Key,
  • the name of the activity in Automation Studio which populates the data,
  • the type of the activity (Import/Query),
  • the description of the activity if one has been set up.

Here’s the Data Extension structure you will need:

Retrieve the ImportDefinition data

We will use WSProxy to retrieve information about Imports that have been set up and used in Automation Studio and to get information about Data Extensions they populate with data. The properties we will use in our script are the following:

  • Name – name of the Import Definition,
  • Description – description of the Import Definition,
  • ObjectID – ID of the Import Definition,
  • DestinationObject.ObjectID – the ID of the destination. In this context, this could be either a Data Extension or a List, so we will need to filter out Lists later on.

As you can see above, we can get only as much as the Data Extension’s ObjectID from the ImportDefinition object, so we will add another call to our script. We will call the DataExtension object to check the Name and the CustomerKey of that Data Extension using the ObjectID value to match it correctly.

Let’s combine all of the above together and add pagination, so that we are not limited by the fact that most API objects have a predefined batch size of 2500. Here’s the full function: [see code snippet]

function getImports(objectType, cols) {
var prox = new Script.Util.WSProxy(),
objectType = "ImportDefinition",
cols = ["DestinationObject.ObjectID", "Description", "Name", "ObjectID"],
moreData = true,
reqID = null,
numItems = 0;
while (moreData) {
moreData = false;
var data = reqID == null ? prox.retrieve(objectType, cols) : prox.getNextBatch(objectType, reqID);
if (data != null) {
moreData = data.HasMoreRows;
reqID = data.RequestID;
if (data && data.Results) {
for (var i = 0; i < data.Results.length; i++) {
if (data.Results[i].DestinationObject.ObjectID) {
var cols = ["Name", "CustomerKey", "ObjectID"];
var filter = {
Property: "ObjectID",
SimpleOperator: "equals",
Value: data.Results[i].DestinationObject.ObjectID
};
var deName = prox.retrieve("DataExtension", cols, filter);
if (deName.Results[0].Name) {
logDE.Rows.Add({
DEName: deName.Results[0].Name,
DEKey: deName.Results[0].CustomerKey,
ActivityName: data.Results[i].Name,
ActivityType: "Import",
ActivityDescription: data.Results[i].Description
});
}
}
numItems++;
}
}
}
}
Write("<br />" + numItems + " total " + objectType + "<br>");
}
view raw get-imports.js hosted with ❤ by GitHub

Retrieve the QueryDefinition data

The QueryDefinition object is a bit easier to work with because SQL queries in Salesforce Marketing Cloud only have one type of destination: a Data Extension. That’s why the QueryDefinition object will be able to return everything we need in one call. The properties we will use in our script are the following:

  • Name – name of the Query Definition,
  • Description – description of the Query Definition,
  • DataExtensionTarget.Name – name of the target Data Extension,
  • DataExtensionTarget.CustomerKey – external key of the target Data Extension.

Again, we will add pagination to make sure we retrieve everything. Here’s the full function: [see code snippet]

function getQueries(objectType, cols) {
var prox = new Script.Util.WSProxy(),
objectType = "QueryDefinition",
cols = ["Name", "Description", "ObjectID", "DataExtensionTarget.Name", "DataExtensionTarget.CustomerKey"],
moreData = true,
reqID = null,
numItems = 0;
while (moreData) {
moreData = false;
var data = reqID == null ? prox.retrieve(objectType, cols) : prox.getNextBatch(objectType, reqID);
if (data != null) {
moreData = data.HasMoreRows;
reqID = data.RequestID;
if (data && data.Results) {
for (var i = 0; i < data.Results.length; i++) {
logDE.Rows.Add({
DEName: data.Results[i].DataExtensionTarget.Name,
DEKey: data.Results[i].DataExtensionTarget.CustomerKey,
ActivityName: data.Results[i].Name,
ActivityType: "Query",
ActivityDescription: data.Results[i].Description
});
numItems++;
}
}
}
}
Write("<br />" + numItems + " total " + objectType + "<br>");
}
view raw get-queries.js hosted with ❤ by GitHub

The full script

Now let’s combine everything together and run our script – you can either run it on a CloudPage, or in Automation Studio. Note, that depending on the volume of activities in your account, this script can take even several minutes to resolve. All you will need to do with below script is to provide the External Key of the Data Extension you created earlier, which will hold the results of our calls: [see code snippet]

<script runat="server">
Platform.Load("Core", "1");
try {
//initiate WSProxy
var prox = new Script.Util.WSProxy();
//initiate data extension
var targetDE = 'xxxxxxx-xxxxxxx-xxxxxxxxx'; //pass external key of the target DE
var logDE = DataExtension.Init(targetDE);
function getImports(objectType, cols) {
var objectType = "ImportDefinition",
cols = ["DestinationObject.ObjectID", "Description", "Name", "ObjectID"],
moreData = true,
reqID = null,
numItems = 0;
while (moreData) {
moreData = false;
var data = reqID == null ? prox.retrieve(objectType, cols) : prox.getNextBatch(objectType, reqID);
if (data != null) {
moreData = data.HasMoreRows;
reqID = data.RequestID;
if (data && data.Results) {
for (var i = 0; i < data.Results.length; i++) {
if (data.Results[i].DestinationObject.ObjectID) {
var cols = ["Name", "CustomerKey", "ObjectID"];
var filter = {
Property: "ObjectID",
SimpleOperator: "equals",
Value: data.Results[i].DestinationObject.ObjectID
};
var deName = prox.retrieve("DataExtension", cols, filter);
if (deName.Results[0].Name) {
logDE.Rows.Add({
DEName: deName.Results[0].Name,
DEKey: deName.Results[0].CustomerKey,
ActivityName: data.Results[i].Name,
ActivityType: "Import",
ActivityDescription: data.Results[i].Description
});
}
}
numItems++;
}
}
}
}
Write("<br />" + numItems + " total " + objectType + "<br>");
}
function getQueries(objectType, cols) {
var objectType = "QueryDefinition",
cols = ["Name", "Description", "ObjectID", "DataExtensionTarget.Name", "DataExtensionTarget.CustomerKey"],
moreData = true,
reqID = null,
numItems = 0;
while (moreData) {
moreData = false;
var data = reqID == null ? prox.retrieve(objectType, cols) : prox.getNextBatch(objectType, reqID);
if (data != null) {
moreData = data.HasMoreRows;
reqID = data.RequestID;
if (data && data.Results) {
for (var i = 0; i < data.Results.length; i++) {
logDE.Rows.Add({
DEName: data.Results[i].DataExtensionTarget.Name,
DEKey: data.Results[i].DataExtensionTarget.CustomerKey,
ActivityName: data.Results[i].Name,
ActivityType: "Query",
ActivityDescription: data.Results[i].Description
});
numItems++;
}
}
}
}
Write("<br />" + numItems + " total " + objectType + "<br>");
}
getImports(objectType, cols)
getQueries(objectType, cols)
} catch (e) {
Write(Stringify(e));
}
</script>

Questions? Comments?

Leave a comment below or email me at zuzanna@sfmarketing.cloud.

Add subscribers to a Journey from a CloudPage using Fire Entry Event

Firing an Entry Event is great for injecting contacts into a Salesforce Marketing Cloud journey, especially if you need to be able to inject them from an external system or a website.

It’s also useful when you’re creating a custom form on a CloudPage and would like to replicate the behavior of SmartCapture forms, which allow injecting contacts to a journey upon form submission (real-time).

In order to use the Fire Entry Event on a CloudPage, we will build a simple form to capture the end user’s email address and we will use REST API to post the collected information to /interaction/v1/events route using Server-Side JavaScript. But first, let’s start with creating a new journey in Salesforce Marketing Cloud’s Journey Builder.

API Event Entry Source

When you create the journey for use with a custom form on a CloudPage, the only difference in the setup is the entry source, which in this case will be the API Event.

The API event in Journey Builder connects the journey canvas to an API used to admit contacts into a journey. When the API fires an event, the contacts entering the journey are stored in a Marketing Cloud data extension you choose. You can set a filter using Marketing Cloud data attributes to ensure that only intended customers enter the journey.

When you drag and drop the API Event into your journey’s canvas, click on it and choose to create an event. You will be prompted to choose a Data Extension for use with the journey and you will also see that an Event Definition Key has been created for your journey – copy it, as we will need it later for our script. Activate the journey.

Custom form on a CloudPage

We can now create our form on a CloudPage. For the purpose of this tutorial, it will be a very simple form collecting just the end user’s email address. We will also add some AMPscript to process the data from the form. If you haven’t created a form on a CloudPage yet, you might want to check out this article first: Create a Sales Cloud-integrated lead capture form using AMPscript. Here’s a basic form for our use case:

%%[
SET @Submit = RequestParameter("Submit")
IF @Submit != "Success" THEN
]%%
<h2>Email:</h2>
<form action="%%=RequestParameter('PAGEURL')=%%" method="GET">
<label for="email">Email address:</label>
<input type="email" id="email" name="email" required="yes">
<button type="submit" name="Submit" id="Submit" value="Success">Submit</button>
</form>
%%[
ENDIF
]%%
%%[
SET @email = RequestParameter("email")
IF @email != "" THEN
]%%
<! –– fire entry event script placeholder ––>
%%[
ENDIF
]%%
view raw simple-form-FEE.html hosted with ❤ by GitHub

Fire an Entry Event using API

In order to inject a contact from our form into a journey, we will use Salesforce Marketing Cloud’s REST API, specifically the /interaction/v1/events route. Here’s an example request:

Host: https://YOUR_SUBDOMAIN.rest.marketingcloudapis.com
POST /interaction/v1/events
Content-Type: application/json
Authorization: Bearer YOUR_ACCESS_TOKEN
{
"ContactKey": "ID601",
"EventDefinitionKey":"AcmeBank-AccountAccessed",
"Data": {
"accountNumber":"123456",
"patronName":"John Smith" }
}

And an example response, which would indicate that the request has been processed correctly:

HTTP/1.1 201
{
"eventInstanceId": "########-####-####-####-############"
}

Like with any other API call, we will need to start with authentication. If you are not familiar with the concept, check out my other article, Salesforce Marketing Cloud API Authentication using Server-Side JavaScript.

As a security measure, it’s best to store your Client Id and Secret as encoded values in a Data Extension to avoid exposing them in your script. My preferred way is to use the EncryptSymmetric and DecryptSymmetric AMPscript functions for encryption, and a simple lookup to get their values from a Data Extension.

Here’s how the authentication part could look like:

//authenticate to get access token
var authEndpoint = 'https://mcxxx.auth.marketingcloudapis.com/&#39; //provide API endpoint
var payload = {
client_id: "xxxxxx", //pass Client ID
client_secret: "xxxxxx", //pass Client Secret
grant_type: "client_credentials"
};
var url = authEndpoint + '/v2/token'
var contentType = 'application/json'
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if (accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.access_token
var rest_instance_url = tokenResponse.rest_instance_url
};

Once we obtain the access_token and the rest_instance_url, we can move on to our actual call. We will pass the access_token in the authentication header, and in the payload, we will pass the data collected through the form and our journey’s event definition key. To get the form data, we will use an undocumented Server-Side JavaScript function, Request.GetQueryStringParameter(). Remember to include all the values passed in the payload in your target Data Extension, which you chose earlier upon creating the journey. Here we have just one such field, called email_field:

//make api call to inject contact to a journey
if (email != null && accessToken != null) {
var email = Request.GetQueryStringParameter("email");
var headerNames = ["Authorization"];
var headerValues = ["Bearer " + accessToken];
var jsonBody = {
"ContactKey": email,
"EventDefinitionKey": "APIEvent-xxxxxx", //provide journey EVENT DEFINITION KEY
"Data": {
"email_field": email
}
};
var requestUrl = rest_instance_url + "/interaction/v1/events";
var fireEntryEvent = HTTP.Post(requestUrl, contentType, Stringify(jsonBody), headerNames, headerValues);

The full script

If you put all the above together, you should end up with something like this:

%%[
SET @Submit = RequestParameter("Submit")
IF @Submit != "Success" THEN
]%%
<h2>Email:</h2>
<form action="%%=RequestParameter('PAGEURL')=%%" method="GET">
<label for="email">Email address:</label>
<input type="email" id="email" name="email" required="yes">
<button type="submit" name="Submit" id="Submit" value="Success">Submit</button>
</form>
%%[
ENDIF
]%%
%%[
SET @email = RequestParameter("email")
IF @email != "" THEN
]%%
<script runat="server">
Platform.Load("Core", "1");
try {
//authenticate to get access token
var authEndpoint = 'https://xxxx.auth.marketingcloudapis.com/&#39; //provide API endpoint
var payload = {
client_id: "xxxxxx", //pass Client ID
client_secret: "xxxxxx", //pass Client Secret
grant_type: "client_credentials"
};
var url = authEndpoint + '/v2/token'
var contentType = 'application/json'
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if (accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.access_token
var rest_instance_url = tokenResponse.rest_instance_url
};
//make api call to inject contact to a journey
if (email != null && accessToken != null) {
var email = Request.GetQueryStringParameter("email");
var headerNames = ["Authorization"];
var headerValues = ["Bearer " + accessToken];
var jsonBody = {
"ContactKey": email,
"EventDefinitionKey": "APIEvent-xxxxxxx", //provide journey EVENT DEFINITION KEY
"Data": {
"email_field": email
}
};
var requestUrl = rest_instance_url + "/interaction/v1/events";
var fireEntryEvent = HTTP.Post(requestUrl, contentType, Stringify(jsonBody), headerNames, headerValues);
if (fireEntryEvent.StatusCode == 201) {
Write("Success");
}
};
} catch (error) {
Write("Error");
}
</script>
%%[
ENDIF
]%%

If you would like to see this script in action, enter your email address on a demo CloudPage I set up: https://pub.s10.exacttarget.com/hkphfaftk4b


Questions? Comments?

Leave a comment below or email me at zuzanna@sfmarketing.cloud.

Create a CloudPages form with an image/file upload option

The HTML <input> tag specifies an input field where the user can enter data. An input field can vary in many ways, depending on the type attribute. The <input> elements with type="file" let the user choose one or more files from their device storage. The file can then be manipulated using JavaScript in order to process it further.

In this article, we will focus on creating a CloudPages form with an image input field, and uploading that image into Marketing Cloud’s Content Builder. The high-level outline of this process, which can be found in this Stack Exchange post, is: once the file has been uploaded via the form, it needs to be Base64 encoded, and then passed in a REST API call to the /asset/v1/content/assets route in order to create the image in Content Builder.

We will need to create two separate CloudPages: the first one will run client-side JavaScript in order to encode the file into a Base64 string and it will then pass it to the second CloudPage, which will run server-side JavaScript to make an API call to create a new asset in Content Builder.

I will break it down into smaller parts to make it easier to understand the different steps along the way.

Create an input field

Let’s start with creating an <input> field on a CloudPage. The input needs to be of the type="file" in order to allow the end-user to choose a file for upload from their local machine. We will also add an accept attribute, which specifies a filter for what file types the user can pick from the file input dialogue box. For our use case, it will be image/*: [see code snippet]

Please select a file to upload:
<br>
<input id="file" type="file" accept="image/*">
<br>
<button id="button">Upload</button>
view raw image-input.html hosted with ❤ by GitHub

We also need to include an id attribute in order to be able to reference the input field in JavaScript.

Encode the image using Base64

Base64 is a group of binary-to-text encoding schemes which enable storing and transferring data over media that are designed to deal with ASCII (text) only. In order to encode the image, we will use the FileReader.readAsDataURL() method to securely read the contents of a file stored on the user’s computer and return a result attribute containing a data: URL representing the file’s data. We will use the addEventListener() method to attach a click event to the Upload button, which will trigger the encoding function: [see code snippet]

document.getElementById('button').addEventListener('click', function() {
var files = document.getElementById('file').files;
if (files.length > 0) {
getBase64(files[0]);
}
});
function getBase64(file) {
var reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function () {
console.log(reader.result);
};
reader.onerror = function (error) {
console.log('Error: ', error);
};
}

The format of the returned result is the following:

data:[<mediatype>][;base64],<data>

The mediatype is a MIME type string, such as ‘image/jpeg’ for a JPEG image file and <data> is the Base64 encoded string representing the uploaded file.

Fetch the data to processing page

We now need to pass the data onto our second CloudPage for further processing. The reason why we are using two pages instead of one is that client-side JavaScript is run after the server-side is complete, which makes it impossible to run them in reverse order on the same page.

In order to pass the data between our CloudPages, we will use the fetch() method. But first, let’s prepare the data that we will need for our API call: [see code snippet]

var fileEncoded = reader.result;
var base64enc = fileEncoded.split(";base64,")[1];
var fullFileName = document.getElementById("file").files[0].name;
var fileName = fullFileName.split(".")[0];
var assetName = fullFileName.split(".")[1];
  • base64enc is the encoded string which represents the image
  • fullFileName is the full name of the file uploaded by the user (eg. “astro.png”)
  • fileName is the first part of the file name, before the filename extension (eg. “astro”)
  • assetName is the filename extension (eg. “png”)

Once we have the above, we can POST it in JSON format to the processing page that will contain the server-side script. Our request will look like this: [see code snippet]

fetch("https://pub.s10.exacttarget.com/xxxxxxx&quot;, { //provide URL of the processing page
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
base64enc: base64enc,
fileName: fileName,
assetName: assetName
})
})
.then(function(res) {
window.alert("Success!");
})
.catch(function(err) {
window.alert("Error!");
});
view raw fetch-data.js hosted with ❤ by GitHub

where the method is POST, headers specify that the content-type is JSON and in the JSON body we will have three attribute-value pairs, base64enc, fileName and assetName.

The fetch() method argument is the path to the CloudPage containing our server-side script, so, for now, you can create an empty CloudPage just to obtain the link.

The fetch() method returns a promise that resolves to a response to that request, whether it is successful or not: .then(function(res) / .catch(function(err).

The full client-side script

Let’s now put all of the above elements together to create our first CloudPage. The complete code will look like this: [see code snippet]

Please select a file to upload:
<br>
<input id="file" type="file" accept="image/*">
<br>
<button id="button">Upload</button>
<script runat="client">
document.getElementById("button")
.addEventListener("click", function() {
var files = document.getElementById("file").files;
if (files.length > 0) {
getBase64(files[0]);
}
});
function getBase64(file) {
var reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function() {
//prepare data to pass to processing page
var fileEncoded = reader.result;
var base64enc = fileEncoded.split(";base64,")[1];
var fullFileName = document.getElementById("file").files[0].name;
var fileName = fullFileName.split(".")[0];
var assetName = fullFileName.split(".")[1];
fetch("https://pub.s10.exacttarget.com/xxxxxxxxx&quot;, { //provide URL of the processing page
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
base64enc: base64enc,
fileName: fileName,
assetName: assetName
})
})
.then(function(res) {
window.alert("Success!");
})
.catch(function(err) {
window.alert("Error!");
});
};
reader.onerror = function(error) {
console.log('Error: ', error);
};
}
</script>

Now we can move on and prepare the second part of the script.

Retrieve the posted data

In order to retrieve the data posted from our first CloudPage, we will use the getPostData() server-side JavaScript method, which sadly is not documented in the official Server-Side JavaScript Syntax Guide, but you can read about it in this post on Salesforce Stack Exchange. Once we access the body of our request, we can parse the data using the ParseJSON() function: [see code snippet]

//retrieve posted data
var jsonData = Platform.Request.GetPostData();
var obj = Platform.Function.ParseJSON(jsonData);
view raw get-posted-data.js hosted with ❤ by GitHub

Prepare the data for API call

Now we can prepare the data for our API call. Let’s take a look at an example JSON body of a create asset request: [see code snippet]

POST /asset/v1/content/assets
{
"name": "Astro",
"assetType": {
"name": "png",
"id": 28
},
"file": "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"
}

In the above, the first name represents the name of the file. The second parameter assetType, consists of a name which represents the file extension and an id of the asset type. Asset types are the file or content types supported by Content Builder and the full list can be found here: List of Asset Types.

Let’s prepare the data and match the asset type based on the name of the file extension: [see code snippet]

//prepare data for API call
var base64enc = obj.base64enc;
var fileName = obj.fileName;
var assetName = obj.assetName;
//match asset type with uploaded file (https://developer.salesforce.com/docs/atlas.en-us.noversion.mc-apis.meta/mc-apis/base-asset-types.htm)
var assetTypes = { ai: 16, psd: 17, pdd: 18, eps: 19, gif: 20, jpe: 21, jpeg: 22, jpg: 23, jp2: 24, jpx: 25, pict: 26, pct: 27, png: 28, tif: 29, tiff: 30, tga: 31, bmp: 32, wmf: 33, vsd: 34, pnm: 35, pgm: 36, pbm: 37, ppm: 38, svg: 39, "3fr": 40, ari: 41, arw: 42, bay: 43, cap: 44, crw: 45, cr2: 46, dcr: 47, dcs: 48, dng: 49, drf: 50, eip: 51, erf: 52, fff: 53, iiq: 54, k25: 55, kdc: 56, mef: 57, mos: 58, mrw: 59, nef: 60, nrw: 61, orf: 62, pef: 63, ptx: 64, pxn: 65, raf: 66, raw: 67, rw2: 68, rwl: 69, rwz: 70, srf: 71, sr2: 72, srw: 73, x3f: 74, "3gp": 75, "3gpp": 76, "3g2": 77, "3gp2": 78, asf: 79, avi: 80, m2ts: 81, mts: 82, dif: 83, dv: 84, mkv: 85, mpg: 86, f4v: 87, flv: 88, mjpg: 89, mjpeg: 90, mxf: 91, mpeg: 92, mp4: 93, m4v: 94, mp4v: 95, mov: 96, swf: 97, wmv: 98, rm: 99, ogv: 100, indd: 101, indt: 102, incx: 103, wwcx: 104, doc: 105, docx: 106, dot: 107, dotx: 108, mdb: 109, mpp: 110, ics: 111, xls: 112, xlsx: 113, xlk: 114, xlsm: 115, xlt: 116, xltm: 117, csv: 118, tsv: 119, tab: 120, pps: 121, ppsx: 122, ppt: 123, pptx: 124, pot: 125, thmx: 126, pdf: 127, ps: 128, qxd: 129, rtf: 130, sxc: 131, sxi: 132, sxw: 133, odt: 134, ods: 135, ots: 136, odp: 137, otp: 138, epub: 139, dvi: 140, key: 141, keynote: 142, pez: 143, aac: 144, m4a: 145, au: 146, aif: 147, aiff: 148, aifc: 149, mp3: 150, wav: 151, wma: 152, midi: 153, oga: 154, ogg: 155, ra: 156, vox: 157, voc: 158, "7z": 159, arj: 160, bz2: 161, cab: 162, gz: 163, gzip: 164, iso: 165, lha: 166, sit: 167, tgz: 168, jar: 169, rar: 170, tar: 171, zip: 172, gpg: 173, htm: 174, html: 175, xhtml: 176, xht: 177, css: 178, less: 179, sass: 180, js: 181, json: 182, atom: 183, rss: 184, xml: 185, xsl: 186, xslt: 187, md: 188, markdown: 189, as: 190, fla: 191, eml: 192, text: 193, txt: 194, freeformblock: 195, textblock: 196, htmlblock: 197, textplusimageblock: 198, imageblock: 199, abtestblock: 200, dynamicblock: 201, stylingblock: 202, einsteincontentblock: 203, webpage: 205, webtemplate: 206, templatebasedemail: 207, htmlemail: 208, textonlyemail: 209, socialshareblock: 210, socialfollowblock: 211, buttonblock: 212, layoutblock: 213, defaulttemplate: 214, smartcaptureblock: 215, smartcaptureformfieldblock: 216, smartcapturesubmitoptionsblock: 217, slotpropertiesblock: 218, externalcontentblock: 219, codesnippetblock: 220, rssfeedblock: 221, formstylingblock: 222, referenceblock: 223, imagecarouselblock: 224, customblock: 225, liveimageblock: 226, livesettingblock: 227, contentmap: 228, jsonmessage: 230 };
var assetTypeID = assetTypes[assetName];

If you’re only going to upload images, you don’t need to include the whole list of asset types – the ones that start with “2” will be sufficient.

Create an asset using REST API

In order to make an API call using the /asset/v1/content/assets route, we first need to authenticate. You will need the Installed Package for API integration, the Client Id and Cient Secret. If you’re not sure how to authenticate programmatically in Salesforce Marketing Cloud, refer to my article, Salesforce Marketing Cloud API Authentication using Server-Side JavaScript.

As a security measure, it’s best to store your Client Id and Secret as encoded values in a Data Extension to avoid exposing them in your script. My preferred way is to use the EncryptSymmetric and DecryptSymmetric AMPscript functions for encryption, and a simple lookup to get their values from a Data Extension.

Here’s the code snippet for the authentication part:

<script runat="server">
Platform.Load("Core","1.1.1");
var authEndpoint = 'https://mcxxxxxxxxxxxxxxxxxxxxxxxxx.auth.marketingcloudapis.com&#39;;
var payload = {
client_id: "insert Client Id",
client_secret: "insert Client Secret",
grant_type: "client_credentials"
};
var url = authEndpoint + '/v2/token';
var contentType = 'application/json';
try {
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if(accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.access_token;
}
} catch (error) {
Write(Stringify(error));
}
</script>

Once we obtain the accessToken, we will need to include it in our final API call, along with the rest_instance_url: [see code snippet]

var headerNames = ["Authorization"];
var headerValues = ["Bearer " + accessToken];
var jsonBody = {
"name": fileName,
"assetType": {
"name": assetName,
"id": assetTypeID
},
"file": base64enc
};
var requestUrl = rest_instance_url + "asset/v1/content/assets"
var createAsset = HTTP.Post(requestUrl, contentType, Stringify(jsonBody), headerNames, headerValues);

The above call will create an image in Content Builder’s main folder and return the following response: [see code snippet]

{
"id":xxx,
"customerKey":"xxx",
"objectID":"xxx",
"assetType":{
"id":23,
"name":"jpg",
"displayName":"Image"
},
"fileProperties":{
"extension":"jpg",
"fileSize":24328,
"fileCreatedDate":"xxx",
"width":700,
"height":421,
"publishedURL":"xxxx"
},
"name":"astro",
"owner":{
"id":xxx,
"email":"",
"name":"APIuser",
"userId":"xxx"
},
"createdDate":"xxx",
"createdBy":{
"id":xxx,
"email":"",
"name":"APIuser",
"userId":"xxx"
},
"modifiedDate":"xxx",
"modifiedBy":{
"id":xxx,
"email":"",
"name":"APIuser",
"userId":"xxx"
},
"enterpriseId":xxx,
"memberId":xxx,
"status":{
"id":2,
"name":"Published"
},
"category":{
"id":xxx8,
"name":"Content Builder",
"parentId":0
},
"availableViews":[
],
"modelVersion":2
}

The full server-side script

Here is the full script for our second CloudPage: [see code snippet]

<script runat="server">
Platform.Load("Core","1");
try {
//fetch posted data
var jsonData = Platform.Request.GetPostData();
var obj = Platform.Function.ParseJSON(jsonData);
//prepare data for API call
var base64enc = obj.base64enc;
var fileName = obj.fileName;
var assetName = obj.assetName;
//match asset type with uploaded file (https://developer.salesforce.com/docs/atlas.en-us.noversion.mc-apis.meta/mc-apis/base-asset-types.htm)
var assetTypes = { ai: 16, psd: 17, pdd: 18, eps: 19, gif: 20, jpe: 21, jpeg: 22, jpg: 23, jp2: 24, jpx: 25, pict: 26, pct: 27, png: 28, tif: 29, tiff: 30, tga: 31, bmp: 32, wmf: 33, vsd: 34, pnm: 35, pgm: 36, pbm: 37, ppm: 38, svg: 39, "3fr": 40, ari: 41, arw: 42, bay: 43, cap: 44, crw: 45, cr2: 46, dcr: 47, dcs: 48, dng: 49, drf: 50, eip: 51, erf: 52, fff: 53, iiq: 54, k25: 55, kdc: 56, mef: 57, mos: 58, mrw: 59, nef: 60, nrw: 61, orf: 62, pef: 63, ptx: 64, pxn: 65, raf: 66, raw: 67, rw2: 68, rwl: 69, rwz: 70, srf: 71, sr2: 72, srw: 73, x3f: 74, "3gp": 75, "3gpp": 76, "3g2": 77, "3gp2": 78, asf: 79, avi: 80, m2ts: 81, mts: 82, dif: 83, dv: 84, mkv: 85, mpg: 86, f4v: 87, flv: 88, mjpg: 89, mjpeg: 90, mxf: 91, mpeg: 92, mp4: 93, m4v: 94, mp4v: 95, mov: 96, swf: 97, wmv: 98, rm: 99, ogv: 100, indd: 101, indt: 102, incx: 103, wwcx: 104, doc: 105, docx: 106, dot: 107, dotx: 108, mdb: 109, mpp: 110, ics: 111, xls: 112, xlsx: 113, xlk: 114, xlsm: 115, xlt: 116, xltm: 117, csv: 118, tsv: 119, tab: 120, pps: 121, ppsx: 122, ppt: 123, pptx: 124, pot: 125, thmx: 126, pdf: 127, ps: 128, qxd: 129, rtf: 130, sxc: 131, sxi: 132, sxw: 133, odt: 134, ods: 135, ots: 136, odp: 137, otp: 138, epub: 139, dvi: 140, key: 141, keynote: 142, pez: 143, aac: 144, m4a: 145, au: 146, aif: 147, aiff: 148, aifc: 149, mp3: 150, wav: 151, wma: 152, midi: 153, oga: 154, ogg: 155, ra: 156, vox: 157, voc: 158, "7z": 159, arj: 160, bz2: 161, cab: 162, gz: 163, gzip: 164, iso: 165, lha: 166, sit: 167, tgz: 168, jar: 169, rar: 170, tar: 171, zip: 172, gpg: 173, htm: 174, html: 175, xhtml: 176, xht: 177, css: 178, less: 179, sass: 180, js: 181, json: 182, atom: 183, rss: 184, xml: 185, xsl: 186, xslt: 187, md: 188, markdown: 189, as: 190, fla: 191, eml: 192, text: 193, txt: 194, freeformblock: 195, textblock: 196, htmlblock: 197, textplusimageblock: 198, imageblock: 199, abtestblock: 200, dynamicblock: 201, stylingblock: 202, einsteincontentblock: 203, webpage: 205, webtemplate: 206, templatebasedemail: 207, htmlemail: 208, textonlyemail: 209, socialshareblock: 210, socialfollowblock: 211, buttonblock: 212, layoutblock: 213, defaulttemplate: 214, smartcaptureblock: 215, smartcaptureformfieldblock: 216, smartcapturesubmitoptionsblock: 217, slotpropertiesblock: 218, externalcontentblock: 219, codesnippetblock: 220, rssfeedblock: 221, formstylingblock: 222, referenceblock: 223, imagecarouselblock: 224, customblock: 225, liveimageblock: 226, livesettingblock: 227, contentmap: 228, jsonmessage: 230 };
var assetTypeID = assetTypes[assetName];
//authenticate to get access token
var authEndpoint = 'https://xxxxxxxx.auth.marketingcloudapis.com/&#39; //add authentication endpoint
var payload = {
client_id: "xxxxx", //pass Client ID
client_secret: "xxxxx", //pass Client Secret
grant_type: "client_credentials"
};
var url = authEndpoint + '/v2/token'
var contentType = 'application/json'
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if (accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.access_token
var rest_instance_url = tokenResponse.rest_instance_url
}
//make api call to create asset
if (base64enc != null) {
var headerNames = ["Authorization"];
var headerValues = ["Bearer " + accessToken];
var jsonBody = {
"name": fileName,
"assetType": {
"name": assetName,
"id": assetTypeID
},
"file": base64enc
};
var requestUrl = rest_instance_url + "asset/v1/content/assets"
var createAsset = HTTP.Post(requestUrl, contentType, Stringify(jsonBody), headerNames, headerValues);
}
} catch (error) {
Write("<br>error: " + Stringify(error));
}
</script>

You can now go back to your first CloudPage and start uploading!

To see this script in action, visit my CloudPage.


Questions? Comments?

Leave a comment below or email me at zuzanna@sfmarketing.cloud.

Trigger SMS text messages using Server-Side JavaScript

In order to be able to programmatically trigger text messages, you will need to have MobileConnect enabled in Marketing Cloud’s Mobile Studio. You will also need to be able to create an installed package in your Marketing Cloud account to interact with Salesforce Marketing Cloud APIs. If you have both in place, we can start by creating a new SMS message.

Create a new message in MobileConnect

Go to Mobile Studio > Mobile Connect and click on Create Message. Choose Outbound and click on Next. In Message Setup, choose a Name, Short/Long Code to be used, From Name and choose API Trigger as the Send Method. Click on Next, type the Outbound Message text and choose the Next Keyword if needed. To finalize, click on Activate. Before you confirm, note the API Key displayed in the pop-up:

The message will now be visible in the Overview screen – make sure it’s status is Active/Scheduled.

Trigger the message using API

In order to trigger a text message, we will have to interact with Salesforce Marketing Cloud’s REST API using the /sms/v1/messageContact/{id}/send route, but before we do that, we will need to authenticate.

Authenticate using Server-Side JavaScript

Let’s start by installing a new, or identifying an existing installed package in your Marketing Cloud account. Note the Client Id and Client Secret as we will need them to authenticate our API request. Make sure that SMS is enabled in the scope of the package you are using:

Depending on the type of the package installed (v2 – enhanced functionality or v1 – legacy functionality), use one of the following code snippets to cover the authentication part of the script.

v1: [see code snippet on Github]

v2: [see code snippet on Github]

<script runat="server">
Platform.Load("Core","1.1.1");
var authEndpoint = 'https://mcxxxxxxxxxxxxxxxxxxxxxxxxx.auth.marketingcloudapis.com&#39;;
var payload = {
client_id: "insert Client Id",
client_secret: "insert Client Secret",
grant_type: "client_credentials"
};
var url = authEndpoint + '/v2/token';
var contentType = 'application/json';
try {
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if(accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.access_token;
}
} catch (error) {
Write(Stringify(error));
}
</script>

Make a messageContact API Request

Below is an example messageContact API Request. The phone number passed in the payload must use the correct format for the designated country code. For example, a mobile number from the United States must include the numerical country code 1 and the area code, eg: 13175551212. [see code snippet on Github]

Host: https://YOUR_SUBDOMAIN.rest.marketingcloudapis.com
POST /sms/v1/messageContact/MzA6Nzg6MA/send
Content-Type: application/json
Authorization: Bearer YOUR_ACCESS_TOKEN
{
"mobileNumbers": [
"13175551212"
],
"Subscribe": true,
"Resubscribe": true,
"keyword": "JOINSMS"
}
view raw messageContact hosted with ❤ by GitHub

To ensure that the mobile number exists for the contact and that the contact subscribed to the specified keyword on your short code, set the Subscribe and Resubscribe values to true and specify the keyword parameter. If you’re not sure how to work with keywords, check out this help document: Keywords and Codes.

If the request is valid, the API returns a token that can be used to make a follow-up call to check the status of the request: [see code snippet on Github]

HTTP/1.1 202 Accepted
{
"tokenId": "c21NCNSDN2sMMWM2miosdjEHH",
}

Now, let’s combine the first script snippet we used to authenticate with the messageContact payload and make a POST request. Below will work with the v1 legacy package: [see code snippet on Github]

<script runat="server">
Platform.Load("Core","1.1.1");
try {
//API authentication
var authEndpoint = ""; //provide authentication endpoint
var payload = {
clientId: "", //provide Client Id
clientSecret: "" //provide Client Secret
};
var url = authEndpoint + "/v1/requestToken";
var contentType = "application/json";
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if(accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.accessToken;
}
//make a call using the messageContact route
var headerNames = ["Authorization"];
var headerValues = ["Bearer " + accessToken];
var smsEndpoint = ""; //provide rest endpoint
var smsPayload = {
"mobileNumbers": [
"" //pass phone number
],
"Subscribe": true,
"Resubscribe": true,
"keyword": "" //provide KEYWORD
};
var smsUrl = smsEndpoint + "/sms/v1/messageContact/xxxxxxxxx/send"; //provide message API key
var sendSMS = HTTP.Post(smsUrl, contentType, Stringify(smsPayload), headerNames, headerValues);
} catch (error) {
Write(Stringify(error));
}
</script>

In the above script, you will need to provide your endpoints, ClientId and ClientSecret (all three can be found in Setup > Apps > Installed Packages). You will also need to insert the message Api Key, the keyword and pass the recipient’s phone number.

Last but not least, always remember to put security measures into practice when setting up this kind of functionality on a CloudPage to prevent your ClientId and ClientSecret from being exposed.


Questions? Comments?

Leave a comment below or email me at zuzanna@sfmarketing.cloud.

Subscribe to Automation Studio notifications via SOAP API

If you have used Automation Studio to build workflows on autopilot, you are certainly aware of the importance of monitoring. Checking manually the execution status of every automation in your account is tedious and exhausting. Sometimes, you need to be notified in real time so you can fix eventual issues. That is why having email notifications enabled on your automations is necessary.

There are two types of notifications to which you can subscribe on automation settings:

  • Error or skipped run: you will receive an email if the automation’s execution fails or is skipped
  • Run Completion: you will receive an email once the automation’s run completes.

Keep in mind that at this moment, the only official way of interacting with Automation Studio via API is through SOAP. I’m saying “official” because there are actually some undocumented, but very limited, REST endpoints that can be used to play with Automation Studio. Let me know in the comments if you are interested.

Therefore, if we check the official SOAP documentation, we will see that creating an automation along with activities and email notifications in its settings is pretty easy and straightforward. However, if we look closer, we will find a comment on the notification tag saying: DOES NOT WORK. But we are not going to accept this for an answer, aren’t we?

This is the SOAP request sample from the documentation to create an automation: [see code snippet on Github]

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<soapenv:Header>
<wsse:Security soapenv:mustUnderstand="1">
<wsse:UsernameToken>
<wsse:Username>username</wsse:Username>
<wsse:Password>password</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
</soapenv:Header>
<soapenv:Body>
<CreateRequest xmlns="http://exacttarget.com/wsdl/partnerAPI">
<Options/>
<Objects xsi:type="Automation">
<Client>
<ID>1234567</ID>
</Client>
<Name>AUT_ONEOFF</Name>
<CustomerKey>AUT_ONEOFF</CustomerKey>
<Description>AUT_ONEOFF</Description>
<!–<CategoryID>74052</CategoryID>–>
<!–<Notifications> DOES NOT WORK
<Notification>
<Address>swhitmore@salesforce.com</Address>
<NotificationType>-1</NotificationType>
</Notification>
</Notifications>–>
<AutomationTasks>
<AutomationTask>
<PartnerKey xsi:nil="true"/>
<ObjectID xsi:nil="true"/>
<Name>Task 1</Name>
<Activities>
<Activity>
<PartnerKey xsi:nil="true"/>
<ObjectID>c7ccca24-4567-4317-b8d0-3874d69c130c</ObjectID><!– ObjectID of Query –>
<Name>Query 1</Name><!– Name of Activity –>
<!–<Definition>
<PartnerKey xsi:nil="true"/>
<ObjectID xsi:nil="true"/>
</Definition>–>
<ActivityObject xsi:type="QueryDefinition">
<PartnerKey xsi:nil="true"/>
<ObjectID>c7ccca24-4567-4317-b8d0-3874d69c130c</ObjectID><!– ObjectID of Query –>
<CustomerKey>546012e7-4438-4526-9e6c-5ec73939848b</CustomerKey><!– CustomerKey of Query –>
<Name>BrowseAbandon_US_Unica_Sendable_Audience_Approved</Name><!– Name of Query –>
</ActivityObject>
</Activity>
</Activities>
</AutomationTask>
</AutomationTasks>
<AutomationType>scheduled</AutomationType>
</Objects>
</CreateRequest>
</soapenv:Body>
</soapenv:Envelope>

To find the source of the problem, we should get into the tags responsible for adding email notifications to the settings. In this example, they are using a wrong value for the NotificationType tag in addition to a missing ChannelType tag.

NotificationType should be either “Complete” OR “Error” and definitely not “-1”. And ChannelType should be equal to 1.

The correct SOAP request to create an automation along with a “Complete” notification email should look like this: [see code snippet on Github]

<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<fueloauth xmlns="http://exacttarget.com">{{access_token}}</fueloauth>
</s:Header>
<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<CreateRequest xmlns="http://exacttarget.com/wsdl/partnerAPI">
<Options/>
<Objects xsi:type="Automation">
<Client>
<ID>50000xxxx</ID>
</Client>
<Name>AUT_ONEOFF4</Name>
<CustomerKey>AUT_ONEOFF4</CustomerKey>
<Description>AUT_ONEOFF4</Description>
<!–<CategoryID>74052</CategoryID>–>
<Notifications>
<Notification>
<ChannelType>1</ChannelType>
<NotificationType>Complete</NotificationType>
<Address>rachid.mamai@xxx.com</Address>
</Notification>
</Notifications>
<AutomationType>scheduled</AutomationType>
</Objects>
</CreateRequest>
</s:Body>
</s:Envelope>

For the curious ones, you may be asking how I found the working values. I invite you to go to Automation Studio, open the Chrome Developer Tools (F12) and click on the Network tab.

Open an automation, add your email to the settings and click done. Now go check out the Network tab. There is a REST call to the endpoint below.

Given the fact that we can only interact with Automation Studio using SOAP calls for now, we can understand why the endpoint is beta and not officially documented:

https://mc.s50.exacttarget.com/rest/beta/automations/notifications/AUTOMATION_ID/

Now let us look at the request payload:

We can see that the values used for NotificationType and ChannelType are as described above. That was worth a try and it worked: [see code snippet on Github]

{
"programId": "TnJZZGdzNjVjRW1zX0lhVjhrNkxPxxxxxx",
"workers": [
{
"body": "",
"channelType": "1",
"programId": "TnJZZGdzNjVjRW1zX0lhVjhrNkxPxxxxxx",
"definition": "rachid.mamai@xxxx.com",
"notificationType": "Complete"
},
{
"channelType": "1",
"programId": "TnJZZGdzNjVjRW1zX0lhVjhrNkxPxxxxxx",
"definition": "rachid.mamai@xxxx.com",
"notificationType": "Error"
}
]
}

I invite you to check the network tab every time you need to interact with Salesforce Marketing Cloud APIs. It can be very interesting and full of undocumented information.

If you know someone that can correct this on the official documentation, please get in touch through the comments or send me a message on LinkedIn.


About the author

Rachid Mamai is a SFMC geek and a Digital Marketing enthusiast living in France. To get in touch with Rachid, visit his LinkedIn.

Get Journey history by filtering the Definition ID via API

In today’s article, we will be going through a non-documented way of getting journey’s history through the API. The majority of this information is available on the UI. However, having a second way of getting this data in raw format can be helpful in some use cases. You can use free software like POSTMAN to interact with Marketing Cloud’s API and follow the steps in this article.

Let’s start by setting the correct scopes in Salesforce Marketing Cloud.

Set scope on Contacts and Journeys

When requesting our access token, we need to provide scopes used by our API calls. Do not forget to enable the Journeys & List and Subscribers scopes on the Installed package. We will be using these two scopes to get our journey’s history. Check out “Introduction to SFMC APIs” article for in-depth information about Installed packages setup and Salesforce Marketing Cloud APIs.

We need to set the scopes on the payload to get our access token: [see code snippet on Github]

{
"grant_type": "client_credentials",
"client_id": "85z0pydo1ykxxxxxxxxxxxx",
"client_secret": "jQ1o2JmRvxxxxxxxxxxxx",
"scope": "journeys_read list_and_subscribers_read",
"account_id": "5000xxxxx"
}
view raw gistfile1.txt hosted with ❤ by GitHub

Get Journey’s Definition ID

In this step, we will be getting information about a specific journey. The undocumented endpoint “interaction” provides access to a range of data, including the DefinitionID. Before getting into it, let me explain the differences between the three different identifiers of a journey:

  • Id: A unique id of the journey assigned by the journey’s API during its creation
  • Key: A unique id of the journey within the MID. Can be generated by the developer
  • DefinitionId: A unique UUID provided by Salesforce Marketing Cloud. Each version of a journey has a unique DefinitionID while the Id and Key remain the same.

Since the journey’s history is version dependent, we will be using DefinitionID to get history of a specific version.

Let us start by making a call to the below endpoint using name=JOURNEY_NAME as a URL parameter. By default, mostRecentVersionOnly parameter is set to true, therefore, the call will return information of the most recent version of the journey. Use VersionNumber=VERSION_NUMBER and mostRecentVersionOnly=false to get information about a specific version. Note that more parameters are available but not covered in this article since irrelevant: [see code snippet on Github]

GET interaction/v1/interactions/?name=JOURNEY_NAME
view raw gistfile1.txt hosted with ❤ by GitHub

The DefinitonID is located in a nested array called items, something like: [see code snippet on Github]

{
"count": 1,
"page": 1,
"pageSize": 50,
"links": {},
"items": [
{
"id": "59978a5f-f503-489e-b44c-xxxxxxxxxxxx",
"key": "3190210c-7f4f-5b06-498b-xxxxxxxxxxxx",
"name": "JOURNEY_NAME",
"lastPublishedDate": "2019-11-20T07:01:11",
"description": "",
"version": 10,
"workflowApiVersion": 1.0,
"createdDate": "2019-11-20T06:49:41.71",
"modifiedDate": "2019-11-20T07:30:46.4",
"goals": [],
"exits": [],
"stats": {
"currentPopulation": 0,
"cumulativePopulation": 0,
"metGoal": 0,
"metExitCriteria": 0,
"goalPerformance": 0.0
},
"entryMode": "SingleEntryAcrossAllVersions",
"definitionType": "Multistep",
"channel": "",
"defaults": {
"email": [
"{{Event.DEAudience-765c8676-e742-da34-b0eb-xxxxxxxxxxxx.\"Email\"}}"
],
"mobileNumber": [
"{{Event.DEAudience-765c8676-e742-da34-b0eb-xxxxxxxxxxxx.\"MobilePhone\"}}"
],
"properties": {
"analyticsTracking": {
"enabled": true,
"analyticsType": "google",
"urlDomainsToTrack": []
}
}
},
"metaData": {
"hasCopiedActivity": true
},
"executionMode": "Production",
"categoryId": 287,
"status": "Stopped",
"definitionId": "06d6f3f6-4532-4fb3-908c-49b7xxxxxxxx",
"scheduledStatus": "None"
}
]
}
view raw gistfile1.txt hosted with ❤ by GitHub

Get Journey’s history by filtering via Definition ID

We are finally there. In this step, we will call the endpoint below: [see code snippet on Github]

POST /interaction/v1/interactions/journeyhistory/search
view raw gistfile1.txt hosted with ❤ by GitHub

The payload should be something like below. Do not forget to set the Content-type as application/json on the call’s parameters: [see code snippet on Github]

{
"definitionIds": [
"06d6f3f6-4532-4fb3-908c-xxxxxxxxxxxx"
],
"start": "2019-10-23T12:29:11.882Z",
"end": null,
"extras": "all"
}
view raw gistfile1.txt hosted with ❤ by GitHub

You can apply multiple filters by setting different variables in the payload. In this example, we are selecting history data for our journey starting from October 23, 2019 to now. The journey is identified by the DefinitionID in the DefinitionIds array parameter in the payload. Other filters are:

  • Page and PageSize: For results pagination. The page starts from one.
  • Extras: represents a list of additional data to fetch. Available values are all, activities, outcomes and stats.
  • OrderBy: You can order by CreatedDate
  • MostRecentVersionOnly: To get information about the most recent versio of the journey. Accepts a true or false value.

This call will return something like below depending on the payload: [see code snippet on Github]

{
"count": 107,
"page": 1,
"pageSize": 100,
"links": {},
"items": [
{
"id": "2157XXXX",
"mid": 50000XXXX,
"eventId": "462f6eae-e39a-4c3f-8da4-xxxxxxxxxxxxx",
"definitionId": "06d6f3f6-4532-4fb3-908c-xxxxxxxxxxxxx",
"definitionName": "YOURJOURNEY_NAME",
"eventName": "YOURJOURNEY_NAME",
"contactKey": "0032Xxxxxxxxxxxxxx",
"transactionTime": "2019-11-20T13:03:09.544Z",
"status": "Complete",
"message": "",
"activityId": "886112ca-6d8d-4e33-9590-xxxxxxxxxxxxx",
"activityType": "SALESCLOUDACTIVITY",
"activityName": "Update Adh?sion Activity"
},
{
"id": "2157XXXX",
"mid": 50000XXXX,
"eventId": "462f6eae-e39a-4c3f-8da4-xxxxxxxxxxxxx",
"definitionId": "06d6f3f6-4532-4fb3-908c-xxxxxxxxxxxxx",
"definitionName": "YOURJOURNEY_NAME",
"eventName": "YOURJOURNEY_NAME",
"contactKey": "0032Xxxxxxxxxxxxxx",
"transactionTime": "2019-11-20T13:03:11.544Z",
"status": "Complete",
"message": "",
"activityId": "4cfa83d7-d8fd-4280-a531-xxxxxxxxxxxxx",
"activityType": "SALESCLOUDACTIVITY",
"activityName": "Create Task Activity"
},
….
]
}
view raw gistfile1.txt hosted with ❤ by GitHub

About the author

Rachid Mamai is a SFMC geek and a Digital Marketing enthusiast living in France. To get in touch with Rachid, visit his LinkedIn.

Update multiple non-sendable Data Extensions to be sendable

In order to programmatically update the settings of an existing Data Extension, we will interact with Salesforce Marketing Cloud’s SOAP Web Services API using WSProxy. Code snippets in this article will show you how to update a Data Extension, so that a non-sendable Data Extension is turned into a sendable Data Extension, but you can modify the script to update other properties of a Data Extension, like retention settings or field properties.

DataExtension object

The DataExtension object represents a data extension within an account. In order to turn a non-sendable Data Extension into a sendable one, we will have to update three of the object’s properties:

  • IsSendable (xsd:boolean) – Indicates whether you can use a data extension as part of an audience for a message send.
  • SendableDataExtensionField (DataExtensionField) – Indicates the field within a sendable data extension to use as an address as part of a send. Possible values include SubscriberID, CustomerKey, or EmailAddress. The application uses this field to establish a data relationship between a value specified by the SendableSubscriberField property and a value within a sendable data extension.
  • SendableSubscriberField (Attribute) – Indicates field to use as sending address. The application uses this field to establish a data relationship between a subscriber field and a value specified by the SendableDataExtensionField property.

To put it in simple words, we need to set the IsSendable value to true and establish which field in the data extension relates to the Subscriber table. If you do it from the UI, you can for example set it to “ContactKey relates to Subscribers on Subscriber Key” or “EmailAddress relates to Subscribers on Subscriber Key”. Here, we will do exactly the same thing, using the following structure:

SendableDataExtensionField relates to subscribers on SendableSubscriberField

Update Data Extension properties via WSProxy

We will use the updateItem WSProxy function to interact with the DataExtension object. Below script will identify a Data Extension by it’s External Key (CustomerKey), set the IsSendable field to true and establish the following send relationship: “email relates to Subscribers on Subscriber Key”, where email is the Data Extension field of type EmailAddress.

The possible values of the SendableSubscriberField include “Subscriber Key” or “Email Address”, depending on whether the Subscriber Key business rule has been turned on. This is the tricky part of this script, as upon retrieving the properties of a sendable Data Extension, the results will return SendableSubscriberField.Name as “_SubscriberKey”, but you actually need to use “Subscriber Key” in the update call.

Here’s the script that will update a single Data Extension:

<script runat="server">
Platform.Load("core","1.1.5");
try{
var prox = new Script.Util.WSProxy();
var CustomerKey = "xxxxx-xxxx-xxxx-xxxx", //Insert DE External Key
IsSendable = "true",
SendableDataExtensionField = {"Name": "email", "DataType": "EmailAddress"}, //Provide the name of the field used to establish a data relationship (SendableDataExtensionField relates to subscribers on SendableSubscriberField)
SendableSubscriberField = {"Name": "Subscriber Key"}; //Use "Subscriber Key" or "Email Address" depending on whether the SUBSCRIBER_KEY business rule has been turned on.
var res = prox.updateItem("DataExtension", {"CustomerKey":CustomerKey, "IsSendable":IsSendable, "SendableDataExtensionField":SendableDataExtensionField, "SendableSubscriberField":SendableSubscriberField});
Write(Stringify(res));
}catch(e){
Write(Stringify(e));
}
</script>

Update multiple Data Extensions in one call

We have two possible ways to go when it comes to updating multiple Data Extensions in one call.

If you’re working with a set of Data Extensions that have an identical field name and data type assigned to the field used in the send relationship, for example email field of type EmailAddress, you can use a simple loop to iterate through an array of Data Extension External Keys:

<script runat="server">
Platform.Load("core","1.1.5");
try{
var prox = new Script.Util.WSProxy();
var CustomerKey = []; //Create an array withh DE External Keys
var IsSendable = "true",
SendableDataExtensionField = {"Name": "email", "DataType": "EmailAddress"}, //Provide the name of the field used to establish a data relationship (SendableDataExtensionField relates to subscribers on SendableSubscriberField)
SendableSubscriberField = {"Name": "Subscriber Key"}; //Use "Subscriber Key" or "Email Address" depending on whether the SUBSCRIBER_KEY business rule has been turned on.
for(var i=0; i< CustomerKey.length; i++) {
var res = prox.updateItem("DataExtension", {"CustomerKey":CustomerKey[i], "IsSendable":IsSendable, "SendableDataExtensionField":SendableDataExtensionField, "SendableSubscriberField":SendableSubscriberField});
Write(Stringify(res) + "<br>");
}
}catch(e){
Write(Stringify(e));
}
</script>

If the field names vary across the Data Extensions, it’s best to use the updateBatch WSProxy function and define properties of each Data Extension separately:

<script runat="server">
Platform.Load("core","1.1.5");
try{
var prox = new Script.Util.WSProxy();
var de1 = {
"CustomerKey":"xxxx-xxxx-xxxx-xxxx-xxxx-xxxx",
"IsSendable":"true",
"SendableDataExtensionField":
{
"Name": "email",
"DataType": "EmailAddress"
},
"SendableSubscriberField":
{
"Name": "Subscriber Key"
}
};
var de2 = {
"CustomerKey":"xxxx-xxxx-xxxx-xxxx-xxxx-xxxx",
"IsSendable":"true",
"IsTestable":"true",
"SendableDataExtensionField":
{
"Name": "emailaddress",
"DataType": "EmailAddress"
},
"SendableSubscriberField":
{
"Name": "Subscriber Key"
}
};
var res = prox.updateBatch("DataExtension", [de1, de2]);
Write(Stringify(res));
}catch(e){
Write(Stringify(e));
}
</script>

Additional resources

If you would like to learn more about using WSProxy and the DataExtension object, I highly recommend reading Gortonington’s article, WSProxy to copy a data extension.

Retrieve tracking data since account inception using WSProxy

Whether you’re building a data warehouse or are dealing with a legal compliance issue, access to historical tracking data from your Salesforce Marketing Cloud account using the out-of-the-box features is limited.

Data Views are powerful, but only allow you to query for up to six months of subscriber and tracking information.

Tracking extracts provide granular tracking data regarding several different aspects of email send jobs, such as clicks, bounces and unsubscribes. They are not time-restricted like Data Views, but if you want to get tracking data from a specific time range, the start and end dates can only be up to 30 days apart.

The third option to access historical data is to reach out to Salesforce support and ask them for a data export for a specific time range, but this service will come at a hefty price.

The method of retrieving tracking data using SOAP API and WSProxy also has some limitations, mostly related to performance when working with big data volumes, but it can be very useful in many cases where using Data Views or Tracking Extracts is not enough.

Retrieve Tracking Data with SOAP API

To retrieve tracking data using the SOAP API, we will call the SentEvent, OpenEvent, ClickEvent, BounceEvent and UnsubEvent objects, which will allow us to create a structure similar to the one we know from Data Views. Each of those objects has a persistent set of properties, which include: BatchID, ClientID, EventDate, SendID, SubscriberKey and TriggeredSendDefinitionObjectID.

Here is an example SOAP envelope that you could use in Postman to retrieve data from any of the tracking event objects:

<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<a:Action s:mustUnderstand="1">Retrieve</a:Action>
<a:ReplyTo>
<a:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</a:Address>
</a:ReplyTo>
<a:To s:mustUnderstand="1">https://{{your endpoint}}.soap.marketingcloudapis.com/Service.asmx</a:To>
<fueloauth xmlns="http://exacttarget.com">{{accessToken}}</fueloauth>
</s:Header>
<s:Body>
<RetrieveRequestMsg xmlns="http://exacttarget.com/wsdl/partnerAPI">
<RetrieveRequest>
<ObjectType>SentEvent</ObjectType>
<Properties>Client.ID</Properties>
<Properties>TriggeredSendDefinitionObjectID</Properties>
<Properties>SendID</Properties>
<Properties>SubscriberKey</Properties>
<Properties>EventDate</Properties>
<Properties>EventType</Properties>
<Properties>BatchID</Properties>
<Properties>ListID</Properties>
</RetrieveRequest>
</RetrieveRequestMsg>
</s:Body>
</s:Envelope>

Interact with SOAP API using WSProxy

To retrieve data from the tracking event objects using WSProxy, we will have to specify the properties to retrieve and perform the retrieve on each of the objects we are interested in. Here’s an example of retrieving data from the SentEvent object:

<script runat="server">
Platform.Load("Core","1");
var prox = new Script.Util.WSProxy();
var cols = [
"SendID",
"SubscriberKey",
"EventDate",
"EventType",
"TriggeredSendDefinitionObjectID",
"BatchID",
"Client.ID",
"ListID"
];
var data = prox.retrieve("SentEvent", cols);
Write(Stringify(data));
</script>

We can also add a filter to narrow down the results. You can filter the results by JobID, SubscriberKey or any other object property. Here, we are going to filter by EventDate, which will show us all the events that took place in the given timeframe:

<script runat="server">
Platform.Load("Core","1");
var prox = new Script.Util.WSProxy();
var cols = [
"SendID",
"SubscriberKey",
"EventDate",
"EventType",
"TriggeredSendDefinitionObjectID",
"BatchID",
"Client.ID",
"ListID"
];
var filter = {Property: "EventDate", SimpleOperator: "between", Value: ["2018-01-01T03:00:00.000Z", "2019-02-02T02:59:59.999Z"]};
var data = prox.retrieve("SentEvent", cols, filter);
Write(Stringify(data));
</script>

If you run the above script in an account that sends large volumes of emails, you will notice that it only pulls a part of the results and displays a status message: MoreDataAvailable. That’s because the SOAP API returns up to 2500 records at a time per retrieve call. To retrieve all available data for a given timeframe, we need to include pagination of retrieves in our script. Let’s also add an HTML table, so that data is displayed in a more accessible way:

<table border="1">
<tr>
<th>Ordinal</th>
<th>SendID</th>
<th>SubscriberKey</th>
<th>Eventhate</th>
<th>EventType</th>
<th>TriggeredSendDefinitionObjectID</th>
<th>BatchID</th>
<th>ClientID</th>
<th>ListID</th>
</tr>
<script runat="server">
Platform.Load("Core","1.1.1");
try{
var prox = new Script.Util.WSProxy(),
objectType = "SentEvent",
cols = [
"SendID",
"SubscriberKey",
"EventDate",
"EventType",
"TriggeredSendDefinitionObjectID",
"BatchID",
"Client.ID",
"ListID"
],
filter = {Property: "EventDate", SimpleOperator: "between", Value: ["2016-01-01T03:00:00.000Z", "2017-02-02T02:59:59.999Z"]},
moreData = true,
reqID = null,
numItems = 0;
while(moreData) {
moreData = false;
var data = reqID == null ?
prox.retrieve(objectType, cols, filter):
prox.getNextBatch(objectType, reqID);
if(data != null) {
moreData = data.HasMoreRows;
reqID = data.RequestID;
if(data && data.Results) {
for(var i=0; i< data.Results.length; i++) {
var j = +i +1
Write("<tr>");
Write("<td>" + j + "</td>");
Write("<td>" + data.Results[i].SendID + "</td>");
Write("<td>" + data.Results[i].SubscriberKey + "</td>");
Write("<td>" + data.Results[i].EventDate + "</td>");
Write("<td>" + data.Results[i].EventType + "</td>");
Write("<td>" + data.Results[i].TriggeredSendDefinitionObjectID + "</td>");
Write("<td>" + data.Results[i].BatchID + "</td>");
Write("<td>" + data.Results[i].Client.ID + "</td>");
Write("<td>" + data.Results[i].PartnerProperties[0].Value + "</td>");
Write("</tr>");
numItems++;
}
}
}
}
Platform.Response.Write("<b><br>" + numItems + " total " + objectType + ":<br><br></b>");
}catch(e){
Write(Stringify(e));
}
</script>
</table>

You can do the same with the remaining objects, here are the scripts:

Write retrieved data into a Data Extension

Although this will increase the processing time of the call, it will allow you to have the data ready for further processing without any additional preparations. Below script will create a new Data Extension called 01_SentEvent in your main Data Extensions folder and insert all the retrieved data.

<script runat="server">
Platform.Load("core","1.1");
try{
//Set time range of retrieved tracking data
var filter = {
Property: "EventDate",
SimpleOperator: "between",
Value: ["2016-01-01T00:00:00.000Z", "2017-10-30T00:00:00.000Z"]
};
//Set Data Extension properties
var fields = [
{ "Name" : "SendID", "FieldType" : "Number"},
{ "Name" : "SubscriberKey", "FieldType" : "Text", "MaxLength" : 50 },
{ "Name" : "EventDate", "FieldType" : "Date", "Ordinal" : 2 },
{ "Name" : "EventType", "FieldType" : "Text", "MaxLength" : 50 },
{ "Name" : "TriggeredSendDefinitionObjectID", "FieldType" : "Text", "MaxLength" : 50 },
{ "Name" : "BatchID", "FieldType" : "Number"},
{ "Name" : "ClientID", "FieldType" : "Number"},
{ "Name" : "ListID", "FieldType" : "Number"}
]
var SentEventDE = {
"CustomerKey" : "01_SentEvent",
"Name" : "01_SentEvent",
"Fields" : fields
};
//Create Data Extensions
var SentDE = DataExtension.Add(SentEventDE);
//Set columns to retrieve
var cols = ["SendID","SubscriberKey","EventDate","EventType","TriggeredSendDefinitionObjectID","BatchID","Client.ID","ListID"];
//Initiate data extensions
var logSentData = DataExtension.Init("01_SentEvent");
//Initiate WSProxy and set object properties
var prox = new Script.Util.WSProxy(),
objectType = "SentEvent",
moreData = true,
reqID = null,
numItems = 0;
//Retrieve data using pagination and insert data to SentEvent DE
while(moreData) {
moreData = false;
var data = reqID == null ?
prox.retrieve(objectType, cols, filter):
prox.getNextBatch(objectType, reqID);
if(data != null) {
moreData = data.HasMoreRows;
reqID = data.RequestID;
if(data && data.Results) {
for(var i=0; i< data.Results.length; i++) {
logSentData.Rows.Add({
SendID: data.Results[i].SendID,
SubscriberKey: data.Results[i].SubscriberKey,
EventDate: data.Results[i].EventDate,
EventType: data.Results[i].EventType,
TriggeredSendDefinitionObjectID: data.Results[i].TriggeredSendDefinitionObjectID,
BatchID: data.Results[i].BatchID,
ClientID: data.Results[i].Client.ID,
ListID: data.Results[i].PartnerProperties[0].Value
});
numItems++;
}
}
}
}
Platform.Response.Write("<br>Inserted " + numItems + " records<br>")
}catch(e){
Write(Stringify(e));
}
</script>

Here are the scripts that will do the same for the remaining objects:

Performance of the retrieve calls

The official Marketing Cloud SOAP API documentation states the following:

In most production implementations, data volume in the account requires you to include specific JobIDs in the filter criteria of the request. If requests that don’t specify a JobID time out during processing, add a JobID in the filter. If specifying a JobID is not possible, or if your implementation requires a broad range of JobIDs, use a data extract-based procedure instead.

Above is true if you are sending heavy volumes of emails each month, but you are not limited to filtering by JobID. You can include any of the object properties in filter criteria. You can, for example, retrieve all send and tracking history for a specific subscriber if you use the SubscriberKey in the filter: filter = {Property: "SubscriberKey", SimpleOperator: "equals", Value: "{{SubscriberKey}}"}

If you’re experiencing time outs when working with the script, try narrowing down the amount of retrieved data by using filters.

Security

Last but not least, always remember to put security measures into practice when setting up this kind of functionality on a CloudPage to prevent your data from being exposed in a breach. For the script to run and either return results onscreen or insert them into a Data Extension, you don’t have to actually publish the CloudPage – just press “Publish”, without further saving it. Or, to be completely safe – create a Script Activity in Automation Studio and run it from there.

Unsubscribe and Log an UnsubEvent with a LogUnsubEvent Execute Call

The LogUnsubEvent provides a convenient way of handling unsubscribes when you create your own unsubscribe landing page or profile center functionality. This call allows you to unsubscribe a subscriber and log an UnsubEvent that is tracked against a specific Job, which means that you will be able to track from which email they unsubscribed and see the results on the tracking dashboard. You can configure the call to either unsubscribe a subscriber from a specific list, publication list, or all subscribers, which will effectively unsubscribe them from receiving any emails.

What’s more important, if you’re using Marketing Cloud Connect to connect with your Salesforce Sales or Service Cloud org, the unsubscribe will be captured on the subscriber record in Sales/Service Cloud. It will automatically check the Email Opt Out (HasOptedOutOfEmail) flag and will also be visible in the Individual Email Results for the corresponding email send.

The LogUnsubEvent Execute call uses the following parameters:

  • SubscriberID – The Marketing Cloud generated ID that uniquely identifies a subscriber.
  • SubscriberKey – The client supplied ID that uniquely identifies a subscriber.
  • EmailAddress – The email address of the subscriber.
  • JobID – The ID of the Job that sent the message.
  • ListID – The ID of the List that the subscriber belonged to. You can use subscriber or publication lists (not suppression lists).
  • BatchID – The ID of the Batch within the Job.
  • Reason – (Optional) The reason the subscriber is being unsubscribed.

The parameters can be divided into 3 sections:

  1. Subscriber context
  2. Job context
  3. Unsub reason

If you make this call from the parent unit of an Enterprise 2.0 account, ensure that you include the ClientID of the child business account to return information specific to that business unit.

Subscriber Context

The Subscriber Context is defined by the SubscriberID, SubscriberKey and EmailAddress parameters. You must supply at least one of these parameters. If you provide more than one of these parameters, we retrieve the Subscriber using one of the values and validate that the other values match the retrieved Subscriber. If they don’t match, an error returns.

If the SubscriberKey permission is turned on and you supply the EmailAddress parameter, you must supply either the SubscriberID or the SubscriberKey.

Job Context

The Job Context is defined by the JobID, ListID and BatchID parameters. These values are used to determine which Job the UnsubEvent is tracked against. The subscriber is also unsubscribed from the List that the Job was sent to. You don’t need to supply all three values. The system looks up any missing values using the following rules:

  1. If the JobID is supplied, we can lookup a missing ListID and/or BatchID.
  2. If the ListID is supplied, we can lookup a missing JobID and/or BatchID.
    1. If the JobID is missing, we use the most recent JobID that the subscriber was sent to.
    2. This may not be the Job that the Subscriber is acting upon.
  3. If only the BatchID is supplied, we cannot lookup the remaining information and the job context is not defined.

If the job context cannot be established because you did not supply any of these parameters or only supplied the BatchID, the UnsubEvent is not created. The subscriber is also Master Unsubscribed from the system of being unsubscribed from a particular list. Remove the ListID to address the All Subscribers list in an account.

Unsub Reason

This is used to specify the reason the subscriber is being unsubscribed from the system. If the reason is not supplied, the default value is used: Unsubscribed via Log Unsub Event Execute call.

Here is an example SOAP Request envelope:

<soap-ENV:Body>
<ExecuteRequestMsg xmlns="http://exacttarget.com/wsdl/partnerAPI">
<Requests>
<Name>LogUnsubEvent</Name>
<Parameters>
<Name>SubscriberID</Name>
<Value>123456</Value>
</Parameters>
<Parameters>
<Name>SubscriberKey</Name>
<Value>Key for username@example.com</Value>
</Parameters>
<Parameters>
<Name>EmailAddress</Name>
<Value>help@example.com</Value>
</Parameters>
<Parameters>
<Name>JobID</Name>
<Value>18099</Value>
</Parameters>
<Parameters>
<Name>ListID</Name>
<Value>17914</Value>
</Parameters>
<Parameters>
<Name>BatchID</Name>
<Value>0</Value>
</Parameters>
</Requests>
</ExecuteRequestMsg>
</SOAP-ENV:Body>

We will now look at three different ways to implement this solution on a CloudPage.

LogUnsubEvent using AMPscript

This is probably the most common way to use the LogUnsubEvent call. Below script will retrieve the SubscriberKey, JobId, ListId and BatchId if you link to the unsubscribe page from your email using the CloudPagesURL function. Depending on whether a list/publication list was selected for the send, it will unsubscribe a subscriber from that particular list, or it will unsubscribe the subscriber from all emails if you used All Subscribers.

<script runat="server">
Platform.Load("Core","1.1.1");
try{
</script>
%%[
VAR @skey, @jid, @reason, @lue, @lue_prop, @lue_statusCode, @overallStatus, @requestId, @Response, @Status, @Error
SET @skey = _subscriberkey
SET @jid = RequestParameter("jobid")
SET @listid = RequestParameter("listid")
SET @batchid = RequestParameter("batchid")
SET @reason = "AMPscript one click unsubscribe"
SET @lue = CreateObject("ExecuteRequest")
SetObjectProperty(@lue,"Name","LogUnsubEvent")
SET @lue_prop = CreateObject("APIProperty")
SetObjectProperty(@lue_prop, "Name", "SubscriberKey")
SetObjectProperty(@lue_prop, "Value", @skey)
AddObjectArrayItem(@lue, "Parameters", @lue_prop)
SET @lue_prop = CreateObject("APIProperty")
SetObjectProperty(@lue_prop, "Name", "JobID")
SetObjectProperty(@lue_prop, "Value", @jid)
AddObjectArrayItem(@lue, "Parameters", @lue_prop)
SET @lue_prop = CreateObject("APIProperty")
SetObjectProperty(@lue_prop, "Name", "ListID")
SetObjectProperty(@lue_prop, "Value", @listid)
AddObjectArrayItem(@lue, "Parameters", @lue_prop)
SET @lue_prop = CreateObject("APIProperty")
SetObjectProperty(@lue_prop, "Name", "BatchID")
SetObjectProperty(@lue_prop, "Value", @batchid)
AddObjectArrayItem(@lue, "Parameters", @lue_prop)
SET @lue_prop = CreateObject("APIProperty")
SetObjectProperty(@lue_prop, "Name", "Reason")
SetObjectProperty(@lue_prop, "Value", @reason)
AddObjectArrayItem(@lue, "Parameters", @lue_prop)
SET @lue_statusCode = InvokeExecute(@lue, @overallStatus, @requestId)
SET @Response = Row(@lue_statusCode, 1)
SET @Status = Field(@Response,"StatusMessage")
SET @Error = Field(@Response,"ErrorCode")
]%%
<script runat="server">
}catch(e){
Write(Stringify(e));
}
</script>
@Response: %%=v(@Response)=%%<br>
@Status: %%=v(@Status)=%%<br>
@Error: %%=v(@Error)=%%<br>

LogUnsubEvent using Server-Side JavaScript

The implementation of the LogUnsubEvent call in SSJS will be almost identical to the AMPscript solution, as the methods for accessing SOAP object data with SSJS are primarily wrappers around AMPScript functions.

Like in the previous example, the script will retrieve the SubscriberKey, JobId, ListId and BatchId if you link to the unsubscribe page from your email using the CloudPagesURL function and will unsubscribe a subscriber either from a list or All Subscribers, depending on which one you use at send time.

<script runat="server">
Platform.Load("core","1");
var subkey, jid, lue, lid, bid, lue_prop, Response;
var subkey = Attribute.GetValue("_subscriberkey");
var jid = Attribute.GetValue("jobid");
var lid = Attribute.GetValue("listid");
var bid = Attribute.GetValue("_JobSubscriberBatchID");
lue = Platform.Function.CreateObject("ExecuteRequest");
Platform.Function.SetObjectProperty(lue,"Name","LogUnsubEvent");
lue_prop = Platform.Function.CreateObject("APIProperty");
Platform.Function.SetObjectProperty(lue_prop, "Name", "SubscriberKey");
Platform.Function.SetObjectProperty(lue_prop, "Value", subkey);
Platform.Function.AddObjectArrayItem(lue, "Parameters", lue_prop);
lue_prop = Platform.Function.CreateObject("APIProperty");
Platform.Function.SetObjectProperty(lue_prop, "Name", "JobID");
Platform.Function.SetObjectProperty(lue_prop, "Value", jid);
Platform.Function.AddObjectArrayItem(lue, "Parameters", lue_prop);
lue_prop = Platform.Function.CreateObject("APIProperty");
Platform.Function.SetObjectProperty(lue_prop, "Name", "ListID");
Platform.Function.SetObjectProperty(lue_prop, "Value", lid);
Platform.Function.AddObjectArrayItem(lue, "Parameters", lue_prop);
lue_prop = Platform.Function.CreateObject("APIProperty");
Platform.Function.SetObjectProperty(lue_prop, "Name", "BatchID");
Platform.Function.SetObjectProperty(lue_prop, "Value", bid);
Platform.Function.AddObjectArrayItem(lue, "Parameters", lue_prop);
lue_prop = Platform.Function.CreateObject("APIProperty");
Platform.Function.SetObjectProperty(lue_prop, "Name", "Reason");
Platform.Function.SetObjectProperty(lue_prop, "Value", "SSJS one click unsubscribe");
Platform.Function.AddObjectArrayItem(lue, "Parameters", lue_prop);
var statusAndRequest = [0,0];
try{
Response = Platform.Function.InvokeExecute(lue, statusAndRequest);
Write(Stringify(Response))
}catch(e){
Write(Stringify(e));
}
</script>

LogUnsubEvent using WSProxy

WSProxy is a new object for Server-Side JavaScript, introduced by Salesforce in 2018. It acts as a proxy between the Marketing Cloud SOAP Web Service and SSJS. The WSProxy object is native to the platform and simpler to use than the SSJS methods. The object reduces overhead and increases the speed of your API calls.

Therefore, the below script is the most simple, and what’s more important, the fastest way to execute the LogUnsubEvent API call. In a speed test ran using the Chrome DevTools, it proved to be the fastest one to load on a CloudPage, with AMPscript just slightly slower and SSJS the slowest one, taking twice as much time to load.

Just like in the previous examples, the script will retrieve the SubscriberKey, JobId, ListId and BatchId if you link to the unsubscribe page from your email using the CloudPagesURL function and will unsubscribe a subscriber either from a list or All Subscribers, depending on which one you use at send time.

<script runat="server">
Platform.Load("Core","1.1.1");
var subkey = Attribute.GetValue("_subscriberkey");
var jid = Attribute.GetValue("jobid");
var lid = Attribute.GetValue("listid");
var bid = Attribute.GetValue("_JobSubscriberBatchID");
var prox = new Script.Util.WSProxy();
var props = [
{ Name: "SubscriberKey", Value: subkey },
{ Name: "JobID", Value: jid },
{ Name: "ListID", Value: lid },
{ Name: "BatchID", Value: bid },
{ Name: "Reason", Value: "WSProxy one click unsubscribe" }
];
try{
var data = prox.execute(props, "LogUnsubEvent");
Write(Stringify(data));
}catch(e){
Write(Stringify(e));
}
</script>

If you would like to find out more about logging an UnsubEvent or WSProxy, check out the following articles:

Salesforce Marketing Cloud API Authentication using Server-Side JavaScript

In order to interact with Salesforce Marketing Cloud APIs you need to create an installed package in your Marketing Cloud account first. Marketing Cloud uses installed packages to help authenticate users and API requests. To create and install packages you must have the Administrator or Marketing Cloud Administrator role assigned to your profile.

Marketing Cloud has two types of installed packages: packages with enhanced functionality (v2) and packages with legacy functionality (v1). If you’re not sure which package has been installed in your account, check the “Details” tab of your installed package – all legacy packages have a banner at the top indicating that it’s a legacy package and a Licenses tab. Enhanced packages have an Access tab.

Note the Authentication Base URI , Client Id and Client Secret as you will need them to authenticate. Below code examples are meant for Server-to-Server Integrations with Client Credentials Grant Type.

Request access token for a Legacy Package

The following script will allow you to authenticate if you’re using the Legacy Package. As of August 1, 2019, Marketing Cloud has removed the ability to create legacy packages, but you can still use legacy authentication and API requests with existing legacy packages.

You will need to insert the Client Id and Client Secret and your tenant-specific authentication endpoint (Authentication Base URI). Bare in mind, that it is not a good idea to publish Client credentials on a CloudPage.

<script runat="server">
Platform.Load("Core","1.1.1");
var authEndpoint = 'https://mcxxxxxxxxxxxxxxxxxxxxxxxxx.auth.marketingcloudapis.com&#39;;
var payload = {
clientId: "insert Client Id",
clientSecret: "insert Client Secret"
};
var url = authEndpoint + '/v1/requestToken';
var contentType = 'application/json';
try {
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if(accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.accessToken;
}
} catch (error) {
Write(Stringify(error));
}
</script>

The access token will be valid for 60 minutes and it is issued with the scopes specified on the API integration in Installed Packages. With Legacy Packages, the access token can only be used in the context of the business unit that created the integration.

Request access token for Enhanced Packages

For this package, you will need to insert the Client Id and Client Secret, your tenant-specific authentication endpoint (Authentication Base URI) and define grant_type as "client_credentials" for server-to-server integrations. You can also specify two optional parameters, scope and account_id. If you don’t include the scope parameter in the request, the token is issued with the scopes specified on the API integration in Installed Packages. For a full list of permissions click here. Account ID is the MID of the target business unit. Use this parameter to switch between business units. If you don’t specify account_id, the returned access token is in the context of the business unit that created the integration. Again, bare in mind, that it is not a good idea to publish Client credentials on a CloudPage.

<script runat="server">
Platform.Load("Core","1.1.1");
var authEndpoint = 'https://mcxxxxxxxxxxxxxxxxxxxxxxxxx.auth.marketingcloudapis.com&#39;;
var payload = {
client_id: "insert Client Id",
client_secret: "insert Client Secret",
grant_type: "client_credentials"
};
var url = authEndpoint + '/v2/token';
var contentType = 'application/json';
try {
var accessTokenRequest = HTTP.Post(url, contentType, Stringify(payload));
if(accessTokenRequest.StatusCode == 200) {
var tokenResponse = Platform.Function.ParseJSON(accessTokenRequest.Response[0]);
var accessToken = tokenResponse.access_token;
}
} catch (error) {
Write(Stringify(error));
}
</script>

The access token has a lifetime of 20 minutes. In the response you will also see the scope, rest_instance_url (your tenant’s REST base URL for making REST API calls) and soap_instance_url (your tenant’s SOAP base URL for making SOAP API calls).

Token type will always be “Bearer”, regardless of the package you are using.

To learn more about Marketing Cloud APIs, visit the Trailhead module Marketing Cloud APIs and the official documentation: Intro to Marketing Cloud APIs.