>Blog
Pixelated image of Max Karlsson

Verify Stripe webhooks in Remix on Cloudflare Pages

November 15, 2022

DJ deck

Remix

Stripe

Cloudflare

Remix.run is a great framework that just gets out of your way and one of the things I really love about Remix is that it allows you to choose from a lot of different hosting providers. I'm a big fan of what they're doing at Cloudflare so I decided to deploy to Cloudflare Pages.

That decision has at times made me question my life choices πŸ˜… the lep from Node.js to Workers has been a big one. The main problem I've faced has been finding solutions to my issues. Community usage of Cloudflare Pages is well below that of fly.io for Remix sites. Fly is a Node runtime whereas Cloudflare Pages is a Worker runtime.

One of the things I struggled and scratched my head about was how to verify a Stripe webhook. I found a few posts about how to make it work with Cloudflare Workers. So that's the first step. But then I ran into some more errors that I couldn't quite solve the normal way. So I thought I'd gather it in one place in case anyone else runs into these same issues.

My solution is a bit roundabout, I'll first show you the wrong way to do things and after that, I'll show you the right way to solve it.

Init Stripe library

First off, to initialise the Stripe library in Remix on Cloudflare Pages, you'll have to do some things a bit differently. Since environment variables aren't picked up globally like they'd be in Node.js on the good old process.env object, you'll have to use the context object that's passed into every loader and action function.

Start by adding your Stripe secrets to the .dev.vars file.

PUBLIC_STRIPE_KEY=<your-stripe-public-key>
PRIVATE_STRIPE_SECRET=<your-stripe-secret>

These variables will come in on the context object like this: context.PUBLIC_STRIPE_KEY and context.PRIVATE_STRIPE_SECRET. You will need to pass these into a function that initialises Stripe rather than the normal of just reading the environment variables directly.

Create a stripe.ts file

Create a folder called lib and add a new file there called stripe.ts (or .js if you're not on the TS train). In there you will need 2 different functions, one to initialise Stripe on the server and the other one for the client. In this walkthrough I'll only show you the server version, the client one is more or less the same but without the httpClient property.

import Stripe from "stripe";

export function initServerStripe({ stripeSecret }: { stripeSecret: string }) {
  return new Stripe(stripeSecret, {
    apiVersion: "2022-08-01",
    httpClient: Stripe.createFetchHttpClient(), // <-- important for Worker support
  });
}

Create the action function

To handle webhook events you need to create an action function. I placed mine in a folder inside the routes directory and called it api, in that folder, I created another folder called events and finally created a file called payments.ts. That gives the following URL path: /api/events/payments.

A Remix action will accept POST requests so you can use this one to handle webhook events from Stripe. Now initialise Stripe in that action.

import type { ActionArgs } from "@remix-run/cloudflare";

export async function action({ request, context }: ActionArgs) {
  const stripeSecret = context.PRIVATE_STRIPE_SECRET as string;
  const stripe = initServerStripe({ stripeSecret });
  // more code to come
}

Verify the webhook signature

For security reasons you should verify the webhook signature before you handle the event body, otherwise, anyone could easily start messing with your payments system.

This is the part where I take you for a spin and show you the wrong way before I show you the right way. You can skip it if you want, but the reason I even include it is that I kept chasing down that path at first, using the official Stripe lib to verify the webhook. Stripe claims to have support for Workers after all, but it seems they've missed a spot.

So here goes. The "official" way, which should be the right way, but is...

The wrong way

To verify the webhook signature you'll need to override the default crypto lib that Stripe uses. First, go back to the stripe.ts file and add the following export:

export const webCrypto = Stripe.createSubtleCryptoProvider();

Import that export in your payments.ts file. Also, add your webhook secret to the .dev.vars file. I called it PRIVATE_STRIPE_WEBHOOK_SECRET in the example below. Then you can verify the webhook with the following:

export async function action({ request, context }: ActionArgs) {
  const stripeSecret = context.PRIVATE_STRIPE_SECRET as string;
  const webhookSecret = context.PRIVATE_STRIPE_WEBHOOK_SECRET as string;
  // Retrieve the event by verifying the signature using the raw body and secret.
  let event;
  let signature = request.headers.get("stripe-signature") as string;
  const payload = await request.text();
  const stripe = initServerStripe({ stripeSecret });

  try {
    event = await stripe.webhooks.constructEventAsync(
      payload,
      signature,
      webhookSecret,
      undefined,
      webCrypto, // <-- this is where you override the crypto implementation
    );
  } catch (err) {
    return json(null, 400);
  }
  // Extract the object from the event.
  const data = event.data;
  const eventType = event.type;
  // handle the Stripe event data based on type. The rest should be more or less like the official Stripe docs
  // don't forget to return a success response to Stripe so they don't disconnect the webhook.
}

Easy hey? Well, nope. That's when I started getting errors about Buffer not being defined.

Uncaught ReferenceError: Buffer is not defined

Great.

After some digging, it turns out you can add this flag to the dev:wrangler script in package.json: --node-compat and the error goes away for development at least.

But what about production?

Of course, that didn't work for production. So how do you get it working in prod? Well, that's why the above is the wrong way. Adding a `--node-compat` flag to something that isn't a node environment is a bit risky. I also tried using some node polyfills for Remix, but none of them seemed to solve the problem either.

So, it's time to dig into...

The right way

You will need to manually verify the webhook signatures. 😱

It's alright. I'll walk you through it. First, have a read of this section over at the official Stripe docs. Helpful? Somewhat. But it's lacking some code examples. That's what this whole post is about.

First, go back over to your payments.ts file and create an async function called constructEventAsync. We give it the same name as Stripe's internal lib for 2 reasons: 1) we want to show it works more or less the same way, and 2) it makes it easier to just drop the Stripe lib back in if they end up fixing Worker support for this issue.

I'll give you some code here. There's a fair bit going on, so read it carefully. This is something you want to understand before you copy-paste it into your own project.

/**
 * A manual implementation of Stripe's signature verification. Works with Workers.
 * @param payload the request payload (derived from request.text())
 * @param signature the provided webhook signature from the request headers
 * @param webhookSecret the webhook secret provided by Stripe.
 * @returns reconstructed payload
 */
async function constructEventAsync(
  payload: string,
  signature: string,
  webhookSecret: string
) {
  const elements = signature.split(","); // split the signature on the comma (,) to get each element. You then go on to split it on = to get the key value pairs. I used a forEach because I find them more readable than Array.reduce, but if that's more your thing, go for it.
  const parts: Record<string, string> = {}; // you can type this out more strictly if you like. It does the trick though.
  elements.forEach((element: string) => {
    // get the key value pairs and build out the parts object
    const elementParts = element.split("="); 
    parts[elementParts[0]] = elementParts[1];
  });
  // prepare the payload for signing. The payload should be a string of the request payload. It should be prefixed by the provided timestamp and separated by a dot (.).
  const signedPayload = `${parts.t}.${payload}`;
  // now verify the payload.
  const verified = await verifyData(signedPayload, webhookSecret, parts.v1);
  // Calculate how much time has passed. Feel free to change this up to match your needs.
  const elapsed = Math.floor(Date.now() / 1000) - Number(parts.t);
  const tolerance = 300; // tolerance value for how much time you tolerate to have passed since the webhook was signed and sent.
  // if the webhook is verified and within time tolerance parse and return the JSON payload.
  if (verified && !(tolerance && elapsed > tolerance)) {
    const jsonPayload = JSON.parse(payload);
    return jsonPayload;
  }
  // otherwise throw an error
  throw new Error("Couldn't verify signature");
}

/**
 * @param secret webhook secret. used to import the key to sign and verify the webhook signature
 */
async function importKey(secret: string) {
  // You'll use WebCrypto which is supported by Workers, it doesn't even require an import statement.
  return await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, 
    false,
    ["sign", "verify"]
  );
}

