Skip to content

Instantly share code, notes, and snippets.

@tmcw
Created December 21, 2021 14:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tmcw/01cf6e03b5ede01d4fc075053960aec9 to your computer and use it in GitHub Desktop.
Save tmcw/01cf6e03b5ede01d4fc075053960aec9 to your computer and use it in GitHub Desktop.
Stripe webhooks in development

Micro-devlog for something tiny.

Placemark uses Stripe, and uses their webhooks. You can use Stripe without webhooks, but it's better to build with the webhooks.

I'm developing the account system actively, so I want the webhooks to work in local development. Thankfully, the stripe cli supports proxying webhooks from your development Stripe environment to your local setup. You run

$ stripe listen --forward-to localhost:5000/stripe_webhooks

Which emits a message like

⣾ Getting ready... > Ready! You are using Stripe API Version [2020-08-27]. Your webhook signing secret is whsec_XXE2bDB… (^C to quit

And then you set that to your webhook signing secret, and go.


Here's the challenge: how? As far as I can tell:

  • The webhook signing secret changes. You can't just run stripe-cli once, copy it, and use the same signing secret.
  • stripe-cli obviously needs to keep running for the webhooks to forward.

So, ideally stripe-cli runs, generates an environment variable, and then my application starts up with that appliication variable. This ends up being pretty tricky!

  • I don't think Procfiles would work because they don't support this sort of 'ordering', plus you have to parse the stderr from stripe-cli to get the webhook secret anyway.
  • There's probably some Docker-ecosystem solution to this but I don't use Docker.
  • I thought about running stripe-cli from my application as a subprocess, but Placemark is built on a Next.js-flavored stack, so it's not simple to add code that runs on startup (there are many discussions about this)

Here's what I ended up with:

/* eslint-disable @typescript-eslint/no-var-requires */
const { spawn } = require("child_process");
const fs = require("fs");

const proxy = spawn("stripe", [
  "listen",
  "--forward-to",
  "http://localhost:3000/api/webhook",
]);

proxy.stderr.pipe(process.stderr);
proxy.stdout.pipe(process.stdout);

let done = false;
new Promise((resolve) => {
  function onData(data) {
    if (done) return;
    const output = data.toString();
    const secret = output.match(/(whsec_[a-zA-Z0-9]+)/);
    if (secret) {
      console.log(`Got development only webhook secret ${secret[1]}`);
      const local = fs.readFileSync("./.env.local", "utf8");
      const map = Object.fromEntries(
        local.split(/\n/g).map((line) => line.split("=", 2))
      );

      map.STRIPE_WEBHOOK_SECRET = secret[1];

      fs.writeFileSync(
        "./.env.local",
        Object.entries(map)
          .filter(([k, v]) => k && v)
          .map(([k, v]) => {
            return `${k}=${v}`;
          })
          .join("\n")
      );
      done = true;
      resolve();
    }
  }
  proxy.stderr.on("data", onData);
}).then(() => {
  const blitz = spawn("blitz", ["dev"]);
  blitz.stderr.pipe(process.stderr);
  blitz.stdout.pipe(process.stdout);
});

So my dev script in package.json is this script, instead of blitz dev. This script runs stripe-cli, extracts the webhook signing secret out of it, edits .env.local to include that secret, and then starts up blitz, which reads from .env.local.

This seems like a Rube-Goldberg solution, but it also seems like a common problem that surely couldn't be this complicated! Perhaps this really is the thing, and most people don't test webhooks locally, or use some other solution? Sound off in the comments!

@trey
Copy link

trey commented Dec 21, 2021

This may not be exactly the same because I'm using Paddle.

I've been using ngrok.

I run something like ngrok http 8000 and then put the URL it outputs (appended with my app's webhook path) into the sandbox settings on Paddle.com.

It's a little tedious to have to do that every time (since I'm not paying for ngrok, so I don't get a static URL), but it does the job for testing locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment