Store Opt-Out data on Twilio with Functions and Sync

June 16, 2022
Written by
Reviewed by

Storing Opt-Out data on Twilio with Functions and Sync

Staying compliant is an important part of your messaging strategy. It ensures that your senders have a high trust level from carriers and, as a result, experience lower filtering and lower risk of being blocked.

Maintaining a clean opt-out list is one of the key pillars for compliant messaging. The Twilio platform helps you maintain customer trust via automated opt-out management (more here), and it's a great start, but under certain circumstances, opt-out information can be lost. Additionally, Twilio does not yet provide an API to retrieve opt-out data.

All that's to say: we at Twilio strongly recommend you maintain and store opt-out data yourself. In this blog post, I'll show you how storing opt-out information can be done on the Twilio platform with Serverless Functions and Sync.

Tutorial prerequisites

Before we can get started building, you need to make sure you have an account with Twilio. You can sign up for a free account here.

Once you've got your account, note the following details – you will need them later:

And with that, you're ready to start.

As I mentioned, we will be leveraging Twilio Functions and Sync. But before we get to writing code, here is our high-level plan:

  1. Create a Messaging Service and add a phone number to it
  2. Create a Sync service and a Map object in it; the Map will be our opt-out data storage.
  3. Create a Function that will be called once the customer sends a message to the phone number. The function will be responsible for:
    1. analyzing the message body to see if it is one of opt-out or opt-in words
    2. if the body is an opt-out word the function will add the sender number to the Sync Map
    3. if the body is an opt-in word the function will remove the sender number from the Sync Map
  4. Update Messaging Service settings to direct a webhook on an incoming message to the function URL.

Here is a diagram of the solution we will be building:

Storing Opt-Out data on Twilio with Functions and Sync Diagram

Sync setup

As mentioned before, we will be storing our opt-out data in a Sync Map.

First, we need a Sync Service and then a Map within the service. In your Twilio account, a Sync Service with the name `Default Service` should already exist – it is okay to use this service, and for this blog post, I will be using the default service. Alternatively, you can create a new one if you navigate to the Sync Services page in the Twilio Console.

For configuring your environment variables later, you will need Sync Service SID. You can see the SIDs of all your services if you go to the Sync Services page in the Twilio Console (note the SID of the service you will be using).

Next, navigate to the chosen Sync Service, select Maps on the left, and create a new Sync Map. You will have to provide a name for your new Map – make sure to leave Time to blank. After the Map is created, take a note of its SID... we will need it later.

Once you have a Sync Service and Map, we are ready to move on.

Developer Environment Setup

Before we continue, let's make sure you have the software you need:

I will use Typescript for this tutorial, but it should work just as well with JavaScript.

Now we can start coding!

Create project

We will start by creating a project using the Twilio Serverless Toolkit. For this run, issue the following command in your shell:

twilio serverless:init opt-out-data-storage --typescript

A couple of notes here:

  • in the command, I used opt-out-data-storage as my project name, feel free to use a different name
  • adding the --typescript parameter will create a project ready for Typescript. You can omit this parameter if you prefer JavaScript

With this step completed, you have a project that you can run in your local environment or deploy directly to Twilio Functions.

You will find a couple of function examples under the src/functions folder and a couple of asset examples under src/assets, you can safely remove them. (Or you can ignore them, whichever you prefer.)

Configure environment

In order to run the code, we'll need environment variables to be in place. Go to .env in the root folder of the project, and update the file to have following keys and values. Make sure to replace placeholders with the values you collected in previous steps.

ACCOUNT_SID=<Twilio Account SID>
AUTH_TOKEN=<Twilio Auth Token>
SYNC_SERVICE_SID=<Sync Service SID>
SYNC_MAP_SID=<Sync Map SID>

Create function

Now it is time to create the file you will be working inside. Navigate to src/functions, and create a file namedcapture-opt-out.protected.ts.

While you are free to choose any other name for your function, make sure that the name ends with .protected.ts (or .protected.js) because this defines the visibility level of the function.

We will start with the following snippet as it contains a bare minimum code to complete a function:

// Imports global types
import '@twilio-labs/serverless-runtime-types'
// Fetches specific types
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'

export const handler: ServerlessFunctionSignature = async function (
  context: Context,
  event: {},
  callback: ServerlessCallback,
) {
  callback(null, 'Hello from Twilio Functions!')
}

We know what parameters our function will take and what environment variables it will use. Reflected in the code this will look like:


// Imports global types
import '@twilio-labs/serverless-runtime-types'
// Fetches specific types
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'

type InboundMessage = {
  Body: string,
  From: string
}

type OptOutContext = {
  SYNC_SERVICE_SID: string,
  SYNC_MAP_SID: string
}

