Marketing Automation, Salesforce Marketing Cloud

Double opt-in with service cloud

Double opt-in with service cloud

Double opt in is basically the must have process on any newsletter sign up page in any marketing automation tool. This will help you to establish cleaner email list of contacts, that most likely have given in their email address that they use. This will reward you with better deliverability rate.

Double opt in process, from business perspective, can be summed to following steps:

  • user fills the form, usually email address
  • email is sent to the user with action to verify their email address
  • user confirms email by clicking on the verification link.

From Salesforce Marketing Cloud, technical, perspective these steps can be translated to:

  • user fills form on a Cloud Page that can be protected with anti spam feature e.g. reCAPTCHA v3
  • after user submits form, transactional email is sent to user with unique verification link
  • user clicks, within certain time frame, on the link that navigates him to another Cloud Page which will subscribe user to the newsletter.

Let us begin to build this solution inside Salesforce Marketing Cloud. Before we begin with building our cloud pages we would need to do some administration work first.

Creating package

Each enhanced functionality, component, API interface inside SFMC that is using has to be added via create or install a package. We need to create package that is API integration component type. This will be used for triggering the transactional email once the form is successfully submitted on our registration cloud page.

Create Package setup

Next step we will chose what type of API integration we need. And that is server-to-server

Last step we select what rights this package requires. For sending transactional emails we following is required for email channel scope

  • Read, Write, Send

This will give you access to the SFMC API and we will be able to send transactional emails. Also you should have retrieved API client id and client secret under components of your newly created package

Data extension

After we defined the package we need to set up data extension that will hold information that will be part of our transactional email. Create triggered data extension from ‘triggered send’ template add fields that are missing or fields you want to capture in your double opt in process.

Create new data extension from template and select TriggeredSendDataExtension. Data extension is set to be sendable and SubscriberKey relates to Subscribers on Subscriber Key – this preset is template’s default. If you want you can also set data retention for how long you want to keep the records in the table for debugging.

We also may want to add additional fields to capture. In my case I am adding:

  • IP address
  • fingerprint – hash of user browser settings I am using fingerprintjs library
  • isContact – to have information that contact already exists in e.g. Service Cloud
  • emailSent – timestamp when the email was sent. In order to make time restriction in which user can validate their email.
  • newsletter – so we know what newsletter we subscribe the user
  • GUID – unique identifier and primary key in the table by which we can validate a particular user. This will be used as part of the validation URL.

Triggered send journey

When data extension has been created we can navigate us to the journey builder and create transactional journey. Make some thoughtful Event Definition Key. I know this part is hardest, but once you give it a name it will stick with the journey forever.

Next we select our newly created data extension to be used for saving data that this API event received.

Delivery template

We create delivery template. How it will look like is up to you. Only thing we need for our double opt in is to add the link with GUID parameter so we can identify this user when he actually click on it.

%%[
SET @guid = AttributeValue("guid")
SET @newsletter = AttributeValue("newsletter")
SET @url = CloudPagesURL("555","g", @guid)
]%%
<html lang="en">
<head>
 ...
<a conversion="true" href="%%=RedirectTo(@url)=%%">Click here to verify</a>

I had to store url in a variable as calling directly CloudPagesURL inside the href tag did throw an exception that I could not understand.

Delivery option to be set as needed. Choose the publication list to store consent and what operation to perform when we send this transactional email out.

Cloud pages

Last but not least we will create bunch of cloud pages that will take care of our double opt in process. We will cover form submission, click on email verify action, late click email verification and some basic error and thank you pages.

NOTE: Each cloud page that is involved in the double opt in process is listed below. In every page you could see script that could be divided into multiple sections so I can explain each section one by one. You can copy each section to one could page in order it to work.

PAGE1: Double opt in form cloud page

Functions to send API calls. Also we do initialize variables. You can also add reCAPTCHA v3. Everything below belongs to the same cloud page and in the order as they will appear bellow.

Helpers functions

