How to Validate Twilio Function Inputs with Zod

September 20, 2024
Written by
Tristan Blackwell
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Validate Twilio Function Inputs with Zod

One of key concepts when creating an API, as you may do with Twilio Serverless Functions, is validating your inputs. Not only will garbage in, result in garbage out but ensuring incoming data has been cleansed is vital for security and proper operation

Zod, a powerful data validation library available via NPM is designed exactly for this use case. It aims to make defining, and managing data structures much easier than would otherwise be possible with vanilla JavaScript or TypeScript.

This article will detail how the Zod NPM package can be leveraged in Twilio Serverless Functions to parse, validate, and transform input data ready for handling in your core business logic.

Prerequisites

Creating a Simple SMS Function

To get started, create a new Twilio Functions Service that will contain our custom code. From the Twilio Console, use the left-hand menu to find the Functions & Assets section (You may need to use the Explore Products option to find this).

Under Services, click the blue Create Service button and name your service twilio-zod. Once created, the editor will open for you. There won’t be any functions or assets just yet but this will soon change!

Via the blue Add + button at the top of the editor, create a new function. This function will send an SMS to a number provided so give it a descriptive name such as send-sms.

By default, this function will be protected, meaning only incoming requests with a valid X-Twilio-Signature header can execute the code. This is great for overall security but for now, click on the small lock icon and make this a public function. This means anyone with the URL can now execute your function, not great for security, however this will make it easier for you to test shortly. In development this is usually fine but it is worth considering the various levels as you move towards production level code. The visibility you choose for functions & assets will largely depend on how you expect the function to be called in your system, and choosing the strictest access policy is best practice.

The function will have some boilerplate code to get started. Replace the content with the code below which will set up our function to send an SMS. The code comments describe what takes place:

exports.handler = function(context, event, callback) {
  // The pre-initialized Twilio client is available from the `context` object
  const client = context.getTwilioClient();
  // Query parameters or values sent in a POST body can be accessed from `event`
  const from = event.From;
  const to = event.To ;
  const body = event.Body || 'Hello world!';
  // Use `messages.create` to generate a one-off SMS message.
  client.messages
    .create({ body, to, from })
    .then((message) => {
      console.log('SMS successfully sent');
      console.log(message.sid);
      return callback(null, `Success! Message SID: ${message.sid}`);
    })
    .catch((error) => {
      // If an error is detected, log this and return a 500 response.
      console.error(error);
      return callback(error);
    });
};

Now test and make sure everything is functional up to this point. Save the code with the Save button and use the blue Deploy All button in the button level to prepare your code ready to use. This will take a few seconds so whilst waiting you can prepare to send your first request.

Click on the 3 vertical gray dots next to the function name and then click Copy URL to copy the URL of the new function. This is the publicly accessible address where a request can be sent to dispatch an SMS. This can be tested with a tool like Postman. In the Postman web app or desktop application, Create a new HTTP request, set the URL to the one which you copied and make this a POST method. Under the Body tab, select the raw option and provide a JSON block with the variables your function needs. These will be a From number, this should be your SMS enabled Twilio phone number, a To number which can be your own mobile number, and a message Body containing the text to send:

{
  "From": "+1234567890",
  "To": "+1234567890",
  "Body":"Ahoy there!"
}

The request on Postman should look similar to the following:

You may need to add your mobile number as a verified caller ID if you are using a trial account. The number inputs should all use the E.164 format.

Go ahead and press Send. You should see ‘Success…’ appear in the response section and an SMS delivered to the phone number you provided. Great stuff you're on a roll, let's continue!

Adding Validation

Our SMS function can take in From, To, and Body parameters. This is visible in the code from the Event object in lines 5-7. What isn’t included however is any form of validation. You can see this if we send the request with the From property removed. Instead of the 200 success response, in Postman we’ll instead see a 500 error code and an error message. Although you know this parameter should be set and how it should be formatted, others using the endpoint might not. We cannot guarantee that all parameters are always present or in the correct format.

It would be possible to add a null/undefined check to the code but this has a few drawbacks. This would need to be done for every event variable, which is rather tedious and prone to someone forgetting if the code is extended in the future, the input is only then asserted as not empty, and further conditionals will be needed for any other checks e.g. length, format, and you’ll find yourself repeating the same large blocks of code each function you make.

This is where Zod comes in, Zod helpers can be leveraged to encapsulate this type of code into readable chunks that are easy to define and extend whilst giving a lot of power in how inputs are validated.

Including the Zod Dependency

Back in the editor, navigate to the Dependencies section under Settings & More. There will be some default dependencies already defined, and using the module input, Zod can be added also. For the Module type zod and Version use 3.23.8, click Add. Zod will now be accessible in our function handlers.

Using Zod

The SMS function can now be extended with the code below. Copy and replace the existing code in the /send-sms Function with the following:

