Secure and Scalable SMS Realtime Voting with Hookdeck, Twilio Verify, Twilio Programmable Messaging, Supabase, and Next.js

June 18, 2024
Written by
Phil Leggetter
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Paul Kamp
Twilion

Secure and Scalable SMS Realtime Voting with Twilio Verify, Twilio Programmable Messaging, Supabase, Hookdeck, and Next.js

Realtime polls and votes are often used to increase customer and audience engagement during live events. You can vote for your favorite performer in shows like American Idol or Dancing with the Stars, share feedback or upvote questions during live shows and debates, or answer surveys during a product launch. To maximize the number of participants, voting is often supported via the web, a dedicated mobile app, and via SMS.

But how do you securely and reliably build realtime SMS voting functionality? If you’re interested in learning how, this tutorial is for you.

Live voting payoff in Supapoll
Live voting payoff in Supapoll

In this tutorial, you’ll start with an application that supports web voting. From there, you’ll learn how to verify a phone number in a Next.js application using Supabase Auth and Twilio Verify. You’ll then add scalable realtime voting to the application enabling a user to vote by sending an SMS message in the format #{number} to a Twilio phone number. You’ll use Twilio Programmable SMS for inbound SMS and the Hookdeck event gateway to help with the webhook development process and also make use of webhook payload transformations, filtering, queuing, and retries. You’ll also use Supabase Database for data persistence and Supabase Realtime for live UI updates.

You can try the deployed application on supapoll.com and dive into the SupaPoll code on GitHub. (Why “SupaPoll”, you may ask? Well, because supapoll.com was available.)

How to get started

To follow this tutorial, you’ll need:

Note that phone numbers have different registration requirements based on number type and location. Numbers may not work immediately.

Clone the repository and set up Supabase

You’re not going to build all the functionality from scratch. Instead, you’ll start with a template application (a fork of an application built by Chensokheng) that supports realtime voting on the web and enhance it by adding phone number registration and SMS voting. So, begin by cloning the application locally:

git clone -b web-only https://github.com/hookdeck/supapoll.git

Navigate into the supapoll directory, login to Supabase with the CLI, and create a Supabase project:

cd supapoll
supabase login
supabase projects create

You'll be prompted for a project name (I suggest "supapoll"), a Supabase organization to create the project in, and a region for your project deployment. You'll also be asked for a database password which you should keep a secure note of.

Link the newly created project by running the following command and selecting the project when prompted:

supabase link

And update the new database to use the SupaPoll schema:

supabase db push

Next, create a .env.local from the .env.sample file, and add your Supabase configuration details, found within Supabase dashboard > Project > Configuration > API, to the new file:

cp .env.sample .env.local

Your .env.local will look similar to the following:

NEXT_PUBLIC_SUPABASE_URL=https://{subdomain}.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SERVICE_ROLE=...
SUPABASE_JWT_SECRET=...

You’ll need to fill out NEXT_PUBLIC_SUPABASE_URL , NEXT_PUBLIC_SUPABASE_ANON_KEY, and SERVICE_ROLE. If you don’t know your subdomain, you can find it by navigating to your Supabase projects, selecting supapoll, and scrolling to Project URL. There you will also find your Anonymous API Key and the Service Role in the Project API keys panel. Leave SUPABASE_JWT_SECRET blank for now, it’ll be used in the Inbound SMS step later in the tutorial.

Next, you will set up GitHub login.

Add GitHub login

Go to the Authentication section within the Supabase dashboard and select Configuration > Providers. Open the GitHub section and click the Github enabled toggle to enable GitHub auth. Copy the Callback URL value.

Client ID and Secret for OAuth in Github
Client ID and Secret for OAuth in Github

Next, go to your GitHub developer settings and create a new OAuth application by clicking the New OAuth App button. On the Register a new OAuth application screen, populate the details. Use the URL you used in the last step in the Authorization callback URL field, and use https://supapoll.test as the Homepage URL until you deploy your application.

Add an OAuth app in Github
Add an OAuth app in Github

Click on Register application to create the new OAuth app.

On the screen that follows, click Generate a new client secret to generate a new client secret.

Copy the Client ID and Client secret values from the GitHub page and add them to the provider details within the Supabase dashboard.

Enabling Github as a login provider in Supabase
Enabling Github as a login provider in Supabase

Click Save in the Supabase dashboard to save your GitHub Authentication provider details.

Install the dependencies for your application and run the starter app:

npm i
npm run dev

Open http://localhost:8080 and test the GitHub login flow:

Active and Past polls screen in SupaPoll
Active and Past polls screen in SupaPoll

Finally, click on your profile picture, select Create, create your first web-only vote, and test voting from the web.

Create a new SupaPoll
Create a new SupaPoll

With the baseline SupaPoll application up and running, you're now ready to add phone number registration and SMS voting.

Phone number registration and verification with Twilio Verify

Supabase has built-in support for user phone number registration using Twilio Verify. However, you still have to do some work to add the functionality to your application.

In this section, you will:

  • Configure your Supabase project to use Twilio Verify
  • Add a user phone number registration workflow to the Next.js application, including:
        1. User phone number capture in a form
        2. Update the user's phone number in Supabase using the Supabase SDK
        3. PIN code capture in a form
        4. Verify the user's phone number using the user-provided PIN code using the Supabase SDK

Configure Supabase phone authentication with Twilio Verify

Go to the Authentication section within the Supabase dashboard and select Configuration > Providers from the sidebar. Open the Phone section, click the Enable Phone provider toggle to enable phone auth, and select Twilio Verify from the SMS Provider drop-down.

Choosing Twilio Verify in Supabase
Choosing Twilio Verify in Supabase

To get the required Twilio credentials, head to the Twilio Console. In the Account Info section, you will find your Account SID and Auth Token.

To get the Twilio Verify Service SID, you need to create a new Verify Service. If Verify is not pinned to your Twilio Console sidebar, you can either find it using search or via the Explore Products + option.

Once you have the Verify section open, click Create new. Enter a friendly name, authorize the use of a friendly name, enable SMS as a Verification channel, and click Continue. (You can also leave Fraud Guard on).

Creating a SupaPoll service
Creating a SupaPoll service

You can find your Verify service SID in the Service SID field on the next screen.

Service SID for Twilio Verify
Service SID for Twilio Verify

Copy this value into the Supabase phone auth configuration and click Save.

Build the register and verify phone number flow

With the configuration in place, you can move to add the functionality to the Next.js application.

Create a new component that supports the phone registration flow in components/phone/register-phone.tsx. Begin with the following code to provide a basic structure for the UI:

"use client";

import { Alert, AlertTitle } from "../ui/alert";
import { AlertCircle } from "lucide-react";

export default function RegisterPhone() {
 const displayPrompt = true;

 return (
   <>
     {displayPrompt && (
       <Alert className="ring-2 mb-6">
         <AlertCircle className="h-4 w-4" />
         <AlertTitle className="mb-6">
           Register your phone number to vote via SMS
         </AlertTitle>
       </Alert>
     )}
   </>
 );
}

The use client indicates that this is a client-side only component.

Import the existing Alert, AlertTitle, and AlertCircle UI components and use them within the component markup.

For now, we’ll hard-code and use a variable called displayPrompt to indicate if the phone number registration prompt should be shown.

Next, update app/layout.tsx to make use of the new component. Begin by importing the component:

import RegisterPhone from "@/components/phone/register-phone";

Then, use the component within the UI definition:

export default async function RootLayout({
 children,
}: {
 children: React.ReactNode;
}) {
 return (
   <html lang="en" suppressHydrationWarning>
     <body
       className={`${inter.className} bg-[#09090B] text-gray-200 antialiased  py-10`}
     >
       <ThemeProvider
         attribute="class"
         defaultTheme="dark"
         enableSystem
         disableTransitionOnChange
       >
         <QueryProvider>
           <main className="flex flex-col max-w-7xl mx-auto min-h-screen space-y-10 p-5">
             <Navbar />
             <RegisterPhone />
             <div className="w-full flex-1 ">{children}</div>
             <Footer />
           </main>
         </QueryProvider>

         <Toaster position="top-center" reverseOrder={false} />
       </ThemeProvider>
     </body>
   </html>
 );
}

Load the application in your browser to see the result:

Beginnings of a registration form
Beginnings of a registration form

Next, add logic to the UI to support the registration workflow:

"use client";

import { Alert, AlertTitle } from "../ui/alert";
import { AlertCircle } from "lucide-react";

import { useState } from "react";

enum VerifySteps {
 REGISTER,
 VERIFY,
 SUCCESS,
 ERROR,
}

export default function RegisterPhone() {
 const [step, setStep] = useState<VerifySteps>(VerifySteps.REGISTER);
 const displayPrompt = true;

 return (
   <>
     {displayPrompt && (
       <Alert className="ring-2 mb-6">
         <AlertCircle className="h-4 w-4" />
         <AlertTitle className="mb-6">
           Register your phone number to vote via SMS
         </AlertTitle>
         {step === VerifySteps.REGISTER && (
           <span>Registration form goes here</span>
         )}
         {step === VerifySteps.VERIFY && (
           <span>Verification form goes here</span>
         )}
         {step === VerifySteps.ERROR && <span>Something went wrong</span>}
       </Alert>
     )}
   </>
 );
}

Import the React useState hook to maintain the user's current step within the workflow.

Create a VerifySteps enumeration with states REGISTER, VERIFY, SUCCESS, and ERROR to represent the steps within the workflow.

Store the initial state as REGISTER:

Finally, add display logic to the component based on the current step value. If the current step is REGISTER, show a registration message if the current step is VERIFY, show the verification message, and if there is an ERROR, provide that feedback to the user.

The application now looks as follows:

Mockup to add a phone number registration form
Mockup to add a phone number registration form

Check the current user's phone number

If the application doesn't have a phone number registered for the current user, it should display a prompt asking them to register their phone number. This is where the displayPrompt variable is used.

As you've seen, Supabase Auth comes with built-in support for phone number verification via various providers, including Twilio Verify. A user's phone number is accessed via the Supabase client. If the phone number is not set on a user, prompt the user to register it.