Functions to help us with SFMC authentication, triggering API event. I did find these on internet (I am not sure where have I taken the code but , got inspired, but I guess it was here)

Also we do initialize some variables used later.

<script runat="server">
    Platform.Load("core","1.1.1");
    Variable.SetValue('@ip', Platform.Request.ClientIP);
    Variable.SetValue('@request', Platform.Request.Method);

    //functions
    function getToken(setup) {
        var config = {
            url : setup.authBaseURI + "v2/token",
            contentType : "application/json",
            payload : {
                "client_id": setup.clientId,
                "client_secret": setup.clientSecret,
                "grant_type": "client_credentials"
            }
        }
        var req = HTTP.Post(config.url, config.contentType, Stringify(config.payload));
        if (req.StatusCode == 200) {
            var res = Platform.Function.ParseJSON(req.Response[0]);
            return res.access_token;
        } else {
            return false;
        }
    }

    function triggerEvent(token, setup, data) {
        var config = {
            url : setup.restBaseURI + "messaging/v1/email/messages/" + setup.guid,
            contentType : "application/json",
            headerName : ["Authorization"],
            headerValue : ["Bearer " + token],
            payload : {
                definitionKey: setup.eventDefinitionKey,
                recipient: data
            }
        }
        var req =   HTTP.Post(config.url, config.contentType, Stringify(config.payload), config.headerName, config.headerValue);
        if (req.StatusCode == 202) {
            var res = Platform.Function.ParseJSON(req["Response"][0]);
            if (res.eventInstanceId != null && res.eventInstanceId != "") return true;
        } else {
            return false;
        }
    }
    var getTransactionalEventByNesletterName = function (newsletter){
        var newsletterMapping = {
            "default":"API-Event-NL-registration"//default in case we use only one newlsetter
            //add more in future if needed
        };
        return newsletterMapping.hasOwnProperty(newsletter) ? newsletterMapping[newsletter] : "";
    } 
</script>

Line 3 – getting the IP address

Line 4 – getting @request type to get information if  the page is accessed by submitting the form. We look for @request == ‘POST’ to process form data. This will ensure that data are not submitted with GET request by adding email parameter to the query string.

Lines 7 – 44 are taken from the internet and are functions that help us with API call to trigger an email.

Lines 45 – 51 Newsletter mapping from GET parameter. In case we have only one newsletter we use the default as our newsletter and wont provide newsletter get parameter in the form registration 

Setting up AMPScript variables eg. @errorPage

%%[
VAR @errorPage, @error, @nextPage, @email, @consent, @fingerprint, @newsletter, @salt, @initVector, @newsletterSCField, @publicationList
SET @errorPage = "435"
SET @nextPage = "434"
  
SET @error = false

  
SET @email =  RequestParameter("email")

SET @fingerprint =  RequestParameter("fingerprint")
SET @newsletter = RequestParameter("nl")
SET @isContact = "False"
SET @guid = GUID()
SET @subscriberkey = @email

IF EMPTY(@newsletter) THEN
    SET @newsletter = "default"
    /* 
      default newsletter service add more if you have more, 
      also you can send user rigth to the error page 
	*/
ENDIF
// only when we have POST request type, otherwise they will be able to submit 
// forms with GET which might cause some unwanted behaviour
IF NOT EMPTY(@email) AND @request == "POST" THEN
]%%

Lines 3-4 initialize variables used for referring error and next page

Lines 11 – 15 setting variables from form when is submitted

Lines 17 – 20 we can decide whether we throw an error or continue with default newsletter we sign contact to

Line 21 – opening of condition which will try to process the form only when we have submited the form via POST

Check if user exist in Service Cloud (optional)

<script runat="server">
    try{
</script>    
%%[ SET @contacts = RetrieveSalesforceObjects("Contact", "Id, Email", "Email", "=", @email) ]%%
<script runat="server">
    }catch(e){
        Variable.SetValue("@error", true)
    }
