How to create a listless double opt-in in Marketing Cloud with AMPscript

How to create a listless double opt-in in Marketing Cloud with AMPscript

This article explains how to create a custom double opt-in with AMPscript in Salesforce Marketing Cloud, without using lists.

Not a fan of reading? Jump to the code snippet.

How it works

In order to create a double opt-in, we need to define how the opt-ins are created for every customer.

In this example, let us consider that creating an opt-in is just a matter of setting a field value from False to True in a Data Extansion called Consents.

Therefore, our first task would be creating a record in this Data Extension with the opt-in set to False by default.

Then, we will send an email with an activation link that would allow the customer to switch the opt-in from False to True. This link should be unique, secure and should have an expiration date of 15 minutes.

Data Extensions

We need to create 2 Data Extensions. The first one, Consents, will manage the opt-ins and the second one, Tokens, will cover the activation link and the link expiration date.

Both Data Extensions will be linked through the unique identifier of the customer, in this case Email.

Consents

Name FieldType IsPrimaryKey IsRequired DefaultValue
Email EmailAddress truetrue
Opt-In Boolean false

Tokens

Name FieldType IsPrimaryKey IsRequired MaxLength
Email EmailAddress truetrue254
Token Text500
CreatedDate Date

Triggered send

To create an Email with the activation link, we need to create a Triggered Send interaction.

As usual, to make it possible, we have to create a TriggeredSend Data Extension and an Email message.

After the Triggered Send has been created, we need to start it and take a note of the External Key (in this case 112233).

TriggeredSend Data Extension

Name FieldType IsPrimaryKey IsRequired MaxLength
SubscriberKey Text truetrue
EmailAddress EmailAddress 254
TokenText500

Email

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Confirmation Email</title>
</head>
<body>
    <a href="%%=RedirectTo(CloudPagesURL(7788,'token',AttributeValue('Token')))=%%">Confirm your subscription</a>
    <custom name="opencounter" type="tracking" />
</body>
</html>

Pages

In order to make our flow, we need to create 2 Cloud pages.

Form

The Form page (Page ID: 4455) will accommodate the subscription form and will send the data to the Form Handler page.

According to the action parameter present in the URL of the page, the Form will be hidden and a message will be shown instead.

