A common AI-built checkout: the pricing page posts { amount: 4900, plan: 'pro' } to /api/checkout, and the server passes that amount straight to Stripe. The checkout looks right in testing. But the request comes from the buyer's browser, so the buyer can change 4900 to 100 in DevTools, pay $1, and your webhook unlocks "pro" because a payment did succeed.
price_data object, the browser is choosing the price.stripe.checkout.sessions.create call. If line_items uses price_data.unit_amount taken from the request, it's exploitable.Stripe allows both shapes. line_items is "a list of items the customer is purchasing. Use this parameter to pass one-time or recurring Prices" (Stripe API: Create a Checkout Session). You can also create an inline price: "pass in price_data instead of a price.id", which also works "with Checkout Sessions" (Stripe docs: Manage prices). Inline prices are fine when the server computes them. They are a hole when the browser does.
plan: 'pro' (or a Price ID). The server maps it to a Price ID from an allowlist and ignores anything else. Stripe's lookup_key on a Price is built for this: it lets you "Render different prices in your frontend" and "Use the rendered price to bill customers" (source).line_items: [{ price: PRICE_ID, quantity: 1 }]. Pin quantity on the server too, unless quantity is something buyers are allowed to choose.amount_total is the "Total of all items after discounts and taxes are applied" (Stripe API: The Checkout Session object), and the line items list shows each item's price.id, quantity and amounts. If you allow promotion codes, expect a lower total and check the price, not the total alone.// Checkout endpoint (server). The browser sends only { plan }.
const PRICES = { pro: process.env.PRICE_PRO, team: process.env.PRICE_TEAM };
const priceId = PRICES[req.body.plan];
if (!priceId) return res.status(400).json({ error: 'unknown plan' });
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }], // no amount from the client
client_reference_id: user.id,
success_url: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://yourapp.com/pricing',
});
// Fulfillment (webhook + success page, server only)
const PLAN_BY_PRICE = { [process.env.PRICE_PRO]: 'pro', [process.env.PRICE_TEAM]: 'team' };
async function fulfill(sessionId) {
const s = await stripe.checkout.sessions.retrieve(sessionId, { expand: ['line_items'] });
if (s.payment_status === 'unpaid') return false;
const item = s.line_items.data[0];
const plan = PLAN_BY_PRICE[item?.price?.id];
if (!plan || item.quantity !== 1) return false; // not something you sell: log it, don't unlock
await grantPlanOnce(s.client_reference_id, plan, s.id); // your helper: unique on session id
return true;
}
Stripe says payment_status is the value "to decide when to fulfill your customer’s order" (source). The Price ID check is what stops a cheap price, a test-mode price, or a leftover inline price from unlocking the expensive plan.
Our checkout must not accept a price or amount from the browser. 1) Change the checkout endpoint to accept only a plan name, map it on the server to a Stripe Price ID from environment variables, reject unknown plans, and create the Checkout Session with line_items [{ price: PRICE_ID, quantity: 1 }] and client_reference_id set to the user's ID. Remove any price_data or unit_amount that comes from the request. 2) In the verified webhook and the success page, call one server-side fulfill(sessionId) that retrieves the session with expand ['line_items'], skips it if payment_status is unpaid, and grants the plan mapped from the purchased price ID (not from anything the client sent), once per session ID.
In a sandbox, repeat the DevTools test: edit the request body to a different amount or plan and resend it. The server should reject it or ignore the change, and the Checkout page should still show your real price. Then complete one sandbox checkout and confirm the plan you get matches the price you paid.
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 checkOnly if your server builds the Checkout Session from an amount the browser sends, for example price_data.unit_amount taken from the request. Send a plan or Price ID from the browser, map it to an allowlisted Stripe Price on the server, and use line_items with that price.
In your verified webhook, retrieve the Checkout Session with line_items expanded. Check payment_status, the purchased price ID and quantity, and amount_total, then grant the plan that matches the price.
No. Inline prices are fine when your server computes the amount. They are only a problem when the amount comes from the client.
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-10-30. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.