</script>
%%[
    IF @error THEN  Redirect(CloudPagesURL(@errorPage)) ENDIF
    IF RowCount(@contacts) THEN     
        SET @contact = Row(@contacts, 1)
        SET @subscriberkey = Field(@contact, "Id")
        SET @isContact = "True"
    ENDIF
]%% 

Line 4 – selecting contact by email address to see if we have a match

Line 11 – in case any error happens with Service Cloud Retrieve function we will catch it and display user friendly error page

Lines 12  – 17 in case we have a match we take subscriber key needed for sending an email also we set isContact field to true.

With this subscriber key set from service cloud we won’t create duplicates in our contacts. All other contacts created with the transactional journeys will have to be removed after their new service cloud counter part will be synchronized later if not already in and used for another newsletters.

You will need to establish some contact removal automation that will remove all contact which have email equal to subscriber key and they are in the triggered send data extension lets say right before the data retention process will kick in.

Execute the API call

<script runat="server">
    try {
        var newsletter =  Variable.GetValue('@newsletter').toLowerCase(),//just in case
            eventName = getTransactionalEventByNesletterName(newsletter);
            // Variable.SetValue('@publicationListId', getPublicationListByNesletterName(newsletter));
        if ( !eventName ) throw ''
        var setup = {
            authBaseURI: "https://<your_API_endpoint>.auth.marketingcloudapis.com/",
            restBaseURI: "https://<your_API_endpoint>.rest.marketingcloudapis.com/",
            clientId: "<client id>",
            clientSecret: "<client secret>",
            mid: "<mid (optional)>",
            eventDefinitionKey: eventName// in case we have multiple newsletter services
        }
    
        setup.guid = Variable.GetValue("@guid")

        var token = getToken(setup);
        var recipient = {
            /* "contactKey": Variable.GetValue("@email"), */
            "contactKey": Variable.GetValue("@subscriberkey"),
            "to": Variable.GetValue("@email"),
            "attributes": {
                "newsletter": Variable.GetValue("@newsletter"),
                "guid": Variable.GetValue("@guid"),
                "isContact": Variable.GetValue("@isContact"),
                "fingerprint": Variable.GetValue("@fingerprint"),
                "ip": Platform.Request.ClientIP
            }
        }
        var success = false;
        if (!!token) success = triggerEvent(token, setup, recipient)
        if (!!success) Variable.SetValue("@error", false)
    } catch (err) {
        /* Write("Error: " + Stringify(err)); */
        Variable.SetValue("@error", true)
    }
</script>

Line 4 – make sure this eventName ​​​​​​​mapped from GET nl parameter in function defined on line

Line 7-14 setup for API call. Information to be found in the package we created at the beginning

Line 16 – What BU we execute the call (not BU set means parent BU)

Lines 23 – 28 variables we save to the Data extension

  • newsletter name
  • GUID it was already created
  • fingerprint to distinguish duplicates when the same email is used
  • ip address 

Redirect to the next page in case of no error

%%[ 
    IF NOT @error THEN Redirect(CloudPagesURL(@nextPage, 'nl', @newsletter)) ENDIF
ENDIF 
]%%

Display actual form

<!doctype html>
<html>
<head>
 <meta charset="utf-8">
 <title></title>
 <meta name="description" content="">
 <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>Newsletter - <span>%%=v(@newsletter)=%%</span></h1>
%%[ 
SET @inputText = ' placeholder="Type your email"'
IF NOT EMPTY(@email) AND @request == "GET" THEN 
   SET @inputText = concat(' value="', v(@email), '"') 
ENDIF 
]%%
<div class="main-page">
    <form id="subscribe-form" action="%%=RequestParameter("PAGEURL")=%%" method="post">
        <input type="email" name="email" %%=v(@inputText)=%% required/>
        <input type="hidden" id="fingerprint" name="fingerprint"/>
        <input type="submit" class="button" type="submit" role="button" value="Register"/>
    </form>
