tutorial // Oct 15, 2021

How to Securely Handle Stripe Webhooks

How to receive and parse Stripe webhooks, validate their contents, and use their data in your application.

How to Securely Handle Stripe Webhooks

Getting Started

For this tutorial, we're going to use the CheatCode Node.js Boilerplate as a starting point for our work. To start, let's clone a copy from Github:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate

Next, cd into the project and install its dependencies:

Terminal

cd nodejs-server-boilerplate && npm install

Next, we need to add one more dependency stripe which will help us to parse and authenticate the webhooks we receive from Stripe:

Terminal

npm i stripe

Finally, go ahead and start up the development server:

Terminal

npm run dev

With that, we're ready to get started.

Obtaining a secret key and webhook signing secret

Before we dig into the code, the first thing we need to do is get access to two things: our Stripe Secret Key and our Webhook Signing Secret.

To obtain these, you will need to have an existing Stripe account. If you don't have one already, you can sign up here. After you have access to the Stripe dashboard, you can continue with the steps below.

O66I0fD25fe8zKhG/qIARNw0m7H7jbEhc.0
View your API keys in the Stripe dashboard.

Once you're logged in, to locate your Secret Key:

  1. First, in the top-right corner, make sure that you've toggled the "Test mode" toggle so that it's lit up (as of writing this will turn orange when activated).
  2. To the left of that toggle, click the "Developers" button.
  3. On the next page, in the left-hand navigation menu, select the "API keys" tab.
  4. Under the "Standard keys" block on this page, locate your "Secret key" and click the "Reveal test key" button.
  5. Copy this key (keep it safe as this is used to perform transactions with your Stripe account).

Next, once we have our secret key, we need to open up the project we just cloned and navigate to the /settings-development.json file:

/settings-development.json

const settings = {
  "authentication": { ... },
  "databases": { ... },
  "smtp": { ... },
  "stripe": {
    "secretKey": "<Paste your secret key here>"
  },
  "support": { ... },
  "urls": { ... }
};

export default settings;

In this file, alphabetically near the bottom of the exported settings object, we want to add a new property stripe and set it to an object with a single property: secretKey. For the value of this property, we want to paste in the secret key you copied from the Stripe dashboard above. Paste it in and then save this file.

In the boilerplate we cloned above, there's a built-in mechanism for loading our settings based on the value of process.env.NODE_ENV (e.g., development, staging, or production). Here, we assume we're developing on our local machine and add our keys to the settings-development.json file. If we were deploying to production, we would make this change in our settings-production.json file, using our live secret key as opposed to our test secret key like we see above.

Next, we need to obtain one more value: our webhook signing secret. To do this, we need to create a new endpoint. From the same "Developers" tab in the Stripe dashboard, from the left-hand navigation (where you clicked on "API keys"), locate the "Webhooks" option.

On this page, you will either see a prompt to create your first webhook endpoint, or, the option to add another endpoint. Click the option to "Add endpoint" to reveal the webhook configuration screen.

In the window that reveals itself, we want to customize the "Endpoint URL" field and then select the events we want to listen to from Stripe.

In the URL field, we want to use the domain name where our app is running. For example, if we were in production, we might do something like https://cheatcode.co/webhooks/stripe. For our example, because we anticipate our app running on localhost, we need a URL that points back to our machine.

s66P9PqoM5JAD7Wr/46KTRT1WeQLK5rH3.0
Ngrok is recommended for testing your webhooks locally.

