DEV Community

Daniel Pertu
Daniel Pertu

Posted on

I told readers to curl our pricing endpoint, and seventeen days later it answers 404

On 5 October I published a post about our app having two copies of every price: the figures in a TypeScript module that the marketing pages render, and the Stripe Price objects that actually took the money. It ended with a section inviting readers to watch the second copy for themselves:

curl -s https://pub-trivia.app/api/pricing
curl -sI https://pub-trivia.app/api/pricing | grep -iE 'cache|age'
Enter fullscreen mode Exit fullscreen mode

Run either of those today:

$ curl -s -o /dev/null -w '%{http_code}\n' https://pub-trivia.app/api/pricing
404
Enter fullscreen mode Exit fullscreen mode

The endpoint was deleted on 15 October. Since the whole point of writing these posts against a live site is that a reader can check the claim, a dead demo deserves its own post rather than a quiet edit, so here is what that URL was, why it is gone, and what to curl instead.

What it was

Thirty-four lines, and every one of them earning its place at the time:

const getCachedPricing = unstable_cache(
  () => getStripePricing(),
  ['stripe-pricing'],
  { revalidate: 300 } // re-fetch from Stripe at most once every 5 minutes
)

export async function GET(request: NextRequest) {
  const ip = getRequestIp(request.headers)
  const { success } = await ratelimit.pricing.limit(ip)
  if (!success) {
    return NextResponse.json({ error: 'Too many requests. Please slow down.' },
      { status: 429, headers: { 'Retry-After': '60' } })
  }

  try {
    const plans = await getCachedPricing()
    return NextResponse.json(plans, {
      headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' },
    })
  } catch (error) {
    console.error('Pricing API error:', error)
    return NextResponse.json({ error: 'Failed to fetch pricing' }, { status: 500 })
  }
}
Enter fullscreen mode Exit fullscreen mode

A cached read of the live product list from Stripe, rate limited at 20 requests per minute per IP so it could not be used as a free proxy to our payment provider, and cached again at the CDN. It made a decent demo precisely because it was so ordinary: curl it twice and you could watch x-vercel-cache go from MISS to HIT while age climbed, and see that the s-maxage directive never reached the client because the CDN had consumed it.

It existed for one reason. The authoritative prices lived in Stripe, the dashboard plan screen needed them in the browser to put a figure on a button, and a React component cannot call the Stripe SDK. So there was an endpoint.

Why it went

The refactor that followed removed the authority, not the endpoint. Checkout now builds every line item inline from the same price module the marketing pages read, in the visitor's own currency, so there are no Stripe Price objects left to be authoritative about anything. There is no second copy of a price to go and fetch.

The deletion shows up in three other files, and the shapes of those changes are the interesting part:

The component lost a prop and a failure state. The plan cards used to take initialPlans: PricingPlan[], find the entry whose planType matched, and read plan.priceId to start a checkout. They now take the plan type and nothing else, and post that to the checkout route. The interface describing a plan's shape, eleven fields of it, went with them.

More telling, this branch went too:

) : (
  <Button size="lg" className="rounded-full w-full" disabled>
    Unavailable
  </Button>
)
Enter fullscreen mode Exit fullscreen mode

That was the fallback for "Stripe did not answer, so we do not know what this plan costs". A disabled button on the only screen in the app whose job is to sell you something. It is not that we got better at handling the error: the request it was handling no longer exists, so the state is unreachable, and the honest thing was to delete the branch rather than keep a button nobody can ever see.

A rate limit bucket disappeared. Our limiter config is a single object of named sliding windows, so removing an endpoint means removing its entry:

-    /**
-     * /api/pricing — 20 per minute per IP.
-     * Public endpoint that proxies Stripe API calls. 20/min stops scraping
-     * while allowing normal page refreshes and plan-page loads.
-     */
-    pricing: sliding(20, 60),
Enter fullscreen mode Exit fullscreen mode

Worth saying out loud because it is a cost people forget when they count the cost of an endpoint. That URL was not 34 lines. It was 34 lines, a limiter bucket, a Redis key namespace, a cache tag, an error branch in a component, a prop threaded down two levels, and an interface. Deleting it paid all of that back at once.

One comment is now half true. The plans screen still says, of its currency resolution, that "Checkout still prices from Stripe itself" in the fallback case. It does not any more: Checkout prices from the module. The code around it is correct, the sentence is a fossil, and I am leaving it in this post rather than quietly fixing it first, because it is the standard way documentation rots. The comment described a dependency, the dependency was removed, and the comment was not where anybody was looking.

What to curl instead

The replacement demo is better, because it shows the thing the old one only described. The pricing page itself now carries the only copy of the price:

$ curl -s https://pub-trivia.app/pricing | grep -o "Prices in [A-Z]\{3\}[^<]*"
Prices in EUR, including VAT where it applies. You pay the price shown.
Enter fullscreen mode Exit fullscreen mode

That comes back in your own currency, resolved on the server from the request, which you can confirm by running it through a VPN exit in another country. The figures on the page itself are the figures Checkout charges.

The caching story moved with it, in the opposite direction:

$ curl -sI https://pub-trivia.app/pricing | grep -i cache-control
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
Enter fullscreen mode Exit fullscreen mode

The old endpoint was five minutes of shared CDN cache. The page that replaced it cannot be shared at all, because its content depends on where the request came from, so it renders per request and tells every cache to keep out. The old post's "watch it cache" paragraph is not merely dead, it is inverted, and that trade is deliberate: a page that renders fast and shows the wrong number is worse than one that renders correctly.

Two claims from that October post do still hold, and you can check both in the same response. There is still exactly one price for a plan rather than two, and the structured data still quotes the plans in pounds, which keeps one fixed figure in search results whatever currency the page itself is rendered in:

$ curl -s https://pub-trivia.app/pricing | grep -o 'priceCurrency":"[A-Z]*' | head -2
priceCurrency":"GBP"
priceCurrency":"GBP"
Enter fullscreen mode Exit fullscreen mode

The thing I would actually keep

A published post is a dependency on a URL, and nothing in your repository knows about it. We have a test that regexes export const metadata out of a hundred route files, a test that holds the sitemap and the auth allowlist to the same list of public paths, and a test that fails if any price surface calls our own price an estimate. None of them know that a blog post from 5 October tells strangers to make a GET request to a path that no longer has a route file.

I am not going to build that, and I do not think anybody should. The practical version is cheaper: when a refactor deletes a public URL, grep your own writing for it before you ship, and if you find it, write the correction rather than editing the past quietly. The second option costs one post and buys back the only thing that makes "check it yourself" worth saying.

Top comments (0)