/**
 * Verifies the provided signature by signing the payload with the timestamp provided 
 * and comparing the signature to the provided signature.
 * @param signedPayload the payload from Stripe prefixed with the timestamp and `.`
 * @param secret Webhook secret.
 * @param providedSig Webhook signature provided by the caller
 * @returns boolean
 */
async function verifyData(
  signedPayload: string,
  secret: string,
  providedSig: string
) {
  const key = await importKey(secret);
  // the verify function returns a Promise<boolean>
  const verified = await crypto.subtle.verify(
    "HMAC",
    key,
    hexToUint8Array(providedSig), // transform the provided signature to a Uint8 Array
    new TextEncoder().encode(signedPayload) // encode the signed payload
  );
  return verified;
}

/**
 * Transforms a hex into a Uint8Array
 * @param hex a hex string to turn into Uint8Array
 * @returns
 */
function hexToUint8Array(hex: string) {
  const bytes = new Uint8Array(Math.ceil(hex.length / 2));
  for (let i = 0; i < bytes.length; i++)
    bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
  return bytes;
}

Now in your loader function, you just need to change from using the Stripe implementation of constructEventAsync to the one you just created.

import type { ActionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";

export async function action({ request, context }: ActionArgs) {
  const webhookSecret = context.PRIVATE_STRIPE_WEBHOOK_SECRET as string;
  // Retrieve the event by verifying the signature using the raw body and secret.
  let event;
  let signature = request.headers.get("stripe-signature") as string;
  const payload = await request.text();

  try {
    event = await constructEventAsync(payload, signature, webhookSecret);
  } catch (err: any) {
    return json({ error: JSON.stringify(err), message: err.message }, 400);
  }
  // Extract the object from the event.
  const data = event.data;
  const eventType = event.type;

  // handle stripe event
  return json(null, 200);
}

Wrap up

This is where I'd normally do a summary, but since most of the useful stuff here is in code I'll just leave you with a few parting words. Remix is a lot of fun and Cloudflare is awesome, but the community is lagging and so is official support by a lot of big libs. There's very little help online. So I'm committing to improving that as much as I can. When I run into Worker-related issues (both using Remix and Shopify Hydrogen) I'll write about my solution to the issue here. Hopefully, it will help someone else in the future.

Disclaimer

I'm not a security or web crypto expert. Please use this at your own risk and let me know if you find any issues with this solution. Come at me on Twitter (if it still exists) @BeppeKarlsson.

DJ deck

Remix

Stripe

Cloudflare

Recent posts

More from the blog

Β© Copyright 2022 Max Karlsson