</div>
<script>
const form = document.getElementById("subscribe-form");
form.addEventListener('submit', (e)=>{   
    const email = document.querySelectorAll("input[type=email]")[0]
    // const checkbox = document.querySelectorAll("input[type=checkbox]")[0]
    let submit = document.querySelectorAll("input[type=submit]")[0]
    submit.disabled = true
})
function initFingerprintJS() {
    // Initialize an agent at application startup.
    const fpPromise = FingerprintJS.load()

    // Get the visitor identifier when you need it.
    fpPromise
    .then(fp => fp.get())
    .then(result => {
        document.getElementsByName("fingerprint")[0].setAttribute("value",result.visitorId)
    })
}
</script>
<script
async
src="//cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@3/dist/fp.min.js"
onload="initFingerprintJS()"
></script>
</body>
</html>

Lines 18 – 22 html mark up for form.

Lines 25-31 will ensure the submit is disabled after we click it as it may send multiple emails at the same time

Lines 32 -42 Fingeprint initialization 

Adding reCAPTCHA is highly recommended to avoid some spam traps and emails that might be not working at all and possibly hurt your IP reputation if the form submission is abused.

PAGE 2: Almost done page

Basically thank you for subscribing cloud page that will let know user about success of the operation and that email is on their way to verify it.

%%[
VAR @errorPage, @newsletter, @publicationList
SET @errorPage = "435"

SET @newsletter =  RequestParameter('nl');

IF EMPTY(@newsletter) THEN   Redirect(CloudPagesURL(@errorPage)) ENDIF

]%%

<html lang="cs">
    <body>
    <h1>Newsletter - <span>%%=v(@newsletter)=%%</span></h1>
    <div>
        <h2>Almost done</h2>
        <p>We require you to confirm your e-mailovou address.</p>   
        <p>Email has been sent to provided address, please confirm it.</p>   
    </div>
    </body>
 </html>

Line 5 – we are getting newletter name so we are able to make this page custom for each newsletter. Adding different content block urls etc

PAGE 3: Verify cloud page

Second page to create in our collection is page that user will land after they click on the verification email link.

%%[
VAR @publicationListId, @newsletterSCField

SET @errorPage = "999"
SET @missedVerify = "998"
SET @guid = RequestParameter("g")
SET @guidValidFor = 120

/* Go to error page when no guid in query params */
IF  EMPTY(@guid)  THEN  Redirect(CloudPagesURL(@errorPage,'r', 1)) ENDIF

SET @contactRows = LookupRows("NL_email_verify_log", "guid", @guid)

/* Go to error page when we do not find contact or find more than one */
IF RowCount(@contactRows) == 0 THEN Redirect(CloudPagesURL(@errorPage,'r',2)) ENDIF
/* IF RowCount(@contactRows) > 1 THEN You won $1000000, should never happen  :) ENDIF */

SET @contactRow = row(@contactRows, 1)

SET @isContact = field(@contactRow, "isContact")
SET @email = field(@contactRow, "EmailAddress")
SET @newsletter = field(@contactRow, "newsletter")
SET @emailSent = field(@contactRow, "emailSent")
SET @emailVerified = field(@contactRow, "emailVerified")
SET @subscriberkey = field(@contactRow, "SubscriberKey")
]%%

Line 6 – we are passing the GUID which is unique identifier of the each transactional email sent

Line 7 – how many hours is this GUID valid and we can verify the email. After this period we will be redirected and prompted to submit the form again

Lines 12 – 25 getting information from the transactional email 

Service cloud newsletter field mapping. In service cloud each newsletter can have separate field for different newsletters.

 <script runat="server">
    //get newsletter service cloud field name by the get parameter 'nl'
    Platform.Load("core","1.1.1");
