Glen Mazza's Weblog

https://glenmazza.net/blog/date/20220828 Sunday August 28, 2022

Using Marketing Cloud's SOAP endpoint to manage subscriptions

Within Marketing Cloud's Automation Studio, SQL Query activities on the ListSubscribers view can be run to determine subscription information for users and lists. For SOAP calls, and programming languages using them, we work with ListSubscriber and SubscriberList types defined in the MC SOAP WSDL for querying and updating, respectively. This entry provides some examples of querying and updating, both via direct SOAP calls using MC's Postman workspace and programmatically with the FuelSDK-Java (see here for initialization code not covered in the code snippets below.)

How to query a user's subscriptions: The below SOAP request provides options on querying user subscription information by subscriberKey and optionally on subscription status (Active or Unsubscribed). The filters equals and IN are shown but there are several others, see the WSDL above (search on SimpleOperator) to see the full list.

<?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:To s:mustUnderstand="1">https://{{et_subdomain}}.soap.marketingcloudapis.com/Service.asmx</a:To>
        <fueloauth xmlns="http://exacttarget.com">{{dne_etAccessToken}}</fueloauth>
    </s:Header>
    <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
        <RetrieveRequestMsg xmlns="http://exacttarget.com/wsdl/partnerAPI">
            <RetrieveRequest>
                <ObjectType>ListSubscriber</ObjectType>
                <Properties>SubscriberKey</Properties>
                <Properties>ListID</Properties>
                <Properties>Status</Properties>
                <Filter xsi:type="SimpleFilterPart">
                    <Property>SubscriberKey</Property>
                    <SimpleOperator>IN</SimpleOperator>
                    <Value>...subscriberKey1...</Value>
                    <Value>...subscriberKey2...</Value>
                    <Value>...subscriberKey3...</Value>
                </Filter>
                <!--Filter xsi:type="ComplexFilterPart">
                    <LeftOperand xsi:type="SimpleFilterPart">
                        <Property>SubscriberKey</Property>
                        <SimpleOperator>equals</SimpleOperator>
                        <Value>...subscriberKey1...</Value>
                    </LeftOperand>
                    <LogicalOperator>AND</LogicalOperator>
                    <RightOperand xsi:type="SimpleFilterPart">
                        <Property>Status</Property>
                        <SimpleOperator>equals</SimpleOperator>
                        <Value>Active</Value>
                    </RightOperand>
                </Filter-->
            </RetrieveRequest>
        </RetrieveRequestMsg>
    </s:Body>
</s:Envelope>

Sample output:

<Results xsi:type="ListSubscriber">
    <PartnerKey xsi:nil="true" />
    <ObjectID xsi:nil="true" />
    <Status>Unsubscribed</Status>
    <ListID>2552723</ListID>
    <SubscriberKey>...subscriberKey1...</SubscriberKey>
</Results>
<Results xsi:type="ListSubscriber">
    <PartnerKey xsi:nil="true" />
    <ObjectID xsi:nil="true" />
    <Status>Active</Status>
    <ListID>2591367</ListID>
    <SubscriberKey>...subscriberKey2...</SubscriberKey>
</Results>

