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 😉

  8. Hi Ivan! Super interesting article! Somehow it is not working for me and I think I am missing some fields. Is there a list of what I need to edit in the code snippets? I always see, that after clicking the button, that the URL is not found (error code 251).

  9. Tom, could you verify that the PageId you are using exists in your Business Unit?

  10. Hi Ivan, I have created the form using your code but I am getting “Your token is missing or has expired.” error every time. I have cross checked the pageIDs and redirectURL etc.. but not finding any error. Please help in this issue.

  11. Hello there, I think it might be related to the field CreatedDate, have a closer look at which data you are capturing there.

  12. Hi, there thanks so much for this article!
    I have two questions- 1) I cannot seem to get the Code for the Form Handler right. When I try to publish, it shows an error notification. The external key as well as my form page id are correct. What else do I have to change?
    2) What for do I need the last code snippet ? or where do I insert it?
    Really hoping you can help me 🙂

  13. Hi Anna, what kind of error are you getting? What does it say?

  14. The last snippet generates the data extensions for you, it’s optional

  15. Hi, the error notification says: An error occurred whil previewing this content. This can happen for many reasons, including incomplete or incorrect MC scripting (AMPscript, SSJS or GTL) or missing subscriber context. Click Cancel to review your code or Publish to push the updated content live.

  16. Don’t mind this error, publish the page and see if the flow works

  17. Hi Ivan, I followed all the steps mentioned in the article. Still I am getting the 500 internal server error when I am publishing the form handler page or trying to submit the email from form. Please help me out.

  18. Hi I am trying to execute this approach and have successfully built all components associated. But I am getting 500 internal server error while I am publishing the form handler page. Please let me know where can I possibly go wrong because I have checked everything.

  19. It could be a million things. Please proceed step by step and read my article and my code carefully.

  20. It could be a million things. Please proceed step by step and read my article and my code carefully.

  21. Hello,
    Does sending the triggered send that way create a new subscriber record in All Subscribers at the send time? I noticed that an external API call doesn’t, but doing the same via SSJS requires existing subscriber with status Active. In other case an email does not make its way to subscriber’s inbox.

    From my perspective it doesnt make sense to create a subscriber before double opt-in process has been finished. What are your thoughts?

  22. Thanks for this, can you explain a bit more where I can get the customer key for the template in the code for making the DEs?

    Thanks,
    J

  23. It was just a proof of concept on my end, but you are correct. At the time of writing this article I wasn’t really mindful of SFMC cleaning and governance best practices.

Comments are closed.

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