Update Web Content with SMS

avatar
Taylor Beseda
August 9, 2022

Let’s build a small website where we display an alert based on a database value. Maybe we want to let visitors know we’re on a doughnut break. We’ll be able to set the message and enable the banner with just a text message!

example banner message

☝️ This is what we’re aiming for. Here’s the source code, if you’d like to follow along locally.

What’s the plan?

We’ll use two HTTP routes, a pub/sub event, one DynamoDB entry, and the Vonage SMS API. We can use Architect to create these resources.

It might be easiest to glance at a commented app.arc manifest for our fresh Arc project:

@app
sms-db-updater # the project name

@events
new-sms # a pub/sub event to do background work

@http
get / # root route handler
post /sms # inbound SMS webhook

@tables
things # a super generic table to stash stuff
  thingID *String

We’ll also want a baseline package.json to install a few dependencies, namely Architect:

// ./package.json
{
 "dependencies": {
   "@architect/functions": "^5.2.1",
   "got": "^12.3.0"
 },
 "devDependencies": {
   "@architect/architect": "^10.4.0"
 }
}

After npm install, we can use the arc command from our project root to scaffold our project source files:

npx arc init

We’ll come back to this source code in a bit. First, we’ll need a way to send and receive SMS text messages.

Set up a Vonage account

We’ll create a free Vonage Communications API account so we can use their SMS APIs to receive and send text messages.

While setting up our account, we’ll collect some important, secret values for our application. Save these to a .env file in the project. Architect’s local development tool, Sandbox, will pick them up automatically.

Vonage API keys

Vonage phone number

Additionally, add one or more friendly, known phone numbers we will allow to update the database:

# ./.env
VONAGE_API_KEY=abc192
VONAGE_API_SECRET=XYZfizzbuzz123
VONAGE_NUMBER=18335555555
# comma separated 10-digit numbers:
KNOWN_PHONES="13035555555,17205555555"

Later we’ll send these variables to our live AWS infrastructure with the arc command.

Write some Cloud Functions!

Getting back to our source code, we’ll write three functions:

  1. Our app’s GET root view
  2. A POST endpoint for incoming SMS webhooks
  3. The event subscriber to handle the work

Root “get /” handler

For demonstration, our app’s root path will render some simple HTML. This view could easily be built with your favorite SSR view engine or frontend framework. In addition to creating some HTML, we’ll grab a value from the things database:

// ./src/http/get-index/index.mjs
import arc from '@architect/functions';

export const handler = arc.http.async(async function () {
 const client = await arc.tables();
 const things = client.things;
 const banner = await things.get({ thingID: 'site:BANNER' });

 return {
   html: `
<html>
 <head>
   <title>SMS Database Updater</title>
 </head>
 <body>
   ${
     banner?.data?.text ?
       `<h1 class="banner">${banner.data.text}</h1>`
       : ''
   }

   <h1>Welcome to my site</h1>
   <p>Lorem ipsum</p>
 </body>
</html>
     `,
 };
});

💁 Note that I’m using ESM import statements, and I’ve changed the file extension to .mjs. This will be true for each of our cloud functions.

Our function immediately checks the database for a thing where its thingID is "site:BANNER". This arbitrary key schema will allow us to use a similar ID pattern for future “things”. The app’s root route currently will render a view without a banner since the things table is empty. We can test this locally with Sandbox by running npx arc sandbox and visiting localhost:3333.

Vonage SMS webhook

Create our inbound SMS webhook handler:

// ./src/http/post-sms/index.mjs
import arc from '@architect/functions';
const { KNOWN_PHONES } = process.env;

async function http(request) {
  // https://developer.vonage.com/api/sms#webhooks
  const message = request.body;
  const from = message.msisdn;

  if (
    from &&
    KNOWN_PHONES &&
    KNOWN_PHONES.indexOf(from) >= 0
  ) {
    // background job to update banner message
    await arc.events.publish({
      name: 'new-sms',
      payload: message,
    });
  }

  // return 200 OK ASAP
  return { status: 200, body: 'ty' };
}

export const handler = arc.http.async(http);

Simple enough, we ingest the SMS event from Vonage, publish an internal event called “new-sms” (this was specified in our app.arc file above), and return a success status to Vonage. It’s essential to reply with a successful response to Vonage as soon as possible. This is common practice with webhooks since providers may not hang on the line while our application does other work. So we let them know we got the data and then “background” the work by creating an event.

Update the database and reply

Now the fun part: save the new banner message and reply to the sender:

// ./src/events/new-sms/index.mjs
import arc from '@architect/functions';
import got from 'got';

const {
  VONAGE_API_KEY: apiKey,
  VONAGE_API_SECRET: apiSecret,
  VONAGE_NUMBER: sender,
} = process.env;

async function subscriber(payload) {
  // https://developer.vonage.com/api/sms#webhooks
  const { msisdn: from, text } = payload;
  const tables = await arc.tables();
  const { things } = tables;
  const bannerThing = {
    thingID: 'site:BANNER',
    data: null,
    updatedAt: Date.now(),
  };

  let reply;
  if (text.toLowerCase().startsWith('reset')) {
    reply = 'Banner message reset.';
  } else {
    bannerThing.data = payload;
    reply = 'Updated banner message.';
  }

  await things.put(bannerThing);

  if (!(apiKey && apiSecret && sender)) {
    throw new Error('Vonage secrets are required!');
  }

  const { data: vonageResult } = await got.post(
    'https://rest.nexmo.com/sms/json',
    {
      form: {
        api_key: apiKey,
        api_secret: apiSecret,
        from: sender,
        to: from,
        text: reply,
      },
    },
  ).json();

  return;
}

export const handler = arc.events.subscribe(subscriber);

Our "new-sms" subscriber function connects to the database and, based on the SMS contents, sets a new banner message or resets the existing message. Then we use got to send a reply SMS to the original sender, letting them know the banner has been changed (or reset).

Deployment

Let’s get our application up to the cloud!

💻 If you haven’t already, set up AWS CLI and some AWS credentials for Architect to use when deploying a stack. We have a guide on arc.codes to get you going (you can skip the step for installing Arc, since it has already been added to our project).

Environment variables

Architect Sandbox will pick up our .env file when developing locally, but we’ll need to add each variable from our .env to our AWS stack with npx arc env. That series of commands will look like this, but with your variables:

npx arc env -e staging -a VONAGE_API_KEY abc192
npx arc env -e staging -a VONAGE_API_SECRET XYZfizzbuzz123
npx arc env -e staging -a VONAGE_NUMBER 18335555555
npx arc env -e staging -a KNOWN_PHONES "13035555555,17205555555"

Run Architect deploy

Use Architect’s CLI to deploy the app to staging:

npx arc deploy

After the CloudFormation is created and deployed, you should get back a URL where our app lives. Mine looks like https://5lzxof1pbc.execute-api.us-west-2.amazonaws.com/.

Try it out

Once our app is up on AWS, we can try sending a text message to our Vonage phone number. We should get a reply letting us know the banner message was updated. And loading our site (from the URL provided by arc deploy) will display an <h1> with our new banner message.

Here’s what my test application looks like after sending “Test banner message 🍩” (yes, emojis are supported!):

example banner message

Further, we can clear the banner by simply texting again with just “reset”.

More ideas

What’s next? Here’s a short list of features I’ve considered adding to this demo.

Anything else you’d like to see? Message me on Twitter (@tbeseda) or find me on Architect’s Discord.