For this, the tool Ngrok is highly recommended. It's a free service (with paid options for additional features) that allows you to create a tunnel back to your computer via the internet. For our demo, the https://tunnel.cheatcode.co/webhooks/stripe endpoint we're using is pointing back to our localhost via Ngrok (free plans get a domain at <randomId>.ngrok.io, but paid plans can use a custom domain like the tunnel.cheatcode.co one we're using here).

The important part here is the part after the domain: /webhooks/stripe. This is the route that's defined within our application where we expect webhooks to be sent.

Next, just below this, we want to click the "Select events" button under the "Select events to listen to" header. In this next window, Stripe gives us the option to customize which events it will send to our endpoint. By default, they will send events of all types, but it's recommended that you customize this for the needs of your application.

For our demo, we're going to add two event types: invoice.payment_succeeded (sent whenever we successfuly receive a payment from a customer) and invoice.payment_failed (sent whenever a payment from a customer fails).

Once you've added these—or whatever events you prefer—click the "Add endpoint" button.

s66P9PqoM5JAD7Wr/9SscGgnSgC1Xwj3y.0
Obtaining your Signing secret from the Stripe dashboard.

Finally, to get your Webhook Signing Secret, from the page shown after creating your endpoint, in the row beneath the URL, locate the "Signing secret" box and click the "Reveal" link inside of it. Copy the secret that's revealed.

/settings-development.json

...
  "stripe": {
    "secretKey": "",
    "webhookSecret": "<Paste your secret here>"
  },
  ...
}

Back in your /settings-development.json file, under the stripe object that we added earlier, add an additional property webhookSecret and set the value to the secret you just copied from the Stripe dashboard.

Adding middleware to parse the webhook request

Now we're ready to get into the code. First, in order to ensure we properly receive webhooks from Stripe, we need to make sure we're handling the request body we'll receive from Stripe properly.

Inside of the project we cloned above, we'll want to navigate to the /middleware/bodyParser.js file:

/middleware/bodyParser.js

import bodyParser from "body-parser";

export default (req, res, next) => {
  const contentType = req.headers["content-type"];

  if (req.headers["stripe-signature"]) {
    return bodyParser.raw({ type: "*/*", limit: "50mb" })(req, res, next);
  }
  
  if (contentType && contentType === "application/x-www-form-urlencoded") {
    return bodyParser.urlencoded({ extended: true })(req, res, next);
  }

  return bodyParser.json()(req, res, next);
};

In this file, we'll find the existing body parser middleware for the boilerplate. In here, you will find a series of conditional statements which change how the request body should be parsed depending on the origin of the request and its specified Content-Type header (this is the mechanism used in an HTTP request to designate the format of the data in the body field on a request).

Generally speaking, request body's will typically be sent as either JSON data or as URL form encoded data. These two types are already handled in our middleware.