%%[

SET @formHandlerPageId = 7788
SET @action = RequestParameter("action")
SET @pageUrl = RequestParameter('PAGEURL')

IF IndexOf(@pageUrl,'?') > 0 THEN
    SET @pageUrl = Substring(@pageUrl,0,Subtract(IndexOf(@pageUrl,'?'),1))
ENDIF

]%%
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Subscription Form</title>
    <style>
        .wrapper {
            width:600px;
            min-height: 300px;
            margin: 5% auto 0;
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <h1>Newsletter subscription.</h1>
        %%[ IF @action == "confirm" THEN ]%%
            <p>Please check your email.</p>
        %%[ ELSEIF @action == "success" THEN ]%%
            <p>Thank you for subscribing.</p>
            <p><a href="%%=v(@pageUrl)=%%">Go back</a></p>
        %%[ ELSEIF @action == "error" THEN ]%%
            <p>Your token is missing or has expired.</p>
            <p><a href="%%=v(@pageUrl)=%%">Please try again</a></p>
        %%[ ELSEIF @action == "exist" THEN ]%%
            <p>Your are already subscribed.</p>
            <p><a href="%%=v(@pageUrl)=%%">Go back</a></p>
        %%[ ELSE ]%%
            <form method="POST" action="%%=CloudPagesURL(@formHandlerPageId)=%%">
                <label for="Email">Your email:</label><br>
                <input name="email" type="email" placeholder="ex: jon.snow@winterfell.co.uk">
                <br>
                <button>Subscribe me</button>
            </form>
        %%[ ENDIF ]%%
    </div>
</body>
</html>

Form handler

The Form Handler page (Page ID: 7788) will handle 2 main scenarios.

The first one is what happens when the data is coming from the Form, and the second is what happens when the data is coming from the activation link.

In the first scenario, we generate a secret key (token), create a record in the Consents and Tokens, send the Email with the activation link and then redirect back to the Form with a confirm message.

We also need to check if the provided Email has his opt-in set to True already.

In the second scenario, we check if the token exists in the Tokens Data Extension and was created less than 15 minutes ago. If everything checks out, we update the opt-in from False to True in the Consents and redirect back to the Form with a success message.

%%[
    SET @email = RequestParameter("email")
    SET @token = RequestParameter("token")
    SET @today = SystemDateToLocalDate(NOW())

    SET @ts_externalKey = 112233
    SET @formPageId = 4455

    IF NOT EMPTY(@email) THEN

        IF Lookup("Consents","Opt-In","Email",@email) == "True" THEN
            Redirect(CloudPagesURL(@formPageId,"action","exist"))
        ENDIF

        SET @token = GUID()

        UpsertDE("Consents",1,"Email",@email,"Opt-In","False")
        UpsertDE("Tokens",1,"Email",@email,"Token",@token,"CreatedDate",@today)

        IF NOT EMPTY(@ts_externalKey) THEN

            SET @ts = CreateObject("TriggeredSend")
            SET @ts_definition = CreateObject("TriggeredSendDefinition")
            SetObjectProperty(@ts_definition, "CustomerKey", @ts_externalKey)
            SetObjectProperty(@ts, "TriggeredSendDefinition", @ts_definition)

            SET @ts_subscriber = CreateObject("Subscriber")
            SetObjectProperty(@ts_subscriber, "EmailAddress", @email) 
            SetObjectProperty(@ts_subscriber, "SubscriberKey", @email) 

            SET @ts_token = CreateObject("Attribute")
            SetObjectProperty(@ts_token, "Name", "Token")
            SetObjectProperty(@ts_token,"Value", @token)
            AddObjectArrayItem(@ts, "Attributes", @ts_token)

            AddObjectArrayItem(@ts, "Subscribers", @ts_subscriber)  
            
            InvokeCreate(@ts, @statusMsg, @errorCode) 

        ENDIF

        Redirect(CloudPagesURL(@formPageId,"action","confirm"))

    ELSEIF NOT EMPTY(@token) THEN

        SET @createdDate = Lookup("Tokens","CreatedDate","Token",@token)
        SET @email = Lookup("Tokens","Email","Token",@token)

        IF NOT EMPTY(@createdDate) AND @today < DateAdd(@createdDate, 15, "MI")  THEN

            UpdateData("Consents",1,"Email",@email,"Opt-In","True")
            
            Redirect(CloudPagesURL(@formPageId,"action","success")) 

        ELSE

            Redirect(CloudPagesURL(@formPageId,"action","error")) 

        ENDIF

    ELSE
        Redirect(CloudPagesURL(@formPageId,"action","error")) 
    ENDIF
]%%

Considerations

Note that we create links and redirects using the CloudPagesURL function.

Also, in this example, we use the GUID() function to generate the token, but feel free to use whatever method necessary to create a key which is unique.

Generate the Data Extensions

Please consider the following code for generating the Data Extensions for this project.

Note that in order to generate a TriggeredSend Data Extension, you need to know the Customer Key for the TriggeredSend Template. You can find it in the Marketing Cloud Contact Builder interface by choosing this template while creating a new Data Extension.

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

    var prox = new Script.Util.WSProxy();

    var req1 = Folder.Retrieve({ Property: 'Name', SimpleOperator: 'equals', Value: 'MyFolder' });
    var FolderID = req1[0].ID;

    var dataExt1 = {
        "CustomerKey": Platform.Function.GUID(),
        "Name": "Consents",
        "CategoryID": FolderID,
        "Fields": [
            {
                "Name": "Email",
                "FieldType": "EmailAddress",
                "IsPrimaryKey": true,
                "IsRequired": true
            },
            {
                "Name": "Opt-In",
                "FieldType": "Boolean",
                "DefaultValue": false
            }
        ]
    }

    var dataExt2 = {
        "CustomerKey": Platform.Function.GUID(),
        "Name": "Tokens",
        "CategoryID": FolderID,
        "Fields": [
            {
                "Name": "Email",
                "FieldType": "EmailAddress",
                "IsPrimaryKey": true,
                "IsRequired": true,
                "MaxLength": 254
            },
            {
                "Name": "Token",
                "FieldType": "Text",
                "MaxLength": 500
            },
            {
                "Name": "CreatedDate",
                "FieldType": "Date"
            }
        ]
    }

    var dataExt3 = {
        "CustomerKey": Platform.Function.GUID(),
        "Name": "TSD_Tokens",
        "CategoryID": FolderID,
        "IsSendable": true,
        "Fields": [
            {
                "Name": "Token",
                "FieldType": "Text",
                "MaxLength": 500
            }
        ],
        "SendableSubscriberField": {
            "Name": "_SubscriberKey"
        },
        "SendableDataExtensionField": {
            "Name": "SubscriberKey"
        },
        "Template": {
            "CustomerKey": "THIS-KEY-IS-UNIQUE-TO-YOUR-SYSTEM"
        }
    }

    var response = prox.createBatch("DataExtension", [dataExt1, dataExt2, dataExt3]);
    Write(Stringify(response));
</script>

Have I missed anything?

Please poke me with a sharp comment below or use the contact form.

  1. Thanks for the article. I have a question. What Status have not activated Subscribers in All Subscribers list? In other words, how All Subscribers list correlates with Consents DE and its “TRUE”/”FALSE” values?

  2. Hi Liza! It doesn’t in this case. It’s up to you to integrate it with whatever system manages your subscribers.

  3. Hi Ivan, nice to meet you, and thanks for the AMPscript!
    I manually followed your guide first and went back and used the server script you provided for setting up the DEs. All seems fine in both cases, but when testing the form. I enter an email and click the “Subscribe me” button—no problem with the email coming to the inbox. I then click the “Confirm your subscription” within the email, and the link takes me to the page.

    This part is where I have a problem. The URL link returns me to the /signup page (Form page) with the form page where I can enter the email address and submit, rather than getting the message “Thank you for subscribing.”

    Troubleshooting — I tried replacing the “/signup page” (Form page) with “/tokens” (Form handler page) while keeping the ?qs=247…(token) in the URL. And this replacement worked! It returns me the /signup page with the message “Thank you for subscribing.”

    I can’t find the problem, do you have any suggestions?

  4. Hi Dan! I recreated the flow from scratch using my own article and haven’t had any issues. I think you mixed up some page ids and put the Id of the form page where the Id of the form handler should be: SET @formPageId = 1234 is the Form page; SET @formHandlerPageId = 1234 is the Form Handler;

  5. Thanks, Ivan. I did get it to work, I changed the email redirect to :
    %%=RedirectTo(CONCAT(CloudPagesURL(123,’token’,AttributeValue(‘Token’))))=%%. This seemed to do the trick, or re-saving the trigger email seemed to fix everything. Thanks again for all your help.

  6. Thank you very much for the terrific example. I have a question about the upsert function on the handler page. It’s using UpsertDE, but according to the documentation UpsertDE should be used only in an email at send time. Should this use UpsertData instead, or is there a reason not to use UpsertData? Thank you again.

  7. Excellent question! Simple answer: don’t trust the official documentation! It’s incomplete and full of errors. I started my blog because I was fed up with how bad the documentation was 😉

Leave a Reply

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

server-side Javascript
Up Next:

How to clone a Data Extension and its records with server-side JavaScript

How to clone a Data Extension and its records with server-side JavaScript