Secure and Scalable SMS Realtime Voting with Hookdeck, Twilio Verify, Twilio Programmable Messaging, Supabase, and Next.js
Time to read: 34 minutes
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.
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:
- Git and Node.js installed in your development environment
- A free Twilio account and a Twilio Phone Number
- A free Supabase account and install the Supabase CLI
- A free Hookdeck account and the Hookdeck CLI installed
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:
Navigate into the supapoll
directory, login to Supabase with the CLI, and create a Supabase project:
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:
And update the new database to use the SupaPoll schema:
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:
Your .env.local
will look similar to the following:
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.
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.
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.
Click Save in the Supabase dashboard to save your GitHub Authentication provider details.
Install the dependencies for your application and run the starter app:
Open http://localhost:8080
and test the GitHub login flow:
Finally, click on your profile picture, select Create, create your first web-only vote, and test voting from the web.
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.
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).
You can find your Verify service SID in the Service SID field on the next screen.
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:
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:
Then, use the component within the UI definition:
Load the application in your browser to see the result:
Next, add logic to the UI to support the registration workflow:
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:
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
.
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):
Once updated, register-phone.tsx
looks as follows:
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.
Next, import a number of items to support form submission and validation:
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:
To use the new RegisterForm
component, update the parent RegisterPhone
component to import and use it.
Include the import in register-phone.tsx
:
And replace the Registration form goes here
message with the imported component:
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 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
:
Open up lib/utils.ts
, and import parsePhoneNumber
.
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.
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
:
And add the transform
to register-form.tsx
and perform validation during transformation using ctx.addIssue
when any validation problems are detected:
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.
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
.
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:
Next, add state to handle storing the phone number:
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.
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:
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.
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
:
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:
Next, update the RegisterPhone
component to define a verification form handler:
Replace the "Verification form goes here" message with the VerifyForm
component, passing the handleVerifySubmit
handler:
If you follow the registration flow in the application now, you'll see the console.log
output.
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 the toast
UI utility used elsewhere in the application for UI feedback:
Update the handleVerifySubmit
function to verify the PIN code using the Supabase SDK and provide user feedback using the toast
UI utility:
Create an otpParams
variable of type VerifyMobileOtpParams
with properties:
phone
- the phone number being verifiedtoken
- the user submitted PIN codetype
- with a value ofphone_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.
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.
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.
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:
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:
To add a phone_number
column to the vote
table, you need to create a migration.
Create a new migration with the Supabase CLI:
Add the following to the generated .sql
migration file:
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:
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:
If you view the Supabase schema visualizer, you will see the new column in the vote
table.
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):
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:
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:
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
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:
Find any phone numbers that are used within a vote where the vote is still active:
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:
Then add the useAvailablePhoneNumbers()
function:
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
:
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
:
The PhoneNumberDropdown
component takes properties defined by PhoneNumberDropdownProps
. These are:
name
- the name of the form component corresponding to the form schemacontrol
- The React Hook Form control is used to manage statephoneNumber
- an array ofFormattedNumber
to be displayed within the drop-down
With the component properties defined, create the PhoneNumberDropdown
component:
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
:
Import the drop-down component and useAvailablePhoneNumbers
hook:
Update the rendered component to set a defaultValue
for phone_number
in the call to the useForm
hook:
Use the useAvailablePhoneNumbers
hook and use the PhoneNumberDropdown
component (add it right before the existing button):
You do not need to make any further changes to VoteForm
. However, it's worth looking at the onSubmit
handler:
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:
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.
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:
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.
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:
Add a phone_number
to the edit form schema:
Add the phone_number
default value to the call to useForm
:
Get the available phone numbers using the imported useAvailablePhoneNumbers
hook and add the PhoneNumberDropdown
component to the UI above the existing update button:
The edit view also has an onSubmit
handler:
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
.
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.
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.
- 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.
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.
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.
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.
Copy the URL at the top of the Connection Created prompt on the screen that follows.
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.
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.
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
.
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.
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.
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:
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.
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.
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):
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.
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.
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):
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:
Go to the Connections section of the Hookdeck Dashboard. The connection's visual representation will now show the localhost
CLI Destination.
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:
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:
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.
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:
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.
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:
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:
Import and use the SDK verifyWebhookSignature
helper function within app/webhooks/vote/route.ts
to verify the signature.
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:
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.
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:
Next, create a Supabase admin client to query the profile
table and find the user who sent the SMS vote.
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
:
Include the createClient
function from the Supabase SDK, createSigner
from Fast JWT, and create a utility function called createSupabaseToken
:
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:
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:
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:
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:
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:
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.
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 :
Import and initialize the Twilio SDK at the top of route.ts
:
Finally, send the text message to the user:
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.
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.
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.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.