export const handler: ServerlessFunctionSignature<OptOutContext, InboundMessage> = function (
  context: Context<OptOutContext>,
  event: InboundMessage,
  callback: ServerlessCallback,
) {
  callback(null, 'Hello from Twilio Functions!')
}

Next, we can add a list of our opt-out and opt-in keywords (here I will use the default lists. You can find defaults under "Opt-Out Management" section in your Messaging Service.)

If needed, you can leverage Twilio's Advanced Opt-out feature to modify the lists, this blogpost describes in details how to do it.


// Imports global types
import '@twilio-labs/serverless-runtime-types'
// Fetches specific types
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'

type InboundMessage = {
  Body: string,
  From: string
}

type OptOutContext = {
  SYNC_SERVICE_SID: string,
  SYNC_MAP_SID: string
}

const optOutWords = new Set<string>([
  'stop',
  'cancel',
  'end',
  'quit',
  'unsubscribe',
  'stopall',
])

const optInWords = new Set<string>([
  'start',
  'yes',
  'unstop',
])

export const handler: ServerlessFunctionSignature<OptOutContext, InboundMessage> = function (
  context: Context<OptOutContext>,
  event: InboundMessage,
  callback: ServerlessCallback,
) {
  callback(null, 'Hello from Twilio Functions!')
}

Now we can start working on the logic to add or remove a number from the Map once we detect opt-out or opt-in keywords.

First, we will construct a messaging response object. We will use it to send feedback and we will always send an empty response, meaning no message will be sent to the sender. Second, we will have if-else with three branches: opt-out, opt-in, and default. The default branch will do nothing; we want to ignore everything that is not an opt-out or opt-in keyword.


// Imports global types
import '@twilio-labs/serverless-runtime-types'
// Fetches specific types
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'

type InboundMessage = {
  Body: string,
  From: string
}

type OptOutContext = {
  SYNC_SERVICE_SID: string,
  SYNC_MAP_SID: string
}

const optOutWords = new Set<string>([
  'stop',
  'cancel',
  'end',
  'quit',
  'unsubscribe',
  'stopall',
])

const optInWords = new Set<string>([
  'start',
  'yes',
  'unstop',
])

export const handler: ServerlessFunctionSignature<OptOutContext, InboundMessage> = function (
  context: Context<OptOutContext>,
  event: InboundMessage,
  callback: ServerlessCallback,
) {
  
  // Response object
  const response = new Twilio.twiml.MessagingResponse()

  if (optOutWords.has(event.Body.trim().toLowerCase())) {
    console.log('We\'ve got opt-out', event)
    // Opt-out path -> here we should add number to the map
    callback(null, response)
  } else if (optInWords.has(event.Body.trim().toLowerCase())) {
    console.log('We\'ve got opt-in', event)
    // Opt-in path -> here we should remove number from the map
    callback(null, response)
  } else {
    callback(null, response)
  }
}

Next, we will write code to add a number to the Map if we detect an opt-out keyword. A couple of notes here:

  • If the Map already contains the number, an exception will be thrown. This is going to happen if a user who opted-out already sends an opt-out keyword again. We can ignore the exception as the state in our storage should not change.
  • We will use the sender number as the "key" for our new Map Item and keep that "data" as an empty JSON object. In production, you can add other useful information to the data object, for example a timestamp.

// Imports global types
import '@twilio-labs/serverless-runtime-types'
// Fetches specific types
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'

type InboundMessage = {
  Body: string,
  From: string
}

type OptOutContext = {
  SYNC_SERVICE_SID: string,
  SYNC_MAP_SID: string
}

const optOutWords = new Set<string>([
  'stop',
  'cancel',
  'end',
  'quit',
  'unsubscribe',
  'stopall',
])

const optInWords = new Set<string>([
  'start',
  'yes',
  'unstop',
])

export const handler: ServerlessFunctionSignature<OptOutContext, InboundMessage> = function (
  context: Context<OptOutContext>,
  event: InboundMessage,
  callback: ServerlessCallback,
) {

  // Response object
  const response = new Twilio.twiml.MessagingResponse()

  if (optOutWords.has(event.Body.trim().toLowerCase())) {
    console.log('We\'ve got opt-out', event)

    context.getTwilioClient()
      .sync
      .services(context.SYNC_SERVICE_SID)
      .syncMaps(context.SYNC_MAP_SID)
      .syncMapItems
      .create({
        key: event.From,
        data: {},
      })
      .then(_ => {
        console.log('Successfully stored map item')
        callback(null, response)
      })
      .catch(err => {
          console.log(err)
          callback(null, response)
        },
      )
  } else if (optInWords.has(event.Body.trim().toLowerCase())) {
    console.log('We\'ve got opt-in', event)
    // Opt-in path -> here we should remove number from the map
    callback(null, response)
  } else {
    callback(null, response)
  }
}