var getServiceCloudFieldByNesletterName = function (newsletter){
  var newsletterMapping = {
    "bestnewsletter":"someNiceNewsletter__c"
    //add more in future
  };
  return newsletterMapping.hasOwnProperty(newsletter) ? newsletterMapping[newsletter] : "";
} 
var getNewsletterHomepage = function (newlsetter) {
  var newsletterHomepage = {
    "bestnewsletter":"https://some-nice-page.com/"
    //add more in future
  };  
  return newsletterHomepage.hasOwnProperty(newsletter) ? newsletterHomepage[newsletter] : "";
}
var newsletter =  Variable.GetValue('@newsletter').toLowerCase();
Variable.SetValue('@newsletterSCField', getServiceCloudFieldByNesletterName(newsletter));
Variable.SetValue('@newlsetterHomepage', getNewsletterHomepage(newsletter));
</script>

Line 5 – 8  – mapping of each newsletter GET parameter nl to service cloud field name

Line 11 – 17 – different return url for different newlsetters

Create contacts and accounts in the service cloud (optional)

We check if the contact exists in the service cloud by looking for account that name is equal to the email as is most likely a private person and not a company. This of course may vary depending on what you have been requested to reconcile on and how the service cloud implementation has been done. Following example is just for illustration purposes only and it is not some golden standard you should follow 🙂

%%[
IF EMPTY(@emailVerified) THEN
    /* When the verification happend after the limit - 5 days */
    IF EMPTY(@emailSent) THEN Redirect(CloudPagesURL(@errorPage)) ENDIF
    IF DateDiff(DateParse(@emailSent,1), Now() ,"H") >= @guidValidFor THEN Redirect(CloudPagesURL(@missedVerify)) ENDIF

    IF EMPTY(@newsletterSCField) THEN  Redirect(CloudPagesURL(@errorPage)) ENDIF

    /* Verified but not yet in Service Cloud as Contact (Account can exist) */
    IF @isContact == "False" THEN
        /* Check if we have account */
        SET @accounts = RetrieveSalesforceObjects("Account", "Id, Name", "Id, Name", "Name", "=", @email))
        IF RowCount(@accounts) THEN     
            SET @account = Row(@accounts, 1)
            SET @accountId = Field(@account, "Id")
        ELSE
			//account was not found
            SET @accountId = CreateSalesforceObject("Account", 6, 
                "Name", @email, 
				//other fields that are required
				// type of an account.. eg. subscriber etc.
     
            )
        ENDIF
        IF EMPTY(@accountId) THEN Redirect(CloudPagesURL(@errorPage, "nl", @newsletter,'r',4)) ENDIF
        
        SET @subscriberkey = CreateSalesforceObject("Contact", 5, 
            "AccountId", @accountId, 
            "LastName", @email, 
            "Email", @email
        )

        IF EMPTY(@subscriberkey) THEN
            Redirect(CloudPagesURL(@errorPage, "nl", @newsletter,'r',5))
        ENDIF
        UpdateData("NL_email_verify_log", 1, 
            "guid", @guid, 
            "emailVerified", FormatDate(Now(),"iso"), 
            "isContact", "True"
        )
    ELSE    
        IF @email == @subscriberkey THEN
            SET @contacts = RetrieveSalesforceObjects("Contact", "Id", "Email", "=", @email)
            IF RowCount(@contacts) THEN   
                SET @contact = Row(@contacts, 1)
                SET @contactId = Field(@contact, "Id")
            ELSE
                Redirect(CloudPagesURL(@errorPage'r',6))
            ENDIF
        ELSE
            SET @contactId = @subscriberkey
        ENDIF
    ENDIF
    /* Contcat exists in Service cloud just write the confirmation timestamp to the DE */
    UpdateData("NL_email_verify_log", 1, "guid", @guid, "emailVerified", FormatDate(Now(),"iso"))
    UpdateSingleSalesforceObject("Contact", @contactId, @newsletterSCField, "True")
ENDIF   
]%%
<html lang="en">
<body>
<h1>Newsletter - <span>%%=v(@newsletter)=%%</span></h1>
<div>
    <h2>You have been successfully registered to the newsletter</h2>

    <br/>
    %%[IF NOT EMPTY(@newlsetterHomepage) THEN  ]%%
        <a class="button" href="%%=v(@newlsetterHomepage)=%%">Go to our pages</a>
    %%[ENDIF]%%
</div>
</body>
</html>

Line 2 – in case contact lead clicks on the link multiple times so we do not do the verification process again. As we subscribe recipient to the newsletter not taking into consideration the previous state

Line 5 – when the time for email verify runs out we prompt contact lead to go over the process again

Line 7 – when the field was not mapped by the newsletter name we go to the error page

Lines 11 – 45 – when the contact is not he service cloud we will create it in service cloud along with account. This logic may change based on the clients requirements. Especially lines 18 – 24 and 30 – 34 as there are used to create these objects in service cloud

Lines 47 – 58 when the contact is available in the service cloud

Line 61 – update the verify log with information that the contact lead email has been verified

Line 62 – update salesforce object newsletter flag to true (subscribe contact lead to the newsletter)

Oh hi there 👋
I have a FREE e-book for you.

Sign up now to get an in-depth analysis of Adobe and Salesforce Marketing Clouds!

We don’t spam! Read our privacy policy for more info.

#cloud page #transactional emails
Marcel Szimonisz
Marcel Szimonisz
MarTech consultant As a marketing automation consultant, I specialize in problem-solving, process automation, and driving innovation for clients' marketing platforms.

I hold certifications in Adobe Campaign v6 (3x certified) and Salesforce Marketing Cloud (5x certified), as well as 1x Salesforce Associate certified.

Moreover, I serve as a community advisor for Adobe Campaign, providing expert insights and guidance.

Beyond my professional pursuits, I explore various programming languages, CMSs, and frameworks, enhancing my technical expertise and staying at the forefront of industry advancements.
Take a look at our subscription offering in case you are looking for a Marketing Automation Consultant.

Leave a comment

Your email address will not be published. Required fields are marked *

Similar posts that you may find useful

Adobe Campaign Classic tips
ACC Tips & Tricks, Adobe Campaign, Marketing Automation

Column must appear in GROUP BY clause

1 minute read

Creating aggregated data reports may not be a daily task, but there are times when you might be tasked with generating such reports. For instance, you could group and track log clicks to create the coveted ‘Hot Clicks Report’. You tell yourself, ‘It’s nothing major,’ and believe you have an idea of how to do […]

Continue reading
Adobe Campaign Classic REST over SOAP
Adobe Campaign, Marketing Automation

Build REST over SOAP API in adobe campaign

4 minutes read

If you’re familiar with Adobe Campaign Classic, you may have noticed that it utilizes the SOAP (Simple Object Access Protocol) API. We live in an age where REST is taking over, and the good old XML SOAP is slowly being forgotten. To me, as an old-timer, I can confidently say that it doesn’t make any […]

Continue reading
Adobe Campaign Classic tips
ACC Tips & Tricks, Adobe Campaign, Marketing Automation

Workflow stuck in pending start status

1 minute read

Have you ever found yourself waiting for a campaign workflow to start, only to experience delays or long waiting times? I’ve got a handy trick to share with you that can help you initiate the workflow immediately or significantly faster. I know that campaign managers schedule sometimes can be tight and waiting for pending start […]

Continue reading
Spawn workflows programatically
Adobe Campaign, Marketing Automation

Deploy workflow templates with JavaScript

3 minutes read

Adobe Campaign is a powerful tool for creating and managing marketing campaigns. One of its most useful features is the ability to create automated workflows. In this post, we will walk you through the process of “spawning” workflows in Adobe Campaign. What is spawning a workflow? And why and where we can it be useful […]

Continue reading
How to JavaScript in SFMC
Marketing Automation, Salesforce Marketing Cloud

JavaScript in Salesforce Marketing Cloud

3 minutes read

Salesforce Marketing Cloud uses JavaScript where a advanced customization is needed e.g. automations, cloud pages and even in message personalization. Last time we discussed how to JavaScript in Adobe Campaign, we discovered that it utilizes an older version of ECMAScript. However, it’s worth noting that Salesforce Marketing Cloud (SFMC) goes even further back and employs […]

Continue reading