const z = require("zod");
const eventSchema = z.object({
  From: z.string(),
  To: z.string(),
  Body: z.string().optional(),
})
exports.handler = function(context, event, callback) {
  // The pre-initialized Twilio client is available from the `context` object
  const client = context.getTwilioClient();
  // Use Zod's `safeParse()` function to compare our incoming event properties
  // against our schema defined above.
  const parsedEvent = eventSchema.safeParse(event);
  if (!parsedEvent.success) {
    console.error("Invalid input", parsedEvent.error);
    return callback(null, new Twilio.Response().setStatusCode(400).setBody("Invalid input"))
  }
  const from = parsedEvent.data.From;
  const to = parsedEvent.data.To;
  const body = parsedEvent.data.Body || 'Hello world!';
  // Use `messages.create` to generate a one-off SMS message.
  client.messages
    .create({ body, to, from })
    .then((message) => {
      console.log('SMS successfully sent');
      console.log(message.sid);
      return callback(null, `Success! Message SID: ${message.sid}`);
    })
    .catch((error) => {
      console.error(error);
      return callback(error);
    });
};

The zod package is required at the top of the function, this package exposes a helpful z property containing all the utilities needed. Our schema is then defined using z.object({...}). This is used to describe the expected incoming event and is currently pretty simple, with 3 string properties. Notice how the .optional() tag has been added to the Body property since a default is declared for this below on line 24.

The remainder of the code is largely the same, the key area to note is the usage of Zod’s .safeParse() helper to parse our actual event (which was used directly before) to ensure it adheres to the defined schema. If it does not, a 400 error is returned informing the caller of bad input data.

Click Save and Deploy All to redeploy the application with the new changes. Navigate back to Postman and send another POST request with the same body from the previous section. With all the expected parameters defined there should be no visible change in behavior externally. Try removing the From property however and what you’ll see instead is a 400 response with the error message Invalid input.

That is solid progress. We have now clearly indicated to the caller, via the 400 status code, that the incoming request is malformed and our message sent back indicates this too.

Further Restricting Inputs

The validation logic enforces that the properties described in the schema exist and are not undefined or null, increasing the confidence that data is present during the subsequent code execution. If further properties were required in this function, the schema could be extended in a single location and they would be accessible on the parsedEvent variable.

This only scratches the surface of possible Zod validation. The schema can be extended a little further for this use case to demonstrate some additional helpers. Copy and replace the existing code in the /send-sms Function with the following:

const z = require("zod");
const eventSchema = z.object({
  From: z.string({ required_error: "A From number is required" }).startsWith("+", { message: "From number must be E.164 formatted"}),
  To: z.string({ required_error: "A To number is required" }).startsWith("+", { message: "To number must be E.164 formatted"}),
  Body: z.string().optional(),
})
exports.handler = function(context, event, callback) {
  // The pre-initialized Twilio client is available from the `context` object
  const client = context.getTwilioClient();
  // Use Zod's `safeParse()` function to compare our incoming event properties
  // against our schema defined above.
  const parsedEvent = eventSchema.safeParse(event);
  if (!parsedEvent.success) {
    console.error("Invalid event: ", parsedEvent.error);
    return callback(null, new Twilio.Response().setStatusCode(400).setBody(parsedEvent.error.toString()))
  }
  const from = parsedEvent.data.From;
  const to = parsedEvent.data.To;
  const body = parsedEvent.data.Body || 'Hello world!';
  // Use `messages.create` to generate a one-off SMS message.
  client.messages
    .create({ body, to, from })
    .then((message) => {
      console.log('SMS successfully sent');
      console.log(message.sid);
      return callback(null, `Success! Message SID: ${message.sid}`);
    })
    .catch((error) => {
      console.error(error);
      return callback(error);
    });
};

The first change made is the extended schema definition. The phone number fields, From and To have been extended to ensure they start with a +, providing a strong indication that it is in fact a phone number being provided. Messages have also been added which are shown as the errors if the event that the defined conditions are not met. This becomes helpful when returning 400 errors. Instead of naively returning invalid data as before, the response text is set to the validation error which will be the message defined in the schema, meaning that the caller now knows exactly the issue faced making it easier to correct on their side.

Save & redeploy once more. This time if you send a request without the From field you will receive a “ A ‘From’ number is required” message. Add this field back in but this time remove the + and now the message will be “ From number must be E.164 formatted”.

Next Steps

From just this simple example it is clear to see how powerful a tool like Zod can be for input validation without bloating code excessively with a large number of checks & conditionals. The Zod documentation has an exhaustive list of helpers for various types such as strings, boolean, or numbers.

A good challenge would be enhancing the E.164 phone number validation. Starting with a + is a good start but it’s possible to be stricter on the inputs allowed, a regex might be a good fit. You could also try instead accepting local phone numbers. If you know valid inputs then you could use a transform to convert this to an E.164 number ready to pass on to the SMS API without adding any additional logic to your main function handler.

Twilio Zod Package

For many common Twilio use cases, the Twilio Zod NPM package provides additional utilities building upon what Zod already offers. If you commonly need to validate Twilio SID’s, parse JSON attributes on Calls, Conversations, Tasks, or gracefully handle errors from Twilio it can be a helpful addition.

Conclusion

Great job! Your Twilio Functions now have a validation layer that you can adjust to fit exactly what each function requires and is easily extendable going forward.

With validation included upfront in your function executions you can have a higher degree of confidence in the data you are consuming which can help reduce errors and increase security. Be sure to check out the Twilio Serverless Functions documentation, the Serverless Toolkit for moving your development to a local machine to iterate even faster, as well as the Zod Documentation for almost any validation use case you can imagine.

Tristan Blackwell is a Twilio Champion & Software Developer at Zing.dev, a Twilio Preferred Partner. He is a ‘tinkerer’, exploring what is possible through technical experimentation which he often shares on LinkedIn. In his spare time, he is usually out running.