DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two URLs answered our Stripe webhook, and which one ran was a setting in somebody else's dashboard

Run these two and compare:

curl -s -X POST https://pub-trivia.app/webhook/stripe     -d '{}'
curl -s -X POST https://pub-trivia.app/api/webhook/stripe -d '{}'
Enter fullscreen mode Exit fullscreen mode
{"error":"Missing stripe-signature header"}
{"error":"Missing stripe-signature header"}
Enter fullscreen mode Exit fullscreen mode

Identical, 400 both times, which is the point: there are two live URLs and one implementation behind them. For a while there were two implementations, and that is the story worth telling.

Config that lives in a vendor's dashboard is config you cannot grep

Both paths existed because at some point both were written. /webhook/stripe first, /api/webhook/stripe later when the API routes got tidied into one place, and nobody deleted the first one.

Two route handlers, both subscribed to the same subscription lifecycle events, and both perfectly functional. The question "which one runs in production" had an answer, and the answer was not in the repository. It was a text field in the Stripe dashboard. Whichever URL happened to be configured there was the one that handled real payments, and you could not determine that by reading the code, running the tests, or inspecting the deploy.

It got better. The local development script forwarded to one of them:

"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhook/stripe"
Enter fullscreen mode Exit fullscreen mode

It used to name the other path. So every subscription change anybody tested locally exercised a handler that may not have been the one serving production, and both appeared to work, because both did work. They just did not do the same thing.

They had drifted, which is the predictable part

Two implementations of one contract do not stay equal. They do not even drift noisily: each change is made to whichever file the person was looking at, and both keep returning 200.

Ours had diverged in the way you would expect. One resolved the customer's plan by reading product metadata off the expanded subscription; the other made an extra API call to fetch the price. One wrote more of our own tables than the other. And one mapped Stripe's past_due status onto our internal active, which is a small line with an opinion in it: a subscription whose payment has failed is either still entitled to the service or it is not, and the two handlers answered that differently depending on a dashboard setting nobody had looked at in months.

A webhook handler is an unusually bad place for this kind of split, because it is the one part of a billing integration that no user interaction will ever exercise. Nobody files a bug saying "the wrong webhook ran." The symptom is an account whose state is subtly wrong, weeks later, with no trail.

The fix is not a deletion

The obvious move is to delete the old route. You cannot, at least not first.

A webhook URL is a contract with somebody else's system. If an endpoint is still registered in the Stripe dashboard and the path starts returning 404, the events do not stop: Stripe retries them, counts the failures, and eventually disables the endpoint, and in the meantime every subscription change arrives at a route that throws it away. Deleting the file is a change whose blast radius is in a system you do not deploy.

So the legacy path became a re-export:

export { POST } from '@/app/api/webhook/stripe/route'

export const dynamic = 'force-dynamic'
export const maxDuration = 30
Enter fullscreen mode Exit fullscreen mode

Four lines under a comment explaining why the file still exists. Both URLs are now the same function, the divergence is gone by construction rather than by discipline, and the file is deletable the day the dashboard stops pointing at it. It also leaves a note for whoever finds it later, which a deleted file cannot do.

The two lines under the export are the interesting bit

The part I got wrong the first time: route segment config does not come along with the handler.

Next.js reads dynamic, maxDuration, revalidate and friends by statically analysing the route file at build time. It is not runtime metadata attached to the function you exported, so export { POST } from '...' brings the handler and none of its configuration. The re-exporting route silently gets the defaults.

For this route, both defaults are wrong in ways that would be hard to diagnose. Without force-dynamic the route is a candidate for static optimisation, and a cached webhook endpoint is a webhook endpoint that stops writing to your database. Without maxDuration = 30 the handler gets the platform default, which for a handler doing several database writes behind a couple of Stripe API calls is a tail of events that time out under load and come back as retries.

So they are declared again, with a comment saying they mirror the canonical route, because the alternative is a reader assuming the re-export carried them.

// Route segment config is parsed statically from the source file, so these have
// to be declared here rather than re-exported. They mirror the canonical route.
Enter fullscreen mode Exit fullscreen mode

The general version: anything a framework reads by looking at your source rather than by calling your code will not survive being re-exported. Metadata exports, route config, and the various generate* functions all behave this way.

What I would do differently

Not "do not create two URLs", because nobody creates two URLs on purpose. The useful rules out of this:

Keep the handler in a module and the routes thin. If every route file is a couple of lines over a function in lib/, a second URL is a feature rather than a hazard.

Point the development forwarding script at the production path. stripe listen --forward-to is the only place a repository states which path it expects to receive events on, so it is worth treating it as documentation and keeping it correct.

Treat a webhook URL change as a migration with two live endpoints. Add the new path, make the old one the same code, repoint the dashboard, confirm deliveries, then delete. The order matters, and only the last step is reversible by you alone.

Write down the reason a dead file is still there. A route that exists purely as an alias looks like a mistake. Six lines of comment is the difference between that and a decision.

If you want to see the paying side of this rather than the plumbing, the plans are at pub-trivia.app/pricing, the prices are served from /api/pricing if you would rather read JSON, and the app has a free tier that needs no card at pub-trivia.app.

Top comments (0)