Guides · Free check · $9 fix pack

Guides

Your checkout trusts the price the browser sends. Anyone can change it.

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.

How to tell in one minute

  1. Open your pricing page with DevTools, Network tab open, and click the buy button.
  2. Look at the request to your checkout endpoint. If its body has an amount, a price in dollars or cents, or a price_data object, the browser is choosing the price.
  3. In your server code, find the 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.

The fix

  1. Send an identifier, not an amount. The browser sends 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).
  2. Create the session with that Price ID. line_items: [{ price: PRICE_ID, quantity: 1 }]. Pin quantity on the server too, unless quantity is something buyers are allowed to choose.
  3. Check what was paid before you unlock. In the webhook (signature verified first, see can someone fake a payment), retrieve the session with line items expanded, which is the step Stripe's fulfillment guide uses: "Retrieve the Checkout Session from the API with the line_items property expanded" (Stripe docs: Fulfill orders). Grant the plan that matches the Price ID that was actually bought, not the plan name your frontend sent.
  4. Compare the amount too. On the session, 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.
  5. Keep the unlock in one server function shared by the webhook and the success page (why the success page can't be the unlock), and make it run once (duplicate events guide).
// 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.

Prompt for your AI builder

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.

Check it again

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.

Check yours in 10 seconds (free).

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 check

FAQ

Can a customer change the price of my Stripe Checkout?

Only 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.

How do I check what the customer actually paid?

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.

Is price_data unsafe?

No. Inline prices are fine when your server computes the amount. They are only a problem when the amount comes from the client.

Sources

More guides

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.