In order to handle requests from Stripe properly, we need to support a raw HTTP body (this is the unparsed HTTP request body, usually plain text or binary data). We need this for Stripe as this is what they expect from within their own webhook validator function (what we'll look at later).

In the code above, we add an additional if statement to check for an HTTP header stripe-signature on all inbound requests to our app. The function exported above is called to via the /middleware/index.js file which is itself called before an inbound request is handed off to our routes in /index.js for resolution.

If we see the HTTP header stripe-signature, we know that we're receiving an inbound request from Stripe (a webhook) and that we want to ensure that the body for that request remains in its raw state. To do it, we call to the .raw() method on the bodyParser object imported at the top of our file (a library that offers a collection of format-specific functions for formatting HTTP request body data).

To it, we pass an options object saying that we want to allow any */* data type and set the request body size limit to 50mb. This ensures that a payload of any size can get through without triggering any errors (feel free to play with this according to your own needs).

Finally, because we expect the .raw() method to return a function, we immediately call that function, passing in the req, res, and next arguments passed to us via Express when it calls our middleware.

With this, we're ready to dig into the actual handlers for our webhooks. First, we need to add the /webhooks/stripe endpoint we alluded to earlier when adding our endpoint on the Stripe dashboard.

Adding an Express endpoint for receiving webhooks

This one is quick. Recall that earlier, in the Stripe dashboard, we assigned our endpoint to http://tunnel.cheatcode.co/webhooks/stripe. Now, we need to add that /webhooks/stripe route in our application and wire it up to the handler code that will parse and receive our webhooks.

/api/index.js

import graphql from "./graphql/server";
import webhooks from "./webhooks";

export default (app) => {
  graphql(app);
  app.post("/webhooks/:service", webhooks);
};

Above, the function we're exporting is called via our /index.js file after the middleware() function. This function is designed to set up the API or routes for our application. By default, in this boilerplate, our API is based on GraphQL. The graphql() function call we see here is irrelevant, but the app argument it's receiving is important.

This is the Express app instance created in our /index.js file. Here, we want to call to the .post() method on that app instance to tell Express we'd like to define a route which receives an HTTP POST request (what we expect to get from Stripe). Here, to keep our code open-ended and applicable to Stripe as well as other services, we define our route's URL as /webhooks/:service where :service is a param that can be swapped with the name of any service (e.g., /webhooks/stripe or /webhooks/facebook).

Next, we want to take a look at the function stored in the webhooks variable we're importing at the top of the file and passing as the second argument to our route.

Adding a webhook handler

The real meat of our implementation will be the handler function we're going to write now. This is where we'll accomplish two things:

  1. Validating the webhook payload that we receive from Stripe (to ensure the data we're receiving is actually from Stripe).
  2. Locating and calling the appropriate code (a function) based on the type of webhook (for our example, either invoice.payment_succeeded or invoice.payment_failed).

To start, we're going to write the validation code using the stripe package that we installed earlier:

/api/webhooks/index.js

import _ from "lodash";
import settings from "../../lib/settings";
import { stripe } from "./stripe";

const handlers = {
  stripe(request) {
    // We'll implement our validation here.
  },
};

export default async (req, res, next) => {
  const handler = handlers[req.params.service];

  if (handler) {
    res.status(200).send("[200] Webhook received.");
    handler(req);
  } else {
    res.status(200).send("[200] Webhook received.");
  }
};

In our previous step, we set up an Express route, passing it a variable webhooks, a function, as the second argument that's called when a request is made to the URL you define, in this case /webhooks/stripe.

In the code above, we're exporting a function which takes in three arguments: req, res, and next. We're anticipating these specific arguments as these are what Express will pass to the callback function for a route (in this case, that callback function is the function we're exporting here and importing back in /api/index.js as webhooks).

Inside of that function, we need to confirm that the service we're receiving a request for stripe has a corresponding handler function to support it. This is so that we don't receive random requests from the internet (e.g. someone spamming /webhooks/hotdog or /webhooks/pizzahut).

To verify that we _have _ a handler function, above our exported function we've defined an object handlers and have defined Stripe as a function on that object (a function defined on an object is referred to as a method in JavaScript).

For that method, we expect to take in the HTTP request object passed to our route. Back down in our exported function—the route callback—we determine which handler to call based on the req.params.service value. Remember, the :service in our URL can be anything, so we need to make sure it exists first before calling it. To do that, we use JavaScript bracket notation to say "on the handlers object, try and find a property with a name equal to the value of req.params.service."

For our example, we'd expect handlers.stripe to be defined. If that handler exists, we want to signal back to the original request that the webhook was received and then call that handler() function, passing in the req that we want to handle.

/api/webhooks/index.js

import _ from "lodash";
import settings from "../../lib/settings";
import { webhooks as stripeWebhooks, stripe } from "./stripe";

const handlers = {
  stripe(request) {
    const data = stripe.webhooks.constructEvent(
      request.body,
      request.headers["stripe-signature"],
      settings.stripe.webhookSecret
    );

    if (!data) return null;

    const handler = stripeWebhooks[data.type];

    if (handler && typeof handler === "function") {
      return handler(data?.data?.object);
    }

    return `${data.type} is not supported.`;
  },
};

export default async (req, res, next) => {
  const handler = handlers[req.params.service];
  if (handler) {
    res.status(200).send("[200] Webhook received.");
    handler(req);
  } else {
    res.status(200).send("[200] Webhook received.");
  }
};

Filling out our stripe() handler function, before we do anything with the webhook we've received from Stripe, we want to ensure that the webhook we're receiving is actually from Stripe and not somebody trying to send us suspicious data.

To do that, Stripe gives us a handy function in its Node.js library—the stripe package we installed at the beginning of the tutorial—for performing this task: stripe.webhooks.constructEvent().

Here, we're importing an instance of stripe from the file /stripe/index.js located inside of our existing /api/webhooks folder (we'll set this up in the next section, so for now we're assuming its existence).

We expect that instance to be an object containing the .webhooks.constructEvent() function that we're calling to here. That function expects three arguments:

  1. The request.body that we received in the HTTP POST request from Stripe.
  2. The stripe-signature header from the HTTP POST request we received from Stripe.
  3. Our webhookSecret that we set up and added to our /settings-development.json file earlier.

The first two arguments are immediately available to us via the HTTP request (or req as we've referenced it elsewhere) object we've received from Stripe. For the webhookSecret, we've imported our settings file as settings at the top of our file, leveraging the built-in settings loader function in /lib/settings.js to pick out the correct settings for us based on our current environment (based on the value of process.env.NODE_ENV, for example, development or production).

Inside of constructEvent(), Stripe attempts to compare the stripe-signature header with a hashed copy of the received request.body. The idea here is that, if this request is valid, the signature stored in stripe-signature will be equal to the hashed version of the request.body using our webhookSecret (only possible if we're using a valid webhookSecret and receiving a legitimate request from Stripe).

If they do match, we expect the data variable we're assigning our .constructEvent() call to to contain the webhook we received from Stripe. If our validation fails, we expect this to be empty.

If it is empty, we return null from our stripe() function (this is purely symbolic as we don't expect a return value from our function).

Assuming that we did successfully receive some data, next, we want to try and find the webhook handler for the specific type of event we're receiving from Stripe. Here, we expect this to be available in the type property on the data object.

At the top of our file, we're also assuming that our /stripe/index.js file here in /api/webhooks will contain an exported value webhooks which we've renamed as stripeWebhooks when importing it up top (again, we haven't created this yet—we're just assuming it exists).

On that object, as we'll see in the next section, we expect a property matching the name of the webhook type we've received (e.g., invoice.payment_succeeded or invoice.payment_failed).

If it does exist, we expect it to return a function to us that itself expects to receive the data contained in our webhook. Assuming that it does, we call that handler() function, passing in data.data.object—here, using JavaScript optional chaining to ensure that object exists on the data object above it, which exists on the data object we stored the parsed and validated request body from Stripe.

To wrap up, let's take a look at this /api/webhooks/stripe/index.js file we've been dancing around.

Adding functions to handle specific webhook events

Now, let's see how we intend to get access to the instance of Stripe we alluded to above and handle each of our webhooks:

/api/webhooks/stripe/index.js

import Stripe from "stripe";
import settings from "../../../lib/settings";

import invoicePaymentSucceeded from "./invoice.payment_succeeded";
import invoicePaymentFailed from "./invoice.payment_failed";

export const webhooks = {
  "invoice.payment_succeeded": invoicePaymentSucceeded,
  "invoice.payment_failed": invoicePaymentFailed,
};

export const stripe = Stripe(settings.stripe.secretKey);

Focusing on the bottom of our file, here we can see the stripe value where we called the stripe.webhooks.constructEvent() being intialized. Here, we take the Stripe function imported from the stripe package we installed at the start of the tutorial being called, passing in the secretKey we took from the Stripe dashboard and added to our /settings-development.json file earlier.

Above this, we can see the webhooks object we imported and renamed as stripeWebhooks back in /api/webhooks/index.js. On it, we have the two event types we'd like to support invoice.payment_succeeded and invoice.payment_failed defined, for each passing a function with a name corresponding to the code we want to run when we receive those specific types of events.

For now, each of those functions are limited to exporting a function which console.log()s the webhook we've received from Stripe. This is where we'd want to take the webhook and make a change to our database, create a copy of the invoice we've received, or trigger some other functionality in our app.

/api/webhooks/stripe/invoice.payment_succeeded.js

export default (webhook) => {
  console.log(webhook);
};

That's it! Now, let's spin up a tunnel via the Ngrok tool we hinted at earlier and receive a test webhook from Stripe.

Wrapping up

In this tutorial, we learned how to set up a webhook endpoint on Stripe, obtain a webhook secret, and then securely validate a webhook using the stripe.webhooks.constructEvent() function. To get there, we set up an HTTP POST route in Express and wired up a series of function to help us organize our webhook handlers based on the type of event received from Stripe.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode