background shape
background shape
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.

Share With Others

Leave a Comment

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

MarTech consultant

Marcel Szimonisz

Marcel Szimonisz

I specialize in solving problems, automating processes, and driving innovation through major marketing automation platforms.

Buy me a coffee