If querying by subscriber it is much faster to query many at once using the IN clause as shown above instead of making separate API calls for each subscriber. (I've found 50 at a time works well, I did not test larger amounts.) Most of the overhead involves making a request and receiving the response, with each extra user queried adding only a minor amount to the total response time.

A similar query, this time using Java and the Fuel SDK:

public RetrieveRequestMsg buildActiveListRequestMsg(String subscriberKey) {
    RetrieveRequest retrieveRequest = new RetrieveRequest();

    retrieveRequest.setObjectType("ListSubscriber");
    retrieveRequest.getProperties().add("SubscriberKey");
    retrieveRequest.getProperties().add("ListID");
    retrieveRequest.getProperties().add("Status");

    retrieveRequest.setFilter(ETUtils.composeFilter(
            ETUtils.composeFilter("SubscriberKey", SimpleOperators.EQUALS, subscriberKey),
            LogicalOperators.AND,
            ETUtils.composeFilter("Status", SimpleOperators.EQUALS, "Active")
    ));

    RetrieveRequestMsg retrieveRequestMsg = new RetrieveRequestMsg();
    retrieveRequestMsg.setRetrieveRequest(retrieveRequest);

    return retrieveRequestMsg;
}

private static FilterPart composeFilter(FilterPart leftPredicate, LogicalOperators operator, FilterPart rightPredicate) {
    ComplexFilterPart filter = new ComplexFilterPart();
    filter.setLeftOperand(leftPredicate);
    filter.setLogicalOperator(operator);
    filter.setRightOperand(rightPredicate);
    return filter;
}

Subsequent processing to get the subscriber's active subscriptions:

RetrieveResponseMsg response = etClient.getSoapConnection().getSoap().retrieve(buildActiveListRequestMsg("subscriberKey1"));
if ("OK".equalsIgnoreCase(retrieveResponseMsg.getOverallStatus())) {
    List results = retrieveResponseMsg.getResults();
    List listIDs = results.stream()
            .map(apiObject -> ((ListSubscriber) apiObject).getListID())
            .collect(Collectors.toList());
}

How to update a user's subscriptions: The MC documentation provides several examples of working with the SubscriberList object for subscribing and unsubscribing users. Note there are two ways to unsubscribe a user from a list: marking the user Unsubscribed, which keeps an entry for the user on the subscription list with an unsubscribed date, and just deleting the user from the list. Marketing Cloud generally recommends the former approach. For list maintenance, we specify a user by subscriber key, and a series of lists. For each list, the List ID, an action (create, update, upsert, delete) and sometimes a status (Active or Unsubscribed) is provided. The delete action does not take a status, and if status is otherwise omitted, it is treated as Active if the user is not already on the list or keeps the status as-is if the user is. Sample Postman query:

<?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">Create</a:Action>
        <a:To s:mustUnderstand="1">https://{{et_subdomain}}.soap.marketingcloudapis.com/Service.asmx</a:To>
        <fueloauth xmlns="http://exacttarget.com">{{dne_etAccessToken}}</fueloauth>
    </s:Header>
    <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
        <UpdateRequest xmlns="http://exacttarget.com/wsdl/partnerAPI">
            <Objects xsi:type="Subscriber">
                <SubscriberKey>...subscriberkey1...</SubscriberKey>
                <Lists>
                    <ID>2716501</ID>
                    <Action>upsert</Action>
                </Lists>
            </Objects>
            <Objects xsi:type="Subscriber">
                <SubscriberKey>...subscriberkey1...</SubscriberKey>
                <Lists>
                    <ID>2712867</ID>
                    <Action>delete</Action>
                </Lists>
            </Objects>
            <Objects xsi:type="Subscriber">
                <SubscriberKey>...subscriberkey2...</SubscriberKey>
                <Lists>
                    <ID>2712867</ID>
                    <Status>Active</Status>
                    <Action>upsert</Action>
                </Lists>
                <Lists>
                    <ID>2716962</ID>
                    <Status>Active</Status>
                    <Action>upsert</Action>
                </Lists>
            </Objects>
        </UpdateRequest>
    </s:Body>
</s:Envelope>

Some notes for the above request:

  • Multiple lists can be updated for a single user by either creating one Objects block per list, as with the SubscriberKey1 user, or by having multiple Lists under a single Objects block as done with the SubscriberKey2 user. For the latter case, if an error would occur with any single list, testing is showing that none of the updates will occur under that Objects block, and furthermore the error response will specify the problem but not which of the subscription changes is causing it.
  • Multiple users cannot be specified under a single Objects block, each user needs its own Objects block(s).
  • Using an action of upsert for subscribes (status = Active) and unsubscribes (status = Unsubscribed) are very unlikely to throw exceptions, providing the list ID and the user's subscriber key exists. This makes upserts a better option for many-lists-in-one-Objects-block changes. (The upsert creates a record if it does not exist, but updates it otherwise.)
  • If you'd like exceptions if you're adding someone to a list who is already there, or making someone unsubscribed who is not subscribed presently, instead of upsert use create or update.
  • Common error codes: SubscriberNotFound (12001), ListNotFound (13000), OnListAlready (13006) (for a create of a user of whatever status who is already on the list), NotOnList (13007) (for an update of a user not on the list).
  • If for an upsert you don't provide a status for the user, if the user is not on the list he will be added with status of Active. Otherwise, no change for the user (will remain Active or Unsubscribed as before.) Providing you don't delete users from lists but just mark them as Unsubscribed, this will allow you to add users to a list while respecting past unsubscribes.

In Java using the FuelSDK, subscription changes are handled via an UpdateRequest. Here, we take Subscriber object(s) in which we identify each user by his SubscriberKey, and then populate each Subscriber object's lists attribute with SubscriberList objects, with each object containing a List ID, action (create, update, delete, upsert, with the default apparently being upsert) and optionally status (Active, Unsubscribed), subject to the same rules given above for the direct SOAP calls. Sample adding and unsubscribing lists from a given Subscriber:

import com.exacttarget.fuelsdk.internal.Subscriber;
import com.exacttarget.fuelsdk.internal.SubscriberList;
import com.exacttarget.fuelsdk.internal.SubscriberStatus;
import com.exacttarget.fuelsdk.internal.UpdateOptions;
import com.exacttarget.fuelsdk.internal.UpdateRequest;
import com.exacttarget.fuelsdk.internal.UpdateResponse;
import com.exacttarget.fuelsdk.ETSdkException;

class BulkSubscriptionUpdate {
    private Subscriber subscriber;
    private final Set subscribes;
    private final Set unsubscribes;
    
    ....
}

public UpdateResponse updateSubscriptions(BulkSubscriptionUpdate bulkSubscriptionUpdate) throws ETSubscriberServiceException {

    UpdateRequest updateRequest = new UpdateRequest();
    UpdateOptions options = new UpdateOptions();
    options.setQueuePriority(Priority.HIGH);
    options.setRequestType(RequestType.SYNCHRONOUS);
    updateRequest.setOptions(options);

    Subscriber subscriber = new Subscriber();
    subscriber.setSubscriberKey(generateSubscriberKey(user.getId()));

    List subscriptions = new ArrayList<>();
    if (!CollectionUtils.isEmpty(bulkSubscriptionUpdate.getSubscribes())) {
        for (Integer etid : bulkSubscriptionUpdate.getSubscribes()) {
            subscriptions.add(buildSubscriptionUpdate(etid, SubscriberStatus.ACTIVE));
        }
    }
    if (!CollectionUtils.isEmpty(bulkSubscriptionUpdate.getUnsubscribes())) {
        for (Integer etid : bulkSubscriptionUpdate.getUnsubscribes()) {
            subscriptions.add(buildSubscriptionUpdate(etid, SubscriberStatus.UNSUBSCRIBED));
        }
    }

    if (!CollectionUtils.isEmpty(subscriptions)) {
        subscriber.getLists().addAll(subscriptions);
        updateRequest.getObjects().add(subscriber);

        try {
            return update(updateRequest);
        } catch (ETSdkException e) {
            LOGGER.error("Failed to update subscriptions for subscriber {}", bulkSubscriptionUpdate.getSubscriber(), e);
            throw e;
        }
    }
    
    // nothing to update
    return null;
}

private static SubscriberList buildSubscriptionUpdate(Integer etid, SubscriberStatus status) {
    SubscriberList subscription = new SubscriberList();
    subscription.setId(etid);
    subscription.setStatus(status);
    subscription.setAction("upsert");
    return subscription;
}

Posted by Glen Mazza in Salesforce at 07:00AM Aug 28, 2022 | Tags:  marketingcloud  salesforce | Comments[0]

https://glenmazza.net/blog/date/20220822 Monday August 22, 2022

Deleting subscriber lists from Marketing Cloud

At work we used to create mailing lists on various search categories of interest to our readers, resulting in thousands of lists being created over the years. Since then we've switched to triggered sends, making these lists no longer of use, and hence we were looking for an automated way of deleting them.

Note it's very easy to delete a list, and in any automated process to delete a large number of lists it's important to make sure you're not accidentally deleting a list you need. For my work I checked any List ID to be deleted against a list of IDs that I knew our systems still used, disallowing the deletion were that the case. Such a process saved me from accidentally deleting two or three needed lists.

Some further points to note:

  • Looking at the list of data views available, for some reason Marketing Cloud does not offer a "List" view to see all available lists in our account, similar to the Subscribers view to see all subscribers. Instead a ListSubscribers view is provided, which perhaps can be thought of as a link table between Subscribers and Lists. I can query this view to obtain lists that at least one person is (or was) subscribed to, probably getting me close to 100% of all lists if perhaps missing some that never had subscribers.
  • We had far more lists than were displayed in the Marketing Cloud UI Email Studio (perhaps ten times more). I don't know why some are hidden from the UI. It could be lists created via Marketing Cloud SOAP calls will not be viewable, or those without certain parameters, etc.
  • If you have comparatively few lists to delete, and they are visible in the Email Studio UI, they can simply be deleted there.
  • Removing our unused lists made the Email Studio UI dramatically faster.

My first step was to get a list of lists in the system that I could examine to see which could be removed. To generate the list, I first created a data extension to store the results of a SQL query on ListSubscribers. Once I ran the query from Automation Studio, filling the extension in the process, I then viewed the extension in Email Studio, which also allows us to download the list from the browser. Here are the columns of the Data Extension that I used. What is needed to delete a list is its list ID, the other columns were used to help me determine whether each list is still in use.

Data Extension holding list metadata

I ran this SQL query on our main (production) Business Unit as it provides the most complete information about lists and their subscribers:

select ListID, ListName, ListType, count(*) as NumSubscribers, max(CreatedDate) as MaxCreateDate, max(DateUnsubscribed) as MaxUnsubscribeDate
from _ListSubscribers
group by ListID, ListName, ListType

I primarily needed the ListNames to determine which lists I could dispose of. In some cases, the MaxCreateDate and MaxUnsubscribe dates, which indicates the last time someone subscribed and unsubscribed to each list, was helpful in making that determination.

While I eventually needed a Java program to automate the deletion of our large number of unneeded lists, to get an idea of what would be needed, I first practiced with Postman to query and delete individual lists. SFMC offers an unofficial Postman workspace for making SOAP and REST MC calls, with documentation for using same on GitHub.

While the SFMC Postman collection doesn't provide out-of-the-box SOAP calls for Lists, it does have one for data extensions that I leveraged in creating commands for lists. The below request returns lists with three filter options: by list ID, list name (exact match), and list name (substring match), with no filter providing all lists up to a SFMC-determined maximum. The list ID that is to be subsequently used for deleting the list is the "id" value (see here for available values to query.) For a list query, it will be returned under the PartnerProperties element.

List query SOAP call:

<?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:To s:mustUnderstand="1">https://{{et_subdomain}}.soap.marketingcloudapis.com/Service.asmx</a:To>
        <fueloauth xmlns="http://exacttarget.com">{{dne_etAccessToken}}</fueloauth>
    </s:Header>
    <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
        <RetrieveRequestMsg xmlns="http://exacttarget.com/wsdl/partnerAPI">
            <RetrieveRequest>
                <ObjectType>List</ObjectType>
                <Properties>ObjectID</Properties>
                <Properties>id</Properties>
                <Properties>CustomerKey</Properties>
                <Properties>ListName</Properties>
                <Properties>Category</Properties>
                <!--Filter xsi:type="SimpleFilterPart">
                    <Property>id</Property>
                    <SimpleOperator>IN</SimpleOperator>
                    <Value>123456</Value>
                    <Value>234567</Value>
                </Filter-->
                <!--Filter xsi:type="SimpleFilterPart">
                    <Property>ListName</Property>
                    <SimpleOperator>like</SimpleOperator>
                    <Value>%News%</Value>
                </Filter-->
                <!--Filter xsi:type="SimpleFilterPart">
                    <Property>ListName</Property>
                    <SimpleOperator>equals</SimpleOperator>
                    <Value>My Sample Newsletter</Value>
                </Filter-->
            </RetrieveRequest>
        </RetrieveRequestMsg>
    </s:Body>
</s:Envelope>

Response fragment:

<Results xsi:type="List">
    <PartnerKey xsi:nil="true" />
    <PartnerProperties>
        <Name>id</Name>
        <Value>123456</Value>
    </PartnerProperties>
    <ObjectID>....</ObjectID>
    <CustomerKey>....</CustomerKey>
    <ListName>My Newsletter</ListName>
    <Category>...folder ID...</Category>
</Results>

A SOAP call for deleting list(s) by their List IDs are as follows:

<?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">Delete</a:Action>
        <a:To s:mustUnderstand="1">https://{{et_subdomain}}.soap.marketingcloudapis.com/Service.asmx</a:To>
        <fueloauth xmlns="http://exacttarget.com">{{dne_etAccessToken}}</fueloauth>
    </s:Header>
    <s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
      <DeleteRequest xmlns="http://exacttarget.com/wsdl/partnerAPI">
         <Options></Options>
         <Objects xsi:type="List">
            <ID xsi:nil="true">123456</ID>
         </Objects>
         <!-- Add as many additional List IDs as desired to delete all in one call-->
         <!--Objects xsi:type="List">
            <ID xsi:nil="true">234567</ID>
         </Objects-->
      </DeleteRequest>
    </s:Body>
</s:Envelope>

Switching over to Java, we created a utility that would read a list of ET List IDs that we no longer needed, and proceeded to delete them after confirming they weren't on our "needed" list. As shown in the above SOAP call, multiple lists can be deleted in just one call -- for the thousands we needed to delete, deleting 50 at a time seemed to work fastest, and our code managed about 3000 lists per minute.

Using the FuelSDK, pseudocode to delete lists would be as follows:

public DeleteResponse deleteListsInMC(java.util.List<Integer> etListIds) throws ETSdkException {
    // etListIds checked earlier to confirm they are not needed (e.g., query against a table of in-use lists)

    DeleteRequest deleteRequest = new DeleteRequest();

    DeleteOptions options = new DeleteOptions();
    options.setRequestType(RequestType.SYNCHRONOUS);
    options.setQueuePriority(priority);

    deleteRequest.setOptions(options);

    for (Integer i : etListIds) {
        List list = new List();
        list.setId(i);
        deleteRequest.getObjects().add(list);
    }

    // configure ETClient similar to here: https://salesforce.stackexchange.com/a/312178
    // ETClient etClient = ....
    return etClient.getSoapConnection().getSoap().delete(deleteRequest);
}

The returned DeleteResponse object returns a list of DeleteResult objects, one object per list deleted. The result objects have an ordinal ID which map 1-to-1 to the order of lists given in the delete request. Sample result processing code:

DeleteResponse dr = deleteListsInMC(listToDelete);
List<DeleteResult> delResult = dr.getResults();

for (DeleteResult drTemp : delResult) {
    Integer drTempListID = listToDelete.get(drTemp.getOrdinalID());

    // error codes: https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/13000_13099_list_object.html
    // Postman showing 13000 ErrorCode with ListNotFound status message in case of unknown lists
    if ("OK".equals(drTemp.getStatusCode())) {
        // OrdinalID is 0-based
        LOGGER.info("Deleted list {} from MC", drTempListID);
    } else if ("Error".equalsIgnoreCase(drTemp.getStatusCode())) {
        if (drTemp.getErrorCode() == 13000) {
            LOGGER.info("List {} not in MC, nothing to delete", drTempListID);
        } else {
            LOGGER.warn("Could not delete list {}: error code {} status message {}",
                    drTempListID, drTemp.getErrorCode(), drTemp.getStatusMessage());
        }
    }
}

Posted by Glen Mazza in Salesforce at 07:00AM Aug 22, 2022 | Tags:  marketingcloud | Comments[0]


Calendar
« August 2022 »
Sun Mon Tue Wed Thu Fri Sat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Today
About Me
Java Software Engineer
TightBlog project maintainer
Arlington, Virginia USA
glen.mazza at pm dot me
GitHub profile for Glen Mazza at Stack Overflow, Q&A for professional and enthusiast programmers
Blog Search


Blog article index
Navigation
About Blog
Blog software: TightBlog 3.7.2
Application Server: Tomcat
Database: MySQL
Hosted on: Linode
SSL Certificate: Let's Encrypt
Installation Instructions