A lot of AI-built checkouts work like this: Stripe redirects the buyer to /success, and that page flips the user to "pro". It looks fine in testing because you only ever reach the page by paying. But the success URL is just a URL. Anyone signed in to your app can type it, and some builds unlock even with no session_id at all.
/success, then /success?session_id=cs_test_fake.Also look at the code for the success page. If it updates the user's plan from the browser (for example a direct database update in client code), the same update can be run by anyone with the browser console open.
Stripe says it plainly: "You can't rely on triggering fulfillment only from your checkout landing page, because it's not guaranteed customers visit that page. For example, a customer can pay successfully and then lose their internet connection before your landing page loads" (Stripe docs: Fulfill orders). So a page-only unlock fails in both directions: real buyers who close the tab get nothing, and people who never paid can get in.
The session_id in the URL isn't proof either. Stripe fills it in from the {CHECKOUT_SESSION_ID} template variable you add to success_url, which it calls "a literal string" you "must add exactly as you see it" (Stripe docs: Customize redirect behavior). The value is only useful once your server looks that session up with your secret key, and even then a real paid session ID copied from someone else would still pass unless you check whose it is.
checkout.session.completed (and checkout.session.async_payment_succeeded for delayed payment methods) and call one fulfill(sessionId) function (customer paid but no access). Verify the signature first (can someone fake a Stripe payment?).client_reference_id to your user's ID. Stripe describes it as a reference that "can be used to reconcile the Session with your internal systems" (Stripe API: The Checkout Session object).fulfill_checkout function with the ID provided", then "Render the page after the fulfillment attempt is complete" (source). Inside fulfill, retrieve the session from the API (Stripe API: Retrieve a Checkout Session) and check payment_status, which is "one of paid, unpaid, or no_payment_required" (source). Stripe's own example fulfills only when it is not unpaid (source).// Creating the session (server)
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: PRICE_ID, quantity: 1 }],
client_reference_id: user.id,
success_url: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://yourapp.com/pricing',
});
// Shared by the webhook and the success page (server only)
async function fulfill(sessionId, expectedUserId) {
const s = await stripe.checkout.sessions.retrieve(sessionId);
if (s.payment_status === 'unpaid') return false; // not paid (yet)
if (expectedUserId && s.client_reference_id !== expectedUserId) return false; // someone else's session
await grantAccessOnce(s.client_reference_id, s.id); // your helper: unique on session id
return true;
}
On the success page, call fulfill(session_id, currentUser.id) on the server and show "Payment received, unlocking..." if it returns false. The webhook will finish the job when it arrives.
Remove any code that grants paid access just because the user reached the success page. 1) Set client_reference_id to the user's ID when creating the Stripe Checkout Session, and use success_url with ?session_id={CHECKOUT_SESSION_ID}. 2) Write one server-side fulfill(sessionId) that retrieves the session with the secret key, skips it if payment_status is unpaid, and grants access to client_reference_id once per session ID. 3) Call it from the verified Stripe webhook for checkout.session.completed and checkout.session.async_payment_succeeded. 4) On the success page, call it on the server only, and only if client_reference_id matches the signed-in user. 5) Make the plan field writable only by server code.
Repeat the private-window test: /success and /success?session_id=cs_test_fake should unlock nothing. Then do one sandbox checkout and confirm access arrives even if you close the tab before the success page loads. That proves the webhook is doing the unlocking.
Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).
Run the free checkYes, if your app grants access when that page loads. Stripe says not to rely on the landing page for fulfillment. Grant access from a verified webhook, and on the success page only after your server retrieves the Checkout Session and checks it.
Not by itself. Retrieve the Checkout Session with your secret key, check payment_status is not unpaid, and check client_reference_id matches the signed-in user so a copied session ID from someone else does nothing.
Stripe recommends it as a second path because webhooks can be delayed, but only through the same server-side, run-once fulfill function the webhook uses.
Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect
Last reviewed 2026-11-02. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.