Now, let's look at the last piece of code – the opt-in branch. Here, we remove a sender number from the Map. Once again, if the number is not in the Map we will ignore the exception.


// Imports global types
import '@twilio-labs/serverless-runtime-types'
// Fetches specific types
import {
  Context,
  ServerlessCallback,
  ServerlessFunctionSignature,
} from '@twilio-labs/serverless-runtime-types/types'

type InboundMessage = {
  Body: string,
  From: string
}

type OptOutContext = {
  SYNC_SERVICE_SID: string,
  SYNC_MAP_SID: string
}

const optOutWords = new Set<string>([
  'stop',
  'cancel',
  'end',
  'quit',
  'unsubscribe',
  'stopall',
])

const optInWords = new Set<string>([
  'start',
  'yes',
  'unstop',
])

export const handler: ServerlessFunctionSignature<OptOutContext, InboundMessage> = function (
  context: Context<OptOutContext>,
  event: InboundMessage,
  callback: ServerlessCallback,
) {

  // Response object
  const response = new Twilio.twiml.MessagingResponse()

  if (optOutWords.has(event.Body.trim().toLowerCase())) {
    console.log('We\'ve got opt-out', event)

    context.getTwilioClient()
      .sync
      .services(context.SYNC_SERVICE_SID)
      .syncMaps(context.SYNC_MAP_SID)
      .syncMapItems
      .create({
        key: event.From,
        data: {},
      })
      .then(_ => {
        console.log('Successfully stored map item')
        callback(null, response)
      })
      .catch(err => {
          console.log(err)
          callback(null, response)
        },
      )
  } else if (optInWords.has(event.Body.trim().toLowerCase())) {
    console.log('We\'ve got opt-in', event)
    context.getTwilioClient()
      .sync
      .services(context.SYNC_SERVICE_SID)
      .syncMaps(context.SYNC_MAP_SID)
      .syncMapItems(event.From)
      .remove()
      .then(_ => {
        console.log('Successfully removed map item')
        callback(null, response)
      })
      .catch(err => {
          console.log(err)
          callback(null, response)
        },
      )
  } else {
    callback(null, response)
  }
}

With this, our function is completed!

Deploy function

The next step is to deploy our code to Twilio Functions. To do this, execute the following command in the root of your project:

npm run deploy

If you have problems deploying your code, check that ACCOUNT_SID and AUTH_TOKEN environment variables are properly configured in your .env file.

Once deployment is complete, you will see the URL of your newly created function. It will look like the following:

Functions:
   [protected] https://capture-opt-out-0000-dev.twil.io/capture-opt-out

Copy the URL – we are ready to configure Messaging Service.

Configuring a Messaging Service

Creating a Messaging Service and adding a phone number to it is out of scope for this tutorial. It can be achieved via the Twilio Console or via API.

Assuming you have a Messaging Service already, go to your Twilio Console and navigate to the Integrations section under your Messaging Service. On the Integrations page, find the Incoming Messages section and select the Send a webhook radio button. Enter the function URL that you copied in the previous step into the Request URL field and click Save configuration.

Testing

To test our solution, send an SMS to one of the numbers that you have in the Senders Pool of your Messaging Service with the body "STOP" (assuming STOP is one of opt-out keywords). You should get an automatic reply about being successfully opted-out:

Opt-out Auto Reply

As soon as you get the automatic reply, check that the sender number was added to the opt-out Map.

You can see the Map content in the Twilio Console or by using the Twilio CLI. The CLI command would be:

twilio api:sync:v1:services:maps:items:list --service-sid ISXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX --map-sid=MPXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX --properties='key'

The output should look like this:

Key
+1234567890

If you see the sender number in the Map, it means that our setup works as expected – and now we can test the opt-in flow. For this, send an SMS message to one of the numbers that you have in the Senders Pool of your Messaging Service with the body "START" (assuming START is one of your opt-in keywords).

As soon as you get an automatic reply that you have successfully opted in, check that the sender number was removed from the opt-out Map.

Conclusion

Using Twilio Functions and Sync, we built reliable opt-out list and stored it on the Twilio platform without relying on any 3rd party tools. The data can be viewed and exported at any time using the Twilio Console, Twilio CLI, or REST API. The great thing about our solution is that it is not limited to SMS only; you could store opt-out information for other messaging channels such as WhatsApp or any other channel you employ.

The full code for the project is available on GitHub at https://github.com/kuschanton/capture-opt-out.

Like what you built? See some of our other Functions and serverless tutorials on the blog.

Anton Kushch is a Senior Solutions Engineer at Twilio. He is helping companies in Emerging Markets in EMEA build powerful customer engagement solutions powered by Twilio. He can be reached at akushch [at] twilio.com, or you can collaborate with him on GitHub at https://github.com/kuschanton.