Two weeks ago I wrote about our Stripe webhook answering at two URLs, /webhook/stripe and /api/webhook/stripe, with the legacy one kept alive as a re-export of the real handler. One of that post's section headings was "The fix is not a deletion", and the reasoning was that the only thing pointing at a webhook URL is a setting in a vendor's dashboard, which you cannot grep, so you keep the old path alive until you are certain nothing is using it.
Eight days later it was a deletion. The post opened with two curls the reader could compare:
curl -s -X POST https://pub-trivia.app/webhook/stripe -d '{}'
curl -s -X POST https://pub-trivia.app/api/webhook/stripe -d '{}'
Here is what those two print today:
Redirecting...
{"error":"Missing stripe-signature header"}
The second is the live webhook refusing an unsigned request, exactly as before. The first is more interesting than a 404, and it is the part of a URL removal I had not thought about.
Six edits in four files
The route handler was 25 lines, a re-export with its own segment config. Deleting the file was the small part. The path was also spelled out in five other places:
-
API_ROUTES, the list the proxy uses to decide what it must not touch:['/api', '/webhook']became['/api']. -
The proxy matcher's negative lookahead, which is one long literal regex:
...|webhook|api|monitoring).*)lost one alternative. -
A second matcher entry,
'/webhook/:path*', which existed only so maintenance mode could answer those paths with a 503. A matcher has to be a literal, so it cannot follow a runtime flag, which is why the paths it excludes are also listed explicitly. -
CRAWLER_DISALLOW, the prefix list ourrobots.txtis generated from. - A test, which looped over four paths asserting each answered 503 while maintenance was on, and now loops over three.
Four files, six edits, for one path. None of them are hard, and that is the point: they are the sort of edit that a grep for the string finds in a second and that a careful reviewer would not question, which makes the forgettable one the one nobody notices is missing.
The one with a visible consequence is the robots list, because robots.txt is a generated public document:
$ curl -s https://pub-trivia.app/robots.txt | grep -c Disallow
7
It was eight. If you have an old copy of our robots.txt in a tab from a fortnight ago, Disallow: /webhook is in it. Removing the route removed the only reason to ask crawlers to stay out of it, and the file shrank by a line. That is a small thing, except that the inverse is the common bug: a path is removed from the app, left in the crawler policy, and three years later nobody knows whether that line protects something or not.
The deleted URL does not 404, it redirects
Removing /webhook from the proxy's API list did not make the path disappear. It moved it from one namespace into another.
Paths under /api are handed straight through by the proxy: no rate limiter, no session refresh, no auth gate, because they do their own authentication. Everything else goes to the gate, and the gate works from a prefix allowlist of public routes. It cannot know the route table, so it cannot tell the difference between a path that was never a route and a path whose route file I deleted eight days ago. Both are "not public", so both get a redirect to the login form:
$ curl -s -X POST -o /dev/null -w '%{http_code} %{redirect_url}\n' \
https://pub-trivia.app/webhook/stripe -d '{}'
307 https://pub-trivia.app/login?next=%2Fwebhook%2Fstripe
And because the login page is a real page:
$ curl -s -L -X POST -o /dev/null -w '%{http_code} %{num_redirects}\n' \
https://pub-trivia.app/webhook/stripe -d '{}'
200 1
A POST to a URL that no longer exists, followed one redirect, now answers 200 OK with a sign-in form in the body. For a human that is a mild annoyance. For a webhook sender it is the worst available answer, because any client that follows redirects and checks only the status code will mark that delivery as accepted and never retry it. The payload went into an HTML page and nothing read it.
Stripe is not such a client. It treats a 3xx on a webhook endpoint as a failed delivery and keeps retrying, so a misconfigured endpoint shows up as failures in its dashboard rather than as silence. That is the only reason this removal is safe rather than a quiet data loss, and it is luck of a kind, in that it is a property of somebody else's client rather than of our code. If you are deleting a webhook path, the order that makes it safe is unglamorous: point the vendor's dashboard at the new URL, watch the deliveries succeed there for longer than feels necessary, and only then delete the route. The intermediate state, where both URLs work, is the whole value of the re-export I defended in the earlier post.
Which claim from the earlier post survived
Two of them did, and one did not.
Still true: config that lives in a vendor's dashboard is config you cannot grep. That is why the deletion needed the dashboard change to come first, and why it is the only step in this that has no artefact in the repository.
Still true: re-exporting a route handler does not carry its route segment config across. The old file had to restate dynamic = 'force-dynamic' and maxDuration itself, because those are read from the module that defines the route, not from the module it borrowed its handler from. Deleting the file deleted that duplication too, which is the cleanest kind of fix.
No longer true: "the fix is not a deletion". It was the right call for a fortnight and the wrong call permanently, which is roughly the lifespan a compatibility shim is supposed to have. The failure mode of these shims is not that they are wrong, it is that nobody ever comes back to them, and a re-export that works has no symptoms at all.
What you can check
-
curl -s -X POST https://pub-trivia.app/api/webhook/stripe -d '{}'still answers{"error":"Missing stripe-signature header"}, which is the handler refusing an unsigned request before it looks at anything else. -
curl -s https://pub-trivia.app/robots.txtlists seven disallowed prefixes, and/webhookis not one of them. -
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://pub-trivia.app/webhook/stripeshows the 307 to the login form with the original path preserved in?next=, which nothing in our app reads, but that is a different post.
The pages this all exists to protect are public and dull by design: the pricing page, where a plan is bought, and the terms, which say what happens to a subscription afterwards. Everything above is plumbing in service of a venue being able to pay for a quiz night without anybody at either end thinking about URLs.
Top comments (0)