Update register-phone.tsx to import useEffect from React, the createSupabaseBrowser utility from @/lib/supabase/client, and the User definition from @supabase/supabase-js.

import { useState, useEffect } from "react";

import { createSupabaseBrowser } from "@/lib/supabase/client";
import { User } from "@supabase/supabase-js";

Update the RegisterPhone function to initialize a variable to track the current user, create an instance of the supabase client, get the current user from the Supabase client within a useEffect hook, and set the displayPrompt based on the presence of a user with a phone number where the current phone registration step is not SUCCESS (i.e., they have not just completed phone registration):

export default function RegisterPhone() {
 const [step, setStep] = useState<VerifySteps>(VerifySteps.REGISTER);
 const [user, setUser] = useState<null | User>(null);

 const supabase = createSupabaseBrowser();

 useEffect(() => {
   const checkUser = async () => {
     setUser((await supabase.auth.getUser()).data.user);
   };
   checkUser();
 }, [supabase.auth]);

 const displayPrompt = user && !user.phone && step !== VerifySteps.SUCCESS;

Once updated, register-phone.tsx looks as follows:

"use client";

import { Alert, AlertTitle } from "../ui/alert";
import { AlertCircle } from "lucide-react";

import { useState, useEffect } from "react";

import { createSupabaseBrowser } from "@/lib/supabase/client";
import { User } from "@supabase/supabase-js";

enum VerifySteps {
 REGISTER,
 VERIFY,
 SUCCESS,
 ERROR,
}

export default function RegisterPhone() {
 const [step, setStep] = useState<VerifySteps>(VerifySteps.REGISTER);
 const [user, setUser] = useState<null | User>(null);

 const supabase = createSupabaseBrowser();

 useEffect(() => {
   const checkUser = async () => {
     setUser((await supabase.auth.getUser()).data.user);
   };
   checkUser();
 }, [supabase.auth]);

 const displayPrompt = user && !user.phone && step !== VerifySteps.SUCCESS;

 return (
   <>
     {displayPrompt && (
       <Alert className="ring-2 mb-6">
         <AlertCircle className="h-4 w-4" />
         <AlertTitle className="mb-6">
           Register your phone number to vote via SMS
         </AlertTitle>
         {step === VerifySteps.REGISTER && (
           <span>Registration form goes here for {user.email}</span>
         )}
         {step === VerifySteps.VERIFY && (
           <span>Verification form goes here</span>
         )}
         {step === VerifySteps.ERROR && <span>Something went wrong</span>}
       </Alert>
     )}
   </>
 );
}

Note that the registration text has been updated to display the current user's email address via {user.email}. This is just to make sure that the user state is being set.

Register phone number form

With the displayPrompt value dynamically set, you can now proceed to the REGISTER step in the register phone workflow.

The phone number registration will take place within a new component. In this component, you will create a form for the user to submit their phone number. Upon submission, you will validate the phone number and update it, triggering the verification flow.

Register phone form UI

Begin by creating components/phone/register-form.tsx.

The component is also a client-only component as indicated by use client.

Import react and a number of other UI components that already exist within the application and that you will use to define the UI.

"use client";

import React from "react";

import {
 Form,
 FormControl,
 FormField,
 FormItem,
 FormLabel,
 FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";

Next, import a number of items to support form submission and validation:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const RegisterFormSchema = z.object({
 phone_number: z.string(),
});

export default function RegisterForm() {
 const registerForm = useForm<z.infer<typeof RegisterFormSchema>>({
   mode: "onSubmit",
   resolver: zodResolver(RegisterFormSchema),
   defaultValues: {
     phone_number: "",
   },
 });

 ...

}

The useForm hook from React Hook Form, zodResolver from the React Hook Form resolvers package, and z from zod. All of these packages support the form validation.

Use zod to define the registration form schema that requires a phone_number of type string.

Within RegisterForm, create a registerForm variable using the useForm hook. Pass in the RegisterFormSchema z inferred type, instruct the hook to handle the onSubmit event, again use the RegisterFormSchema wrapped in a call to zodResolver for the resolver property value, and set the default value for the phone_number to be an empty string.

Once the above has been applied, register-form.tsx looks as follows:

"use client";

import React from "react";

import {
 Form,
 FormControl,
 FormField,
 FormItem,
 FormLabel,
 FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const RegisterFormSchema = z.object({
 phone_number: z.string(),
});

export default function RegisterForm() {
 const registerForm = useForm<z.infer<typeof RegisterFormSchema>>({
   mode: "onSubmit",
   resolver: zodResolver(RegisterFormSchema),
   defaultValues: {
     phone_number: "",
   },
 });

 function onRegisterSubmit(data: z.infer<typeof RegisterFormSchema>) {
   // Handle submission
 }

 return (
   <Form {...registerForm}>
     <form
       onSubmit={registerForm.handleSubmit(onRegisterSubmit)}
       className="space-y-8"
       name="register-form"
     >
       <FormField
         control={registerForm.control}
         name="phone_number"
         render={({ field }) => (
           <FormItem>
             <FormLabel>Phone Number</FormLabel>
             <FormControl>
               <Input placeholder="Enter your phone number" {...field} />
             </FormControl>

             <FormMessage />
           </FormItem>
         )}
       />

       <Button type="submit" disabled={false}>
         Register
       </Button>
     </form>
   </Form>
 );
}

To use the new RegisterForm component, update the parent RegisterPhone component to import and use it.

Include the import in register-phone.tsx:

import RegisterForm from "./register-form";

And replace the Registration form goes here message with the imported component:

return (
   <>
     {displayPrompt && (
       <Alert className="ring-2 mb-6">
         <AlertCircle className="h-4 w-4" />
         <AlertTitle className="mb-6">
           Register your phone number to vote via SMS
         </AlertTitle>
         {step === VerifySteps.REGISTER && (
           <RegisterForm />
         )}
         {step === VerifySteps.VERIFY && (
           <span>Verification form goes here</span>
         )}
         {step === VerifySteps.ERROR && <span>Something went wrong</span>}
       </Alert>
     )}
   </>
 );

If you click the Register button within the application's UI, nothing will happen. That's to be expected since the onRegisterSubmit button doesn't presently do anything.

Phone number registration form in SupaPoll
Phone number registration form in SupaPoll

Phone number validation

The RegisterFormSchema performs some very basic validation at present. However, it's important that phone numbers are fully validated and that we store phone numbers in a consistent format. To achieve this, add a zod transformation with error checking to the schema definition.

But before adding the validation, create a few utility functions that can be used across the codebase that take advantage of the often-used libphonenumber-js for phone number validation and parsing.

Begin by installing libphonenumber-js:

npm i libphonenumber-js

Open up ​​lib/utils.ts, and import parsePhoneNumber.

import { parsePhoneNumber } from "libphonenumber-js";

Then, export parsePhoneNumber for use elsewhere in the codebase. Also, define two functions to ensure that phone numbers are consistently stored in E.164 format and displayed using an international format that is easy for users to read.

export { parsePhoneNumber } from "libphonenumber-js";

export function toStoredPhoneNumberFormat(phoneNumber: string) {
 const parseNumber = parsePhoneNumber(phoneNumber);
 return parseNumber.format("E.164");
}

export function toDisplayedPhoneNumberFormat(phoneNumber: string) {
 const parseNumber = parsePhoneNumber(phoneNumber);
 return parseNumber.formatInternational();
}

toStoredPhoneNumberFormat converts a passed number to E.164, and toDisplayedPhoneNumberFormat converts a number to the international format.

With the utility functions in place, import parsePhoneNumber, and toStoredPhoneNumberFormat within register-form.tsx:

import { parsePhoneNumber, toStoredPhoneNumberFormat } from "@/lib/utils";

And add the transform to register-form.tsx and perform validation during transformation using ctx.addIssue when any validation problems are detected:

const RegisterFormSchema = z
 .object({
   phone_number: z.string(),
 })
 .transform((val, ctx) => {
   const { phone_number, ...rest } = val;
   try {
     const phoneNumber = parsePhoneNumber(phone_number);

     if (!phoneNumber?.isValid()) {
       ctx.addIssue({
         code: z.ZodIssueCode.custom,
         path: ["phone_number"],
         message: `is not a phone number`,
       });
       return z.NEVER;
     }
   } catch (e) {
     ctx.addIssue({
       code: z.ZodIssueCode.custom,
       path: ["phone_number"],
       message: `could not be parsed as a phone number`,
     });
     return z.NEVER;
   }

   return { ...rest, phone_number: toStoredPhoneNumberFormat(phone_number) };

 });

Within the transform, get the phone_number and pass it to the parseNumber utility function, which returns an object assigned to phoneNumber. Use the phoneNumber.isValid function to check if the phone number is valid. If it is not valid, or an error is thrown when parsing, flag an issue using ctx.addIssue.

Finally, if the number is valid, return the phone_number in the E.164 format using the toStoredPhoneNumberFormat function.

Phone number validation in SupaPoll
Phone number validation in SupaPoll

Handle successful form submission

Once the phone number format has been validated, the RegisterPhone parent component must be informed of the successful form submission and of the user's phone number.

To achieve this, add a successful submit handler to the RegisterForm component in register-form.tsx.

export type RegistrationSubmitHandler = ({
 phoneNumber,
}: {
 phoneNumber: string;
}) => void;

export type RegisterFormProps = {
 onSubmit: RegistrationSubmitHandler;
};

export default function RegisterForm({
 onSubmit,
}: RegisterFormProps) {
 const registerForm = useForm<z.infer<typeof RegisterFormSchema>>({
   mode: "onSubmit",
   resolver: zodResolver(RegisterFormSchema),
   defaultValues: {
     phone_number: "",
   },
 });

 function onRegisterSubmit(data: z.infer<typeof RegisterFormSchema>) {
   onSubmit({ phoneNumber: data.phone_number });
 }
 
 ...

Define a RegistrationSubmitHandler function that accepts an argument with a phoneNumber property. This will be used as a callback when the form is successfully submitted.

Create a RegisterFormProps type with an onSubmit property of type RegistrationSubmitHandler and update the RegisterForm to require the properties defined by the RegisterFormProps type to be set on the component.

Next, update the currently empty onRegisterSubmit handler to call the onSubmit handler, passing the validated phone number.

Update the user's phone number

With the communication mechanism between the parent (RegisterPhone) and child component (RegisterForm) in place, update register-phone.tsx to handle the successful submission and update the user's phone number.

Begin by updating the RegisterForm import statement to include the success handler:

import RegisterForm, { RegistrationSubmitHandler } from "./register-form";

Next, add state to handle storing the phone number:

export default function RegisterPhone() {
 const [step, setStep] = useState<VerifySteps>(VerifySteps.REGISTER);
 const [user, setUser] = useState<null | User>(null);
 const [phoneNumber, setPhoneNumber] = useState<undefined | string>();

 ...

Define a handleRegisterSubmit of type RegistrationSubmitHandler that receives the validated phone number, stores it, updates the user's phone number, and advances the workflow to the phone number verification step.

const supabase = createSupabaseBrowser();

 useEffect(() => {
   const checkUser = async () => {
     setUser((await supabase.auth.getUser()).data.user);
   };
   checkUser();
 }, [supabase.auth]);

 const handleRegisterSubmit: RegistrationSubmitHandler = async ({
   phoneNumber,
 }) => {
   setPhoneNumber(phoneNumber);

   const { error } = await supabase.auth.updateUser({
     phone: phoneNumber,
   });

   if (error) {
     console.error(error);
     setStep(VerifySteps.ERROR);
   } else {
     setStep(VerifySteps.VERIFY);
   }
 };

The setPhoneNumber state function is called to set the phone number state.

The supabase.auth.updateUser function is called, passing the phone number and returning an object from which the error property is deconstructed. If there is an error, log it to the console and use setStep to move to the ERROR step of the workflow. Otherwise, advance to the VERIFY step.

Finally, connect the parent and child components by passing the success handler:

const displayPrompt = user && !user.phone && step !== VerifySteps.SUCCESS;

 return (
   <>
     {displayPrompt && (
       <Alert className="ring-2 mb-6">
         <AlertCircle className="h-4 w-4" />
         <AlertTitle className="mb-6">
           Register your phone number to vote via SMS
         </AlertTitle>
         {step === VerifySteps.REGISTER && (
           <RegisterForm onSubmit={handleRegisterSubmit} />
         )}
         {step === VerifySteps.VERIFY && (
           <span>Verification form goes here</span>
         )}
         {step === VerifySteps.ERROR && <span>Something went wrong</span>}
       </Alert>
     )}
   </>
 );
}

Add an onSubmit attribute to the <RegisterForm /> component, passing the handleRegisterSubmit function.

When the phone number registration form is submitted, the passed phone number will receive an SMS verification PIN code that can be used to validate the phone number in the next step. The "Verification form goes here" message then appears.

SupaPoll overview with one poll active
SupaPoll overview with one poll active

Verify phone number form

As noted, upon calling supabase.auth.updateUser, Supabase triggers a verification PIN code to be sent via Twilio Verify to the phone number so the user can verify their phone number. Within the application, you now need to display a form for the user to enter the PIN and then use the Supabase SDK to verify the PIN and, therefore, the phone number.

Verify phone form UI and validation

The UI for accepting the PIN code has a similar structure and uses the same functionality as the phone number registration form.

Add the following in a new file, components/phone/verify-form.tsx:

"use client";

import React from "react";
import {
 Form,
 FormControl,
 FormField,
 FormItem,
 FormLabel,
 FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const VerifyFormSchema = z.object({ code: z.string() });

export type VerificationSubmitHandler = ({
 phoneNumber,
 code,
}: {
 phoneNumber: string;
 code: string;
}) => void;

export type VerifyFormProps = {
 phoneNumber: string;
 onSubmit: VerificationSubmitHandler;
};

export default function VerifyForm({ phoneNumber, onSubmit }: VerifyFormProps) {
 const verifyForm = useForm<z.infer<typeof VerifyFormSchema>>({
   mode: "onSubmit",
   resolver: zodResolver(VerifyFormSchema),
   defaultValues: {
     code: "",
   },
 });

 async function onVerifySubmit(data: z.infer<typeof VerifyFormSchema>) {
   onSubmit({ phoneNumber, code: data.code });
 }

 return (
   <Form {...verifyForm}>
     <form
       onSubmit={verifyForm.handleSubmit(onVerifySubmit)}
       className="space-y-8"
       name="verify-form"
     >
       <FormField
         control={verifyForm.control}
         name="code"
         render={({ field }) => (
           <FormItem>
             <FormLabel>Verification Code</FormLabel>
             <FormControl>
               <Input
                 placeholder="Enter the verification code you receive via SMS"
                 {...field}
                 autoComplete="off"
               />
             </FormControl>

             <FormMessage />
           </FormItem>
         )}
       />

       <Button type="submit" disabled={false}>
         Verify
       </Button>
     </form>
   </Form>
 );
}

Since the details of this component are very similar to the phone number registration component, I won't go into all the details. Instead, here's a quick overview of the key pieces of this component:

  • Reuse of the UI components
  • Use useForm for submission handling
  • Use zod for PIN code form value verification
  • Definition and use of a VerificationSubmitHandler for communicating the form submission and submitted PIN code to the parent component

Verify the submitted PIN code

Now you can update register-phone.tsx to import and use the VerifyForm component.

Begin by importing the new component and the form submission handler definition:

import VerifyForm, { VerificationSubmitHandler } from "./verify-form";

Next, update the RegisterPhone component to define a verification form handler:

const handleVerifySubmit: VerificationSubmitHandler = ({
   phoneNumber,
   code,
 }) => {
   console.log("TODO: Add handling code");
 };

Replace the "Verification form goes here" message with the VerifyForm component, passing the handleVerifySubmit handler:

return (
   <>
     {displayPrompt && (
       <Alert className="ring-2 mb-6">
         <AlertCircle className="h-4 w-4" />
         <AlertTitle className="mb-6">
           Register your phone number to vote via SMS
         </AlertTitle>
         {step === VerifySteps.REGISTER && (
           <RegisterForm onSubmit={handleRegisterSubmit} />
         )}
         {step === VerifySteps.VERIFY && (
           <VerifyForm
             phoneNumber={phoneNumber!}
             onSubmit={handleVerifySubmit}
           />
         )}
         {step === VerifySteps.ERROR && <span>Something went wrong</span>}
       </Alert>
     )}
   </>
 );

If you follow the registration flow in the application now, you'll see the console.log output.

Register and verify a phone number in SupaPoll
Register and verify a phone number in SupaPoll

The final step is to check the PIN code using the Supabase SDK and provide feedback to the user.

Within register-phone.tsx, update the supabase-js import to include VerifyMobileTopParams:

import { User, VerifyMobileOtpParams } from "@supabase/supabase-js";

Import the toast UI utility used elsewhere in the application for UI feedback:

import toast from "react-hot-toast";

Update the handleVerifySubmit function to verify the PIN code using the Supabase SDK and provide user feedback using the toast UI utility:

const handleVerifySubmit: VerificationSubmitHandler = ({
   phoneNumber,
   code,
 }) => {
   const otpParams: VerifyMobileOtpParams = {
     phone: phoneNumber!,
     token: code,
     type: "phone_change",
   };

   const verify = async () => {
     const { error } = await supabase.auth.verifyOtp(otpParams);

     if (error) {
       console.error(error);
       setStep(VerifySteps.ERROR);
     } else {
       setStep(VerifySteps.SUCCESS);
     }
   };

   toast.promise(verify(), {
     loading: "Verifying code...",
     success: "Successfully verified",
     error: "Fail to verify",
   });
 };

Create an otpParams variable of type VerifyMobileOtpParams with properties:

  • phone - the phone number being verified
  • token - the user submitted PIN code
  • type - with a value of phone_change to inform Supabase that the phone number is being changed

Define an inline async function called verify that executes the verification using the Supabase SDK. Within the function, set the workflow step to ERROR if an error is returned or SUCCESS if the verification succeeds.

Use the toast.promise utility to execute the inline verify function and provide user feedback.

With that in place, the application now registers and verifies a user's phone number.

Register a Phone Number so you can vote in SupaPoll
Register a Phone Number so you can vote in SupaPoll

SMS voting at scale with Hookdeck and Twilio Programmable SMS

With user phone number verification complete, you can now move on to adding support for voting via SMS.

In this section, you will:

  • Buy Twilio phone number(s)
  • Configure the application to use the phone number(s)
  • Assign a phone number to a vote for voting via SMS
  • Configure Hookdeck to receive Twilio SMS webhooks for the purchased phone number(s)
  • Receive inbound SMS webhooks on your localhost running Next.js application using the Hookdeck CLI
  • Use the inbound SMS webhooks to register a vote

Buy a phone number in Twilio

In order to receive votes via SMS, you need an inbound phone number that supports receiving SMS messages.

Head to the Twilio Console, go to the Phone Numbers > Manager > Buy a number section, and search for a number with SMS capabilities.

Buy a Twilio Phone Number
Buy a Twilio Phone Number

Choose an easy-to-read number (one with a few digits that repeat), click Buy next to the number in the search results, and click Next in the Review phone number prompt that appears.

When the process is complete, your number will be listed in the Active Numbers section of the Twilio Console.

Different number types and countries have different registration requirements. You may need to fulfill registration requirements before you can continue.
Twilio phone numbers in your Console
Twilio phone numbers in your Console

Configure SupaPoll to use the Twilio phone numbers

Since SupaPoll doesn't have an admin interface, all configuration is stored in configuration files or environment variables. In local development, environment variables are ready from a .env.local file. So, store the newly purchased phone number(s) in your .env.local with the variable named NEXT_PUBLIC_PHONE_NUMBERS. If you purchased more than one phone number, split them with a comma and store them in E.164 format . For example:

NEXT_PUBLIC_PHONE_NUMBERS=+447700195562,+447700195566

You'll use this environment variable shortly.

Assign a phone number to a vote

To support voting via SMS, you need to make changes to two distinct parts of the SupaPoll application.

1. Add phone number support to the vote table and the create_vote function in the Supabase database

2. Add the ability to create a vote with a phone number and edit the phone number associated with the vote in the Next.js application

Update the vote table and create_vote function

SupaPoll is a database-driven application, and until now, you haven't had to think too much about the database. Although you won't dive into the details of the database in this tutorial, in order to support assigning phone numbers to votes, you need to make a change to the vote table within the database.

The existing schema looks as follows via the Supabase schema visualizer, including the vote table:

Supabase database explorer
Supabase database explorer

To add a phone_number column to the vote table, you need to create a migration.

Create a new migration with the Supabase CLI:

supabase migrations new add_phone_number_voting

Add the following to the generated .sql migration file:

alter table "public"."vote" add column "phone_number" text;

Additionally, you need to be able to create a vote with a phone number. The SupaPoll database and application makes use of Postgres Functions to create a vote. So, also add the following to the migration file to add a phone_number argument and the insertion of the phone number into the vote table:

DROP FUNCTION IF EXISTS "public"."create_vote"(options jsonb, title text, end_date timestamp without time zone, description text);

CREATE OR REPLACE FUNCTION "public"."create_vote"("options" "jsonb", "title" "text", "end_date" timestamp without time zone, "description" "text", "phone_number" "text" DEFAULT NULL::"text") RETURNS "uuid"
   LANGUAGE "plpgsql" SECURITY DEFINER
   SET "search_path" TO 'public'
   AS $$
DECLARE
 return_id uuid;
 options_count INT;
 key_value_type text;
 position_value_type text;
 vote_count_value_type text;
BEGIN

 SELECT COUNT(*) INTO options_count
 FROM jsonb_object_keys(options);

 IF options_count <= 1 THEN
   RAISE EXCEPTION 'Options must have more than one key.';
 END IF;

  -- Check if all values associated with keys are objects
 SELECT jsonb_typeof(value) INTO key_value_type
 FROM jsonb_each(options)
 WHERE NOT jsonb_typeof(value) IN ('object');

 IF key_value_type IS NOT NULL THEN
   RAISE EXCEPTION 'All values in options must be objects.';
 END IF;

 -- Check if all positions are numbers
 SELECT jsonb_typeof(value) INTO position_value_type
 FROM jsonb_each(options::jsonb -> 'position')
 WHERE NOT jsonb_typeof(value) IN ('number');

 IF position_value_type IS NOT NULL THEN
   RAISE EXCEPTION 'All positions in options must be numbers.';
 END IF;

   -- Check if all vote_count are numbers
 SELECT jsonb_typeof(value) INTO vote_count_value_type
 FROM jsonb_each(options::jsonb -> 'vote_count')
 WHERE NOT jsonb_typeof(value) IN ('number');

 IF vote_count_value_type IS NOT NULL THEN
   RAISE EXCEPTION 'All vote_count in options must be numbers.';
 END IF;

 INSERT INTO vote (created_by, title, end_date, description, phone_number)
 VALUES (auth.uid(),title, end_date, description, phone_number)
 RETURNING id INTO return_id;

 INSERT INTO vote_options (vote_id,options)
 VALUES (return_id, options);
 return return_id;
END $$;

The first DROP statement removes the previous function. The subsequent CREATE statement creates a new function that accepts the optional phone_number argument. Towards the end of the function, you'll see the INSERT statement that uses the phone_number.

You'll see how to execute this function from the Next.js application shortly.

Push the migration to the remote Supabase database:

supabase db push

If you view the Supabase schema visualizer, you will see the new column in the vote table.

Add a phone number field in the database
Add a phone number field in the database

It's worth calling out the presence of the profile table. The contents of this table is generated by an INSERT trigger on the auth.users table. This is important later because, for security reasons, there is limited querying functionality on the auth.users table, so you will need to query the profile table instead.

Add phone number selection SupaPoll Next.js application

To add the phone number selection to the Next.js application, you will:

1. Update the Supabase type definitions

2. Create a React Hook to return the available numbers

3. Create a PhoneNumberDropdown component

4. Use the PhoneNumberDropdown component within the create view and persist the phone number

5. Use the PhoneNumberDropdown component within the edit view and persist the phone number change

Update the Supabase TypeScript type definitions

Supabase can generate types from the database. To do this, run the following command, replacing {PROJECT_ID} with your Supabase project ID, which you can get from the Project Settings section in the Supabase dashboard (called Reference ID there):

npx supabase gen types typescript --project-id {PROJECT_ID} --schema public > lib/types/supabase.ts

Upon running the command, lib/types/supabase.ts is updated. View the diff, and you'll see phone_number added to various types to reflect the database changes you just made.

Finally, open lib/types/index.ts and add a phone_number property to the IVote definition:

export type IVote = {
 created_at: string;
 created_by: string;
 description?: string | null;
 end_date: string;
 id: string;
 title: string;
 phone_number: string | null;
};

Create a React Hook to return the available numbers

Begin by creating a React Hook to retrieve phone numbers available for SMS voting. This has to take into account the phone numbers configured with the application and also the phone numbers already in use by an existing and active vote. The latter is the case, as you can only have a phone number associated with a single active vote.

Add the following to lib/hook/index.ts to fetch the initial list of available phone numbers from the NEXT_PUBLIC_PHONE_NUMBERS environment variable, splitting by a comma delimiter, and assign to a configuredPhoneNumbers variable:

const configuredPhoneNumbers = process.env.NEXT_PUBLIC_PHONE_NUMBERS
 ? process.env.NEXT_PUBLIC_PHONE_NUMBERS.split(",")
 : [];

Next, define the structure of the numbers to be returned. This structure contains:

* e164 - the number in E.164 format

* displayNumber - the number formatting in a way for easier readability

export type FormattedNumber = {
 e164: string;
 displayNumber: string;
};

Create the useAvailablePhoneNumbers React hook by making use of TanStack Query for data fetching and catching.

The return value from the hook is a UseQueryResult of FormattedNumber[], and you'll use the Supabase SDK within the query:

export function useAvailablePhoneNumbers(): UseQueryResult<
 FormattedNumber[]
> {
 const supabase = createSupabaseBrowser();

 ...
}

Find any phone numbers that are used within a vote where the vote is still active:

export function useAvailablePhoneNumbers(): UseQueryResult<
 FormattedNumber[]
> {
 const supabase = createSupabaseBrowser();

 return useQuery({
   queryKey: ["available-phone-numbers"],
   queryFn: async () => {
     // Get phone numbers used in all active votes
     const { error, data } = await supabase
       .from("vote")
       .select("phone_number")
       .filter("end_date", "gte", new Date().toISOString());

     if (error) {
       console.error(error);
       throw new Error("Failed to fetch phone numbers");
     }
     const usedPhoneNumbers = data.map((row) => row.phone_number);
     
     ...
   },
 });
}

Within a TanStack Query useQuery, use the Supabase SDK to perform a query for any votes (using the vote database table), fetching the phone_number, where the end date of the vote is after today. The phone numbers cannot be used in any other votes and are there for not available.

Next, you will determine the available phone numbers. First, add imports to the top of the tile:

import {
 sortVoteOptionsBy,
 toDisplayedPhoneNumberFormat,
 toStoredPhoneNumberFormat,
} from "../utils";

Then add the useAvailablePhoneNumbers() function:

export function useAvailablePhoneNumbers(): UseQueryResult<
 FormattedNumber[]
> {
 const supabase = createSupabaseBrowser();

 return useQuery({
   queryKey: ["available-phone-numbers"],
   queryFn: async () => {
     // Get phone numbers used in all active votes
     const { error, data } = await supabase
       .from("vote")
       .select("phone_number")
       .filter("end_date", "gte", new Date().toISOString());

     if (error) {
       console.error(error);
       throw new Error("Failed to fetch phone numbers");
     }
     const usedPhoneNumbers = data.map((row) => row.phone_number);

     const availableNumbers = configuredPhoneNumbers
       .filter((tel) => !usedPhoneNumbers.includes(tel))
       .map((tel) => {
         return {
           e164: toStoredPhoneNumberFormat(tel),
           displayNumber: toDisplayedPhoneNumberFormat(tel),
         };
       });

     return availableNumbers;
   },
 });
}

Remove any of the usedPhoneNumbers from the configuredPhoneNumbers. Also, ensure the availableNumbers are in the required FormattedNumber structure by importing and making use of the toStoredPhoneNumberFormat and toDisplayedPhoneNumberFormat utility functions you created earlier.

The availableNumbers variable is returned for use.

Create a PhoneNumberDropdown component

The PhoneNumberDropdown component should allow a user to pick the phone number that enables SMS voting. It should only show available numbers, so you'll also make use of the useAvailablePhoneNumbers hook you just created.

Create a file components/phone/phone-number-dropdown.tsx and begin by importing components to be used within the PhoneNumberDropdown:

import { Button } from "@/components/ui/button";

import {
 FormControl,
 FormDescription,
 FormField,
 FormItem,
 FormLabel,
 FormMessage,
} from "@/components/ui/form";

import {
 DropdownMenu,
 DropdownMenuContent,
 DropdownMenuRadioGroup,
 DropdownMenuRadioItem,
 DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

import { cn, toDisplayedPhoneNumberFormat } from "@/lib/utils";

The PhoneNumberDropdown is used within a form, so various form components are imported along with Button, a cn utils function to help with styling, and toDisplayedPhoneNumberFormat for displaying phone numbers.

Import the FormattedNumber type you created earlier, Control from react-hook-form, and define the props to be passed to the new component called PhoneNumberDropdownProps:

import { FormattedNumber } from "@/lib/hook";
import { Control } from "react-hook-form";

export type PhoneNumberDropdownProps = {
 name: string;
 control: Control<any>;
 phoneNumbers: FormattedNumber[] | undefined;
};

The PhoneNumberDropdown component takes properties defined by PhoneNumberDropdownProps. These are:

  • name - the name of the form component corresponding to the form schema
  • control - The React Hook Form control is used to manage state
  • phoneNumber - an array of FormattedNumber to be displayed within the drop-down

With the component properties defined, create the PhoneNumberDropdown component:

export default function PhoneNumberDropdown({
 control,
 phoneNumbers,
 name,
}: PhoneNumberDropdownProps) {
 return (
   <FormField
     control={control}
     name={name}
     render={({ field }) => (
       <FormItem className="flex flex-col items-start">
         <FormLabel>Vote by Phone Number</FormLabel>
         {!field.value && (!phoneNumbers || phoneNumbers.length == 0) ? (
           <FormDescription>No numbers available</FormDescription>
         ) : (
           <FormControl>
             <DropdownMenu>
               <DropdownMenuTrigger asChild>
                 <Button
                   variant={"outline"}
                   className={cn(
                     "w-full pl-3 text-left font-normal justify-start"
                   )}
                 >
                   {field.value
                     ? toDisplayedPhoneNumberFormat(field.value)
                     : "Not enabled"}
                 </Button>
               </DropdownMenuTrigger>
               <DropdownMenuContent align="start">
                 <DropdownMenuRadioGroup onValueChange={field.onChange}>
                   <DropdownMenuRadioItem value="">
                     <span>Not enabled</span>
                   </DropdownMenuRadioItem>
                   {phoneNumbers &&
                     phoneNumbers.map((number) => (
                     <DropdownMenuRadioItem
                       key={number.e164}
                       value={number.e164}
                     >
                       {number.displayNumber}
                     </DropdownMenuRadioItem>
                   ))}
                 </DropdownMenuRadioGroup>
               </DropdownMenuContent>
             </DropdownMenu>
           </FormControl>
         )}

         <FormMessage />
       </FormItem>
     )}
   />
 );
}

If no phone numbers are available (phoneNumbers is undefined or an array with no elements) and the drop-down doesn't have a phone number already set (field.value), then No numbers available is shown.

Hook into the onValueChanged property of the DropdownMenuRadioGroup component to trigger the field.onChange handler to ensure the form state is updated.

Statically define a DropdownMenuRadioItem with an empty string value and display the message Not enabled. This can be used when a user does not want SMS voting to be used in a vote.

Display the passed phoneNumber values as DropdownMenuRadioItem components using their E.164 values as the value and key and displaying the displayNumber in the UI.

Use the PhoneNumberDropdown component within the create vote view

With the PhoneNumberDropdown built you can now use it within the application. Begin by adding it to the vote creation view defined in app/create/VoteForm.tsx.

Update the schema to include a phone_number:

const FormSchema = z
 .object({
   vote_options: z
     .array(z.string())
     .refine((value) => value.length >= 2 && value.length <= 6, {
       message: "You have to select at least two items and max at six items.",
     }),
   title: z
     .string()
     .min(5, { message: "Title has a minimum characters of 5" }),
   description: z.string().optional(),
   end_date: z.date(),
   phone_number: z.string(),
 })
 .refine(
   (data) => {
     const vote_options = [...new Set([...data.vote_options])];
     return vote_options.length === data.vote_options.length;
   },
   { message: "Vote option need to be unique", path: ["vote_options"] }
 );

Import the drop-down component and useAvailablePhoneNumbers hook:

import { useAvailablePhoneNumbers } from "@/lib/hook";
import PhoneNumberDropdown from "@/components/phone/phone-number-dropdown";

Update the rendered component to set a defaultValue for phone_number in the call to the useForm hook:

export default function VoteForm() {
 const optionRef = useRef() as React.MutableRefObject<HTMLInputElement>;

 const [options, setOptions] = useState<{ id: string; label: string }[]>([]);
 const form = useForm<z.infer<typeof FormSchema>>({
   mode: "onSubmit",
   resolver: zodResolver(FormSchema),
   defaultValues: {
     title: "",
     vote_options: [],
     phone_number: "",
   },
 });

 ...
}

Use the useAvailablePhoneNumbers hook and use the PhoneNumberDropdown component (add it right before the existing button):

export default function VoteForm() {
 ...

 const { data: availablePhoneNumbers } = useAvailablePhoneNumbers();

 return (
   <Form {...form}>
     <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
       ...

       <PhoneNumberDropdown
         name="phone_number"
         control={form.control}
         phoneNumbers={availablePhoneNumbers}
       />

       <Button
         type="submit"
         className="w-full"
         disabled={!(options.length >= 2)}
       >
         Create
       </Button>
     </form>
   </Form>
 );
}

You do not need to make any further changes to VoteForm. However, it's worth looking at the onSubmit handler:

async function onSubmit(data: z.infer<typeof FormSchema>) {
   const vote_options: IVoteOptions = {};
   data.vote_options.forEach((option, index) => {
     vote_options[option] = {
       position: index,
       vote_count: 0,
     };
   });
   const insertData = { ...data, vote_options };
   toast.promise(createVote(insertData), {
     loading: "creating...",
     success: "Successfully create a vote",
     error: "Fail to create vote",
   });
 }

Within onSubmit, the form data is passed to a createVote function. This now receives a phone_number.

Go to the definition of createVote in lib/actions/vote.ts, and update it to accept a phone_number and use it within the database create_vote function that you updated earlier:

export async function createVote(data: {
 vote_options: IVoteOptions;
 end_date: Date;
 title: string;
 description?: string;
 phone_number?: string;
}) {
 const supabase = await createSupabaseServer();

 const { data: voteId, error } = await supabase.rpc("create_vote", {
   options: data.vote_options as any,
   title: data.title,
   end_date: new Date(data.end_date).toISOString(),
   description: data.description || "",
   phone_number: data.phone_number || "",
 });

 if (error) {
   throw "Fail to create vote." + error.message;
 } else {
   redirect("/vote/" + voteId);
 }
}

Add an optional phone_number property to the data parameter of the createVote function. Update the supabase.rpc("create_vote", …) call to also pass the data.phone_number or an empty string if it has no value.

With the VoteForm component updated to use the PhoneNumberDropdown, the user can now create a vote and assign a phone number to support SMS voting.

SupaPoll create a new poll
SupaPoll create a new poll

One additional change is to add the phone number to the main vote view so that users know which phone number to send an SMS vote to.

Open app/vote/components/Info.tsx and add a phone number to the UI if one is set on the vote:

"use client";

import React from "react";
import dynamic from "next/dynamic";
import { IVote } from "@/lib/types";
import { toDisplayedPhoneNumberFormat } from "@/lib/utils";

const TimeCountDown = dynamic(() => import("./TimeCountDown"), { ssr: false });

export default function Info({ vote }: { vote: IVote }) {
 const tomorrow = new Date(vote.end_date);
 tomorrow.setHours(0, 0, 0, 0);

 return (
   <div className="space-y-3 w-full">
     <h2 className="text-3xl font-bold break-words">{vote.title}</h2>
     <TimeCountDown targetDate={tomorrow} />
     {vote.phone_number && (
       <div className="mt-12 text-2xl ">
         Vote by sending an SMS:{" "}
         <span className="bg-zinc-600 p-1">#choice</span> to{" "}
         <span className="font-extrabold">
           {toDisplayedPhoneNumberFormat(vote.phone_number)}
         </span>
       </div>
     )}
   </div>
 );
}

Check for the phone_number presence on a vote and use the toDisplayedPhoneNumberFormat utility function you created earlier to present the phone number in an easily readable format.

SupaPoll vote result
SupaPoll vote result

The vote view now contains two important pieces of information of information for SMS voting:

1. The phone number to send the text message to.

2. The contents to include in the SMS body in the format #{choice} where {choice} is a number referencing the voting choice. For example, in the image above, #2 in an SMS body would represent a vote for the Hookdeck CLI.

Use the PhoneNumberDropdown component within the edit vote view

Updating the edit view to support changing or removing the phone number associated with a vote is very similar to the create view.

Open app/edit/[id]/EditVoteForm.tsx and add the following imports:

import { useAvailablePhoneNumbers } from "@/lib/hook";
import PhoneNumberDropdown from "@/components/phone/phone-number-dropdown";

Add a phone_number to the edit form schema:

const FormSchema = z.object({
 title: z.string().min(5, { message: "Title has a minimum characters of 5" }),
 end_date: z.date(),
 description: z.string().optional(),
 phone_number: z.string(),
});

Add the phone_number default value to the call to useForm:

export default function EditVoteForm({ vote }: { vote: IVote }) {
 const form = useForm<z.infer<typeof FormSchema>>({
   mode: "onSubmit",
   resolver: zodResolver(FormSchema),
   defaultValues: {
     title: vote.title,
     end_date: new Date(vote.end_date),
     description: vote.description || "",
     phone_number: vote.phone_number || "",
   },
 });

 ...

Get the available phone numbers using the imported useAvailablePhoneNumbers hook and add the PhoneNumberDropdown component to the UI above the existing update button:

export default function EditVoteForm({ vote }: { vote: IVote }) {
 ...

 const { data: availablePhoneNumbers } = useAvailablePhoneNumbers();

 return (
   <Form {...form}>
     <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
       ...

       <PhoneNumberDropdown
         control={form.control}
         phoneNumbers={availablePhoneNumbers}
         name="phone_number"
       />

       <Button
         type="submit"
         className="w-full"
         disabled={!form.formState.isValid}
       >
         update
       </Button>
     </form>
   </Form>
 );
}

The edit view also has an onSubmit handler:

async function onSubmit(data: z.infer<typeof FormSchema>) {
   toast.promise(updateVoteById(data, vote.id), {
     loading: "update...",
     success: "Successfully update",
     error: (err) => "Fail to update vote. " + err.toString(),
   });
 }

This function calls an updatevoteById utility that you need to update.

Open lib/actions/vote.ts and change the updateVoteById function to accept a phone_number property on the data parameter. Also, change the update call to the Supabase client to pass the data.phone_number.

export async function updateVoteById(
 data: {
   end_date: Date;
   description?: string;
   title: string;
   phone_number: string;
 },
 voteId: string,
) {
 const supabase = await createSupabaseServer();
 const { error, data: vote } = await supabase
   .from("vote")
   .update({
     title: data.title,
     end_date: data.end_date.toISOString(),
     description: data.description,
     phone_number: data.phone_number,
   })
   .eq("id", voteId);
 if (error) {
   throw error.message;
 }
 revalidatePath("/vote/" + voteId);
 return redirect("/vote/" + voteId);
}

You'll notice that this does not call a Postgres function but instead uses the Supabase SDK's update function and built-in support for manipulating database tables.

Users can now edit the phone number associated with a vote.

Edit an existing poll GIF
Edit an existing poll GIF

Users can now edit the phone number associated with a vote.

Configure Hookdeck to receive Twilio SMS webhooks

The next step is to set up Hookdeck to receive SMS webhooks from Twilio and deliver them to your Next.js application. At this point, you may ask why use Hookdeck instead of pointing the webhooks directly at your Next.js application.

Here are a few reasons:

  • You can use the Hookdeck CLI to receive webhooks on your localhost during development.
  • You can replay webhook events, simplifying the development process and saving time and SMS message fees both from your phone and via Twilio.
  • Hookdeck provides filters to filter in or out messages, and transformation to manipulate headers, body payloads, and destination paths on the fly.
  • Hookdeck delivers webhooks at a configurable rate so reduce the load on your application.
  • Hookdeck is a highly scalable serverless message queue that automatically retries failed webhooks, meaning you’ll never miss a Twilio webhook.

Hookdeck concepts

At this point, it's worth digging a bit deeper into some of the core concepts of Hookdeck.

Overview of the Webhook mapping for this tutorial in Hookdeck
Overview of the Webhook mapping for this tutorial in Hookdeck
  • Request: A representation of an inbound HTTP request to a Hookdeck Source. In this tutorial, it’s an SMS webhook.
  • Source: The entry point into Hookdeck. Represented by an HTTP endpoint. A received Request generates an Event on each Connection the Source is part of. A Source can be used in one or more Connections.
  • Connection: Connections represent routing between Sources and Destination. Connections can have Rules, such as Transformations and Filters.
  • Rules: Rules that are applied to events on a Connection such as Transformations, Filters, Delays, and Retries. You’ll use Transformations and Filters in this tutorial.
  • Event: For every Request received by a Source, an Event is generated on a Connection. However, the Events can be filtered from being delivered to a Destination by a Filter rule.
  • Destination: Represents where an Event will be delivered. Destinations can be used in one or more Connections. Hookdeck supports three destination types:
    1. HTTP destinations with a URL
    2. A Mock type that accepts all requests and is useful during testing
    3. CLI used during development
  • Attempt: An attempt to deliver an Event to the Destination defined in the Hookdeck Destination. For example, an outbound HTTP request made to an HTTP endpoint.

Create a Hookdeck Connection

Head to the Hookdeck dashboard. If you are presented with the onboarding flow, click Skip to dashboard.

Navigate to Configure > Connections and click the + Connection button at the top right of the dashboard.

Do not click the button in the center of the page, as this takes you through a different flow.

Within the Define your request source section, enter twilio-sms under Source Name.

Then, set up Hookdeck to verify that inbound webhooks are coming from Twilio. Open the Advanced Configuration panel and enable Source Authentication. Search for and select * Twilio.

Head to the Twilio Console, copy your Auth Token, and paste it into the Webhook Signing Secret field in the Hookdeck dashboard.

Create a Source in Hookdeck
Create a Source in Hookdeck

Under Define your event destination, enter prod-supapoll-sms as the Destination Name.

Set the Destination Type as Mock API. A Mock API accepts all inbound requests. When you deploy your application, you should replace this destination with an HTTP destination.

Enable Max deliver rate and accept the initial setting of 5 requests per second. This isn’t too useful right now, but is very useful when you deploy the application.

Define the Destination in Hookdeck
Define the Destination in Hookdeck

Skip over the Define your connection rules section. You'll add a transformation and filter rule shortly.

Name your connection prod-twilio-supapoll-sms-voting and click + Create.

Name a connection in Hookdeck
Name a connection in Hookdeck

Copy the URL at the top of the Connection Created prompt on the screen that follows.

Active Hookdeck URL for webhooks
Active Hookdeck URL for webhooks

Configure Twilio phone number SMS webhooks

Go to the Phone Numbers -> Manage -> Active numbers section in the Twilio Console.

For each number that you have purchased and you wish to be used within your SupaPoll application, click on the number, go to the Messaging Configuration section, and use your Hookdeck URL with the path /webhooks/vote appended (you’ll create this route in the Next.js application shortly).

Click Save configuration.

Pasting a Hookdeck URL into a Twilio webhook
Pasting a Hookdeck URL into a Twilio webhook

Send a test message to a Twilio phone number

Send a text message from your mobile to one of the Twilio phone numbers with the message body content of #1 this is a test.

Back in the Hookdeck Dashboard you will see the prompt update indicating the webhooks has been received.

A Hookdeck URL available for your Twilio webhooks
A Hookdeck URL available for your Twilio webhooks

Click the arrow to the right of the row to view the full details of the webhook event, including the HTTP headers and body.

Inspect the Event Data > Headers, and you can see the content-type is application/x-www-form-url-encoded.

Event Details in Hookdeck
Event Details in Hookdeck

Inspect the Event Data > Body, and you can see Hookdeck has formatted the payload as JSON for easier inspection. Here, you can also see that the Body property of the payload contains the contents of the SMS message you sent.

View the Body of a request from Twilio
View the Body of a request from Twilio

Transform SMS webhook payloads

The inbound SMS is received by Hookdeck with the content-type of application/x-www-form-url-encoded and for the application you are building it's preferable to receive the payload as JSON. Additionally, and more interestingly, you can also transform the payload in Hookdeck to offload some of the parsing logic to Hookdeck as a serverless worker.

Go to the Connections section of the Hookdeck dashboard. The connections view provides a visual representation of your connections.

Click the line between the twilio-sms Source and the prod-supapoll-sms Destination, click the Transform + button, and click Create new transformation.

Create a Transformation in Hookdeck
Create a Transformation in Hookdeck

The transformation editor shows the previous Twilio SMS webhook request on the left of the view and the editor on the right.

Hookdeck transformations work by passing the request object to a function, allowing you to manipulate it.

Enter the following code to convert the content-type and also extract the vote for easy access:

addHandler('transform', (request, context) => {
 request.headers['content-type'] = 'application/json';

 // Matches up to two digits e.g. #99
 const voteMatch = new RegExp('^#(\\d{1,2})\\s?(.*?)$')
 const result = voteMatch.exec(request.body.Body);
 if(result) {
   request.body._voteNumber = result[1];

   // If no additional text is passed will be empty
   request.body._voteAdditionalText = result[2];
 }

 return request;
});

The content-type is updated simply by changing the content-type header, and Hookdeck does the rest.

The transformation also adds two additional properties to the request.body payload:

  • _voteNumber - the vote number
  • _voteAdditionalText - any additional text sent with the vote

The manipulated request must be returned from a transformation function.

Test the transformation by clicking the Run button, and you will see the resultant output appear in the Output tab of the editor.

Adding two properties to a Request Body from Hookdeck
Adding two properties to a Request Body from Hookdeck

Click Confirm in the transformation editor, name the transformation twilio-sms-to-vote, click Confirm, followed by Save in the connection dialog.

Filter SMS webhook payloads

You only want to accept SMS messages that have a message body of the correct format beginning with # and then a number. For example, #1. From the transformation you know that payloads that do have the correct format will now have a _voteNumber property on the request body.

You could perform filtering within your application. However, Hookdeck can do this for you, meaning you can reduce the processing load on your application and you only need to handle messages with a valid format within your application logic.

From the open connection dialog, click the Filter + button followed by the Editor button.

Filter in Hookdeck
Filter in Hookdeck

The filter editor view is similar to the transformation editor view, except the headers, body, query, and path are separated.

In this case, you want to filter on the payload body for the presence of the _voteNumber property. Enter the following filter into the editor (hint: you can also use the AI Filter button to generate the filter syntax):

{
 "_voteNumber": {
   "$exist": true
 }
}

Click the Test Filter button to test if the filter matches the payload. The initial test will fail, symbolized by the red icon on the Body tab. Update the test payload by adding a _voteNumber property and run the test again to ensure the test matches.

Using Artificial Intelligence suggested Filters in Hookdeck
Using Artificial Intelligence suggested Filters in Hookdeck

Click Confirm in the filter editor, followed by Save in the connection dialog.

Test sending two SMS messages to one of your Twilio numbers:

1. hello from test - this will not meet the required format and won't reach the Destination

2. #1 is awesome - this will be transformed and will match the required filter so will reach the Destination

View the inbound webhook request and associated events in the Hookdeck Requests view, and where the transformation is applied and the filter matches, you will see the triggered events in a sub row. Within the Events view you will see only triggered events.

View the Body of a request in Hookdeck
View the Body of a request in Hookdeck

Receive SMS webhooks on your localhost with the Hookdeck CLI

You now have the Twilio SMS webhook requests being received and processed by Hookdeck. But to build the SMS functionality into your Next.js application, you need to receive the events on your localhost. This is exactly what the Hookdeck CLI is for.

Assuming your application is running on port 8080, run the following Hookdeck CLI command in a new terminal window (running this command may also take you through a workflow to anthenticate the CLI with Hookdeck):

hookdeck listen 8080 twilio-sms

This command dynamically creates a Connection within Hookdeck from the Source named twilio-sms to your CLI.

When prompted for a path, enter / and use localhost as your connection label. Note that because the Twilio Webhook URL is set to contain the /webhooks/vote path, you do not need to use the full path here. If preferred, you can change this behavior by disabling Path Forwarding on a Hookdeck Destination.

The full output of this command will be similar to the following:

hookdeck listen 8080 twilio-sms
? What path should the events be forwarded to (ie: /webhooks)? /
? What's your connection label (ie: My API)? localhost

Dashboard
👉 Inspect and replay events: https://dashboard.hookdeck.com?team_id=tm_U9Zod13qtsHp

twilio-sms Source
🔌 Event URL: https://hkdk.events/z5elg90qnjw31c

Connections
localhost forwarding to /

> Ready! (^C to quit)

Go to the Connections section of the Hookdeck Dashboard. The connection's visual representation will now show the localhost CLI Destination.

Localhost in Hookdeck, without Transformations or Filters
Localhost in Hookdeck, without Transformations or Filters

However, if you look closely, you will also notice that the transformation and filter are not applied to the localhost connection. So, you need to add these to match the prod-twilio-supapoll-sms-voting connection.

You can reuse the existing transformation. To do so, click on the localhost connection, click the Transform + button, select the existing twilio-sms-to-vote transformation, and click Save.

Filters can't be shared, so open the prod-twilio-supapoll-sms-voting connection, copy the filter rule, open the localhost connection, click the Filter + button, paste the filter into the textarea, and click Save.

After applying the transformation and filter, the connection visual representation will show the related icons:

Add filters or transformations in Hookdeck to localhost
Add filters or transformations in Hookdeck to localhost

Now, it's time to receive an SMS webhook on your localhost.

Send a test message in the expected #{choice} format to one of your Twilio phone numbers.

Within your terminal running the Hookdeck CLI listen command, you will see a log entry similar to the following:

2024-06-05 10:56:36 [404] POST http://localhost:8080/webhooks/vote/webhooks/vote | https://dashboard.hookdeck.com/cli/events/evt_bnpYvITBAJWvBEOwxJ

Navigate to the Events section of the Hookdeck Dashboard and change the drop-down under the Inspect label from HTTP View to CLI View and will see an Event with a status of 404.

A failed webhook in Hookdeck with a 404
A failed webhook in Hookdeck with a 404

The 404 status is expected because the Next.js application doesn't yet have a route defined on /webhooks/vote. The next step is to implement that route.

Using inbound SMS webhooks to register votes

The /webhooks/vote has to perform several steps:

1. Receive the inbound HTTP request

2. Verify the webhook originated from Hookdeck

3. Find the user associated with the phone number the SMS has come "From"

4. Lookup the vote associated with the SMS "To" number

5. Check that the vote option sent in the message is a valid option in the vote

6. Register the user's vote

7. Send an acknowledgment SMS back to the voter

Receive the inbound HTTP request

Begin by creating the /webhooks/vote route. Create a app/webhooks/vote/route.ts file with the following contents:

import { NextResponse } from "next/server";

export async function POST(request: Request) {
 return NextResponse.json({ accepted: true });
}

Go to the Events section of the Hookdeck Dashboard and click the replay icon within the Status column to retry delivering the event to the localhost Destination.

Retrying a failed webhook in Hookdeck
Retrying a failed webhook in Hookdeck

The Hookdeck events view will show a 200 status, and you will see the HTTP request logged in your terminal by the Hookdeck CLI with output similar to the following:

2024-06-05 11:26:57 [200] POST http://localhost:8080/webhooks/vote | https://dashboard.hookdeck.com/cli/events/evt_bnpYvITBAJWvBEOwxJ

Verify the webhook originated from Hookdeck

With the HTTP request successfully received, you can now add functionality to verify that the request originated from Hookdeck.

Navigate to the project secrets section in the Hookdeck Dashboard, copy the Signing Secret value, and add a HOOKDECK_SIGNING_SECRET entry to your .env.local with the value.

Install the Hookdeck SDK:

npm i @hookdeck/sdk

Import and use the SDK verifyWebhookSignature helper function within app/webhooks/vote/route.ts to verify the signature.

import { NextResponse } from "next/server";
import { verifyWebhookSignature } from "@hookdeck/sdk/webhooks/helpers";

export async function POST(request: Request) {
 const headers: { [key: string]: string } = {};
 request.headers.forEach((value, key) => {
   headers[key] = value;
 });

 const rawBody = await request.text();

 const verificationResult = await verifyWebhookSignature({
   headers,
   rawBody,
   signingSecret: process.env.HOOKDECK_SIGNING_SECRET!,
   config: { checkSourceVerification: true },
 });

 if (!verificationResult.isValidSignature) {
   return new NextResponse(
     JSON.stringify({ error: "Could not verify the webhook signature" }),
     { status: 401 },
   );
 }

 return NextResponse.json({ webhook_verified: true });
}

Update the POST handler to extract the headers and raw body from the request and pass those, along with the HOOKDECK_SIGNING_SECRET, to the verifyWebhookSignature function. Use the result of that function call to determine whether the request is successfully signed. Based on the verification result, send an appropriate HTTP response.

Retry the event from the Hookdeck Dashboard and inspect the status code is 200 and the body payload is:

{
  "webhook_verified": true
}

Find out more about signature verification in the Hookdeck webhook signature verification docs.

Find the user associated with the phone number the SMS has come "From"

Go to the Events view in the Hookdeck Dashboard again and click on a successful Attempt. Ensure the Request tab is active in the right-hand panel, and scroll down to the Body section. Change the right drop-down to Types and ensure TypeScript is selected in the language drop-down.

Highlighting a successful Webhook in Hookdeck
Highlighting a successful Webhook in Hookdeck

You will see the TypeScript type definition for the request body. Use the copy button to copy the contents and paste the definition into app/webhooks/vote/route.ts.

You can now reference a typed Twilio SMS webhook payload body:

import { NextResponse } from "next/server";
import { verifyWebhookSignature } from "@hookdeck/sdk/webhooks/helpers";

interface Body {
 ToCountry: string;
 ToState: string;
 SmsMessageSid: string;
 NumMedia: string;
 ToCity: string;
 FromZip: string;
 SmsSid: string;
 FromState: string;
 SmsStatus: string;
 FromCity: string;
 Body: string;
 FromCountry: string;
 To: string;
 ToZip: string;
 NumSegments: string;
 MessageSid: string;
 AccountSid: string;
 From: string;
 ApiVersion: string;
 _voteNumber: string;
 _voteAdditionalText: string;
}

export async function POST(request: Request) {
 const headers: { [key: string]: string } = {};
 request.headers.forEach((value, key) => {
   headers[key] = value;
 });

 const rawBody = await request.text();

 const verificationResult = await verifyWebhookSignature({
   headers,
   rawBody,
   signingSecret: process.env.HOOKDECK_SIGNING_SECRET!,
   config: { checkSourceVerification: true },
 });

 if (!verificationResult.isValidSignature) {
   return new NextResponse(
     JSON.stringify({ error: "Could not verify the webhook signature" }),
     { status: 401 },
   );
 }

 const body: Body = JSON.parse(rawBody);

 return NextResponse.json({ webhook_verified: true });
}

Next, create a Supabase admin client to query the profile table and find the user who sent the SMS vote.

import createSupabaseServerAdmin from "@/lib/supabase/admin";
import { toStoredPhoneNumberFormat } from "@/lib/utils";

export async function POST(request: Request) {
 …

 const body: Body = JSON.parse(rawBody);

 // Find the user associated with the phone number the SMS has come "From"
 const supabase = await createSupabaseServerAdmin();

 // Supabase does not store the "+" sign in the phone number, so remove it for the lookup
 const from = toStoredPhoneNumberFormat(body.From).replace("+", "");

 const { data: voter, error: profileError } = await supabase
   .from("profile")
   .select("*")
   .eq("phone", from)
   .single();

 if (profileError) {
   const errorMessage =
     "Error: could not find a user with the provided 'from' phone number";
   console.error(errorMessage, profileError);

   return new NextResponse(
     JSON.stringify({
       error: errorMessage,
     }),
     { status: 404 },
   );
 }

 console.log("Found profile", voter);

 return NextResponse.json({ voting_user_found: true });
}

Import the createSupabaseServerAdmin and toStoredPhoneNumberFormat utility functions. Calling createSupabaseServerAdmin utilizes the NEXT_PUBLIC_SUPABASE_URL and SERVICE_ROLE you have set in your .env.local file.

Ensure the body.From phone number is in E.164 format. However, as a small workaround, you'll need to strip the "+" as Supabase Authentication stores the phone number without the "+" prefix, which is the number stored in the profile table that you are querying.

Find the profile and thus the user with the matching phone number by performing a query using the Supabase client.

If a profile is not found, return a 404 response. Otherwise, log the user and return a 200.

Lookup the vote associated with the SMS "To" number

The next step is to look up the vote associated with the SMS webhook "To" number. However, before you do that, you should add functionality to perform future database interactions as the user.

Act as a Supabase User

The benefits of interacting with the Supabase Database as a user are:

1. You get Row Level Security on all operations. For example, RLS can ensure that only a user who created a vote can edit it.

2. The user can be inferred within database operations.

In order to impersonate a user (in a good and perfectly legitimate way), you need to create a JWT signed with the user's email and ID.

Begin by ensuring you have the Supabase JWT secret from the Supabase dashboard > Project > Configuration > API section of your project within the Supabase dashboard and assigning the value to a new SUPABASE_JWT_SECRET variable in .env.local.

Next, install a JWT package called fast-jwt:

npm i fast-jwt

Include the createClient function from the Supabase SDK, createSigner from Fast JWT, and create a utility function called createSupabaseToken:

import { createClient } from "@supabase/supabase-js";
import { createSigner } from "fast-jwt";

const signer = createSigner({
 key: process.env.SUPABASE_JWT_SECRET,
 algorithm: "HS256",
});

function createSupabaseToken(userEmail: string, userId: string) {
 const ONE_HOUR = 60 * 60;
 const exp = Math.round(Date.now() / 1000) + ONE_HOUR;
 const payload = {
   aud: "authenticated",
   exp,
   sub: userId,
   email: userEmail,
   role: "authenticated",
 };
 return signer(payload);
}

Create a signer using the createSigner utility passing the Supabase JWT token and an algorithm with a value of HS256.

Create a createSupabaseToken function that takes an email and user ID to create the JWT token for the user. The signed payload has several properties, including the passed email and user ID. Use the signer to sign the payload and return the result from the createSupabaseToken function.

Update the POST handler to create a Supabase client authenticated as the user and get the user via the client:

export async function POST(request: Request) {
 ...

 console.log("Found profile", voter);

 // Lookup the vote associated with the SMS "To" number

 // - Act as a Supabase User
 const token = createSupabaseToken(
   voter.email as string,
   voter.id,
 );

 const userClient = createClient(
   process.env.NEXT_PUBLIC_SUPABASE_URL!,
   process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
   {
     global: { headers: { Authorization: `Bearer ${token}` } },
   },
 );

 // can be used normally with RLS!
 const user = await userClient.auth.getUser();
 console.log("Mapped to user", user);

 return NextResponse.json({ authenticated_user_found: true });
}

Since you have looked up the voter (user) already, use their email and ID to create the Supabase token using createSupabaseToken.

Once you have the SupabaseToken, use the Supabase createClient to create a client authenticated as the user: pass the Supabase URL and anon key, and use the generated token in an Authorization header.

Finally, use the asynchronous userClient.auth.getUser() function to check you can get the expected user.

Retry the event from the Hookdeck Dashboard to manually test the route's handling of the webhooks and the new functionality.

Perform the Vote lookup

Next is to find the vote with the associated inbound (To) Twilio phone number:

export async function POST(request: Request) {
 ...

 const user = await userClient.auth.getUser();
 console.log("Mapped to user", user);

 // - Perform the Vote lookup
 const votePhoneNumber = toStoredPhoneNumberFormat(body.To);

 const { data: vote, error: voteError } = await userClient
   .from("vote")
   .select("*")
   .eq("phone_number", votePhoneNumber)
   .single();

 if (voteError) {
   return new NextResponse(
     JSON.stringify({
       error:
         "Error: could not find a poll with the provided 'to' phone number",
     }),
     { status: 404 },
   );
 }

 console.log("Found vote", vote);

 return NextResponse.json({ vote_found: true });
}

Convert the phone number in the Twilio SMS webhook body.To property to a format stored in the vote table and assign to a votePhoneNumber variable.

Query the vote table and look for a vote with the associated phone_number equal to votePhoneNumber. The use of single() means a single row must be returned; otherwise the voteError is populated.

If there is an error, return a 404. Otherwise log the vote is found and return a 200 response.

Again, you can test this functionality by retrying the event using the Hookdeck Dashboard.

Check that the vote option sent in the message is a valid option in the vote

The next thing in the flow is to check that the vote option (the _voteNumber property in the webhook payload) is a valid vote option.

Begin by querying the vote_options table and getting the available vote options:

export async function POST(request: Request) {
 ...

 console.log("Found vote", vote);

 // Check that the vote option sent in the message is a valid option in the vote
 const { data: voteOptions, error: voteOptionsError } = await userClient
   .from("vote_options")
   .select("options")
   .eq("vote_id", vote.id)
   .single();

 if (voteOptionsError) {
   console.error(voteOptionsError);

   return new NextResponse(
     JSON.stringify({
       error:
         `Error: could not find vote options for the poll with a poll with id "${vote.id}"`,
     }),
     { status: 404 },
   );
 }

 return NextResponse.json({ found_vote_options: true });
}

Use single() again to trigger an error if a single match is not found. If there is an error, return a 404 response; otherwise, proceed to the next step of the flow.

The next step is to check that the user-provided vote option is present within the available options:

import { IVoteOptions } from "@/lib/types";

export async function POST(request: Request) {
 ...

 if (voteOptionsError) {
   console.error(voteOptionsError);

   return new NextResponse(
     JSON.stringify({
       error:
         `Error: could not find vote options for the poll with a poll with id "${vote.id}"`,
     }),
     { status: 404 },
   );
 }

 const options = voteOptions.options as unknown as IVoteOptions;
 const votedForOption = body._voteNumber;

 console.log("options", options);

 let selectedOptionText = null;
 const optionKeys = Object.keys(options);
 for (let i = 0; i < optionKeys.length; ++i) {
   const key = optionKeys[i];
   if (Number(options[key].position) === Number(votedForOption)) {
     console.log("Found matching option", key, options[key]);
     selectedOptionText = key;
     break;
   }
 }

 if (!options || !selectedOptionText) {
   return new NextResponse(
     JSON.stringify({
       error:
         `Error: could not find vote option sent in the body of the SMS message: "${votedForOption}" for poll with id "${vote.id}"`,
     }),
     { status: 404 },
   );
 }

 return NextResponse.json({ option_validated: true });
}

Cast the voteOptions.options property to a IVoteOptions for type inference.

Then, loop through the options from the database and ensure the SMS submitted vote option is within the available options. When you find the valid option, store the option text in a selectedOptionText variable for use in the vote registration step.

If the user's option cannot be found, return a 404.

Register the user's vote

The penultimate step in the flow is to register the user's vote in the database.

Add the following code the the POST hander:

export async function POST(request: Request) {
 ...

 if (!options || !selectedOptionText) {
   return new NextResponse(
     JSON.stringify({
       error:
         `Error: could not find vote option sent in the body of the SMS message: "${votedForOption}" for poll with id "${vote.id}"`,
     }),
     { status: 404 },
   );
 }

 // Register the user's vote to the poll
 const { error, data } = await userClient.rpc("update_vote", {
   update_id: vote.id,
   option: selectedOptionText,
 });

 if (error) {
   const errorMessage =
     `Error: could not update the vote: "${votedForOption}" for poll with id "${vote.id}"`;
   console.error(errorMessage, error);

   return new NextResponse(
     JSON.stringify({
       error: errorMessage,
     }),
     { status: 500 },
   );
 }

 console.log("Updated vote", data);

 return NextResponse.json({ vote_updated: true });
}

Use the userClient to call the update_vote database function passing the arguments vote ID and the selected option text value. This function needs to be called in the context of an authenticated user to infer who the current user is when registering the vote and assign the vote to them.

No error is expected upon calling the database function. So, if an error occurs, return a 500.

Note that more could be done here to identify why the error occurred, for example if the user has already registered a vote. If you do come across this error during development, you can remove your vote from the database using the Supabase Dashboard. Go to Table Editor > vote_log, select the row you wish to delete, click the Delete 1 row button, and confirm by clicking Delete in the confirmation dialog.

Removing an entry in Supabase
Removing an entry in Supabase

Send an acknowledgment SMS back to the voter

The final step is to let the user know that their vote has been registered via a confirmation SMS message.

Get your Twilio Account SID and Auth Token from the Account Info section of the Twilio Console home page. Add the values of the Account SID and Auth Token to variables TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN, respectively, inside your .env.local.

Install the Twilio Node SDK :

npm i twilio

Import and initialize the Twilio SDK at the top of route.ts:

import { Twilio } from "twilio";

const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID;
const twilioAuthToken = process.env.TWILIO_AUTH_TOKEN;
const twilioClient = new Twilio(twilioAccountSid, twilioAuthToken);

Finally, send the text message to the user:

export async function POST(request: Request) {
 ...

 console.log("Updated vote", data);

 await twilioClient.messages.create({
   to: body.From,
   from: body.To,
   body: `Thanks for your vote for ${selectedOptionText} 🎉`,
 });

 return NextResponse.json({ vote_success: true });
}

The to value should match the inbound SMS webhook body.From value and the from the vote SMS phone number used in body.From. Confirm the selected vote option by using the selectedOptionText in the body of the SMS message.

If you are on a Twilio trial account you can only send SMS messages to verified phone numbers.

All the functionality for SMS voting is now in place. So, try registering a vote via SMS with the web UI open. The realtime UI updates are powered by Supabase Realtime.

SMS voting with Hookdeck animated
SMS voting with Hookdeck animated

Testing, troubleshooting, and deploying apps with Hookdeck

The Hookdeck CLI is great for testing receiving webhooks on your localhost, and you don’t actually need a Hookdeck account to use it.

You’ve gone through the process of retrying events using the Hookdeck Dashboard. This is a real time (and money) saver when testing inbound webhooks, rather than having to send an SMS to trigger the webhook.

The Hookdeck request and event views are also useful for checking whether Hookdeck has received a webhook, an event has been triggered, and the request/response of a Destination.

Hookdeck has an issues and notifications feature that will notify you whenever there is a problem. There are a number of issue triggers, including delivery and transformation problems.

If you decide to deploy your application, you'll need to change the Destination Type of the prod-twilio-supapoll-sms-voting Destination in Hookdeck. To do this, open up the Destination, change the value in the Destination Type drop-down to HTTP, and enter the public URL endpoint where your application is deployed. Remember, you don't need to include the path (/webhooks/vote) as that's already configured in the Twilio SMS Webhook URL.

I've mentioned it once already, but it's worth repeating: if you have a Twilio trial account, the recipient's phone number needs to be verified to receive an outbound SMS, such as the vote confirmation message.

Conclusion

In this tutorial, you've learned how to augment a Next.js voting application to support realtime voting via SMS with Supabase, Twilio, and Hookdeck.

You used Twilio Verify to register and verify a phone number for a user with Supabase. You updated the application to support associating Twilio phone numbers with votes, which included Next.js app changes and SQL updates. You also configured Hookdeck, adding a scalable serverless shield in front of your Next.js application infrastructure and made use of features such as transformations and filters.

The source to the SupaPoll application is available on GitHub, and you can see the application running live at supapoll.com. I'd love to receive feature suggestions and pull requests, and please raise an issue if you find any problems. But, also feel free to fork the application and take it in any direction you want. Here are a few ideas:

  • The SMS votes support additional text along with the vote choice. How about turning that additional text into a comment or just adding SMS support for comments?
  • Improve the error handling in /webhooks/vote and notify users via SMS of the problems, where applicable.
  • Add support for email voting using Twilio SendGrid.
  • Check out the Supabase docs, Twilio docs, and Hookdeck docs and get inspired by other potential features.

Phil Leggetter is the Head of Developer Relations at Hookdeck. He's spent years working with event-driven applications, loves the interactive features you can build with webhooks and WebSockets, and spends his days helping developers succeed with these technologies. You connect with Phil on LinkedIn, X (formerly Twitter), or via email at phil [at] hookdeck.com.