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!
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.