If you've had Cursor, Claude Code, Lovable or Bolt wire up Stripe for you, the happy path probably works. You click "Upgrade", pay with 4242 4242 4242 4242, land on /success, and you're Pro. Ship it.
The trouble is that the happy path is the only path most of us test. After reading a lot of agent-written Stripe integrations, I keep seeing the same three bugs. None of them shows up in a demo, and all three let people get paid features without paying.
So I wrote a small, MIT-licensed bash script that checks for them in about a minute: PayWallKit/paywall-check.
Bug 1: the webhook accepts unsigned events
Stripe signs every webhook it sends. Your handler is supposed to check that signature with stripe.webhooks.constructEvent(rawBody, signature, secret) before trusting anything.
Agents get this wrong in a specific way. They parse the body as JSON first (await req.json()), then call constructEvent on it. Verification fails, because the signature covers the raw bytes. Then the agent "fixes" the error by deleting the check. Now anyone who finds your webhook URL can do this:
curl -X POST https://yourapp.com/api/webhooks/stripe \
-H 'content-type: application/json' \
-d '{"type":"checkout.session.completed","data":{"object":{"payment_status":"paid"}}}'
If that returns a 200, you have a free "payment succeeded" button on the internet.
Fix (Next.js App Router):
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 400 });
}
// handle event...
}
On Supabase Edge Functions (Deno), use constructEventAsync with Stripe.createSubtleCryptoProvider().
Bug 2: the success page grants access
This one is very common: the /success page reads session_id from the URL and flips the user to Pro. Two problems:
- Anyone can type
/success?session_id=anything. - A real buyer who closes the tab before the redirect finishes pays and gets nothing.
The success page should only say "thanks, we're setting things up." The verified webhook is the only thing that should write paid access to your database.
Bug 3: secret keys in the client bundle
Anything prefixed NEXT_PUBLIC_ or VITE_ ships to every visitor's browser. Agents sometimes put the Stripe secret key, the webhook secret, or Supabase's service_role key behind one of those prefixes to make an error go away.
Note that Stripe secret keys start with sk_live_ / sk_test_ (underscore), and webhook secrets start with whsec_. A search for sk- only finds OpenAI keys.
Running the check
git clone https://github.com/PayWallKit/paywall-check
cd paywall-check
./paywall-check.sh https://yourapp.com /api/webhooks/stripe /success ./.next/static
The arguments are your base URL, your webhook path, your success path, and (optionally) your built client directory. The script:
- sends one unsigned fake
checkout.session.completedto your webhook and fails if it gets a 2xx, - sends one GET to your success page with a made-up
session_idand fails if the page says you've been upgraded, - greps your client build for
sk_live_/sk_test_,whsec_andservice_role.
It exits non-zero on any failure, so you can drop it into CI. Only run it against apps you own.
It's a smoke test, not an audit. A "pass" on check 2 only means the page didn't say you're upgraded, so look at your database too. It also doesn't cover refunds, cancellations, duplicate events or client-chosen prices. Those need the Stripe CLI (stripe listen + stripe trigger) and a look at what actually changed in your DB.
Tell the agent up front
Fixing these after the fact is fine. Not getting them in the first place is better. This block goes in AGENTS.md, CLAUDE.md or .cursor/rules:
- The webhook is the only writer of paid access. Success pages, redirects,
client callbacks and query strings never grant access.
- Verify every webhook with stripe.webhooks.constructEvent(rawBody, signature,
STRIPE_WEBHOOK_SECRET). Use the raw body; never parse JSON first.
(Supabase Edge Functions / Deno: constructEventAsync + createSubtleCryptoProvider.)
- Idempotency: record event.id before handling; skip duplicates.
- The client sends a plan key, never a price, amount, quantity or user id.
- Gate on the server. UI checks are cosmetic.
- customer.subscription.deleted, charge.refunded and charge.dispute.created remove access.
The idempotency line matters more than it looks. Stripe retries any webhook that doesn't get a fast 2xx, so "my webhook fired twice" is normal. Store event.id with a unique constraint and skip duplicates.
I sell a $19 Paywall Kit with full rules and a reference code. HN5 takes $5 off through Friday.
What other failure modes have you seen in agent-written billing code? I'd like to add checks for the common ones, so issues and PRs on the repo are welcome.
This post was written with the help of AI and checked by me before publishing.
Top comments (0)