Skip to main content
LaunchGateAUDIT TODAY. LAUNCH TOMORROW.

PaymentsCritical risk

Payment Security Checklist: 6 Tests Before Real Money

Server-side prices, verified Stripe webhooks, no double charges, no raw card data: a plain-English payment logic checklist for founders building fast.

By
LaunchGate Team
Published
Last reviewed
Reading time
10 min

IntermediateFounderDeveloper

Payment bugs are sneaky: your checkout can pass every demo you give it. Then someone edits one number in a request and buys your $99 plan for 50 cents. Or a retry quietly charges a real customer twice.

Stripe handles the card. The logic around it is yours: which price gets charged, when an account unlocks, and what happens when the same event shows up twice. Here are the six payment checks we run before an app takes real money. Run them only on apps you own, in Stripe test mode.

1. The browser should never decide the price

If your checkout endpoint reads an amount from the request, the customer controls it. Anyone can open developer tools, change 9900 to 50 and send it. Stripe's minimum charge of 0.50 USD won't stop that: you've sold a $99 plan for 50 cents, and nothing looks broken.

The fix is to send what someone is buying, never how much it costs. Your server maps that to a Stripe Price it trusts. When you create a Checkout Session with a price ID, Stripe uses that price to compute the order total.

Educational demo

// Risky: the browser names the amount
POST /api/checkout  { "plan": "pro", "amount": 9900 }

// Safer: the browser names the plan, the server looks up the price
POST /api/checkout  { "plan": "pro" }
line_items: [{ price: PRICES[plan], quantity: 1 }]

Treat every other client value the same way: quantities should be whole numbers in a sensible range, and a hidden "discount applied" field is one more thing a visitor can edit. OWASP's testing guide covers forged requests that go around your front end, starting with a one-time discount controlled by a hidden field.

Watch your minor units

Stripe expects amounts in each currency's smallest unit. 1000 means 10 USD, but 10 means 10 JPY, because yen is a zero-decimal currency. Code that multiplies every price by 100 overcharges in currencies like that, and floating-point math drifts. Keep money as integers in minor units, and let Stripe Price objects hold the amounts wherever you can.

2. Only Stripe gets to say "paid"

Your webhook endpoint is a public URL. If it acts on any JSON that arrives, anyone can post a fake "payment succeeded" event. Stripe's webhook guide warns that without verification, attackers could send forged events that fulfill orders or grant account access.

Stripe signs every event in a Stripe-Signature header. Verify it with Stripe's official library, your endpoint's whsec_ signing secret and the raw request body. That last part trips people up: if your framework parses the JSON first, verification fails, and the tempting "fix" is to switch it off. By default, the libraries also reject events signed more than five minutes earlier, which blunts replayed requests. Don't set that tolerance to zero: Stripe says zero turns the check off. Test mode and live mode use different signing secrets, so check both.

Don't let your success page do the unlocking. Stripe says you can't rely on the checkout landing page alone, because a customer can pay and lose their connection before it loads. And if visiting that page grants access without your server checking the Checkout Session's payment_status, anyone who finds the address can use it. Fulfill from verified webhooks, and let the landing page run the same server-side check.

3. Every payment should count once

Plan for every webhook to arrive more than once. Stripe says your endpoint might receive the same event more than once, that it retries failed deliveries for up to three days in live mode, and that it doesn't promise to deliver events in order. Its fulfillment guide adds that your fulfillment function might run several times, even at the same moment, for one Checkout Session.

So make repeats harmless. Store each processed event ID and each fulfilled Checkout Session ID behind a unique constraint in your database, and skip anything you've already handled. Don't use timestamps to spot duplicates: Stripe records an event's created time in seconds, so different events can share one.

Duplicate charges can also start on your side: a double-click on Pay, or a retry after a timeout. Stripe's answer is the idempotency key. Send one with each POST request, and a retry with the same key returns the saved result of the first request instead of doing the work again. Stripe advises against building keys from sensitive data such as email addresses. For Payment Intents, it suggests a key based on your cart or session ID. A disabled Pay button helps, but the database constraint is the real guard.

4. Keep raw card numbers off your servers

PCI DSS, the card industry's data security standard, applies to every business that stores, processes or transmits card data. Stripe's integration security guide says that if you handle sensitive card data directly, you might need to meet more than 300 security controls, while its low-risk integrations send payment details straight to Stripe without passing through your servers. For most founders, that settles it: use Stripe's hosted Checkout page or its Payment Element, not a card form you built.

The PCI Security Standards Council published PCI DSS v4.0.1 in June 2024 as a limited revision with no added or deleted requirements, and retired v4.0 on December 31, 2024. How you embed payments still matters. In February 2025, the Council clarified a newer SAQ A eligibility criterion about script attacks: it applies to sites that embed a processor's payment form, for example in an iframe, not to sites that redirect customers to the processor's own page.

Which self-assessment you file is a question for your payment processor or a PCI professional. You can check the side doors yourself: card numbers pasted into a support chat or a free-text field, or caught by your logs and error tracker. Stripe also says not to store card details in metadata.

5. Coupons and credits need hard limits

Discount bugs are business logic: rules your app forgot to enforce on the server. OWASP's testing guide describes checking whether a once-per-order discount can be used again, for example by going back to the discount page or by loading and unloading a cart. It recommends hard controls, such as invalidating a used code in the database and keeping per-user counters on the back end.

If you use Stripe, let Stripe apply the code. Promotion codes can be limited to a specific customer, first-time orders, a minimum order value, an expiry date and a maximum number of redemptions. One catch: for subscriptions, the minimum amount is checked only at redemption, so it covers the first payment, not later ones. Free trials and referral credits need the same limits. OWASP files these under unrestricted access to sensitive business flows: flows that hurt the business when used far more than intended.

6. Access should follow the money

Stripe says a subscription integration needs a webhook handler, because most subscription activity happens asynchronously. Statuses move between trialing, active, past_due, unpaid, canceled and others, and Stripe recommends revoking access on canceled or unpaid. Even after invoice.paid, Stripe suggests confirming the subscription is active before you extend access.

Refunds and disputes belong in the same loop. The same guide notes that if you skip risk events such as charge.dispute.created, your app won't learn about disputes until someone checks the Dashboard. The window to respond to a dispute is usually 7 to 21 days, depending on the card network, and missing it means you lose automatically. If a customer disputes a charge while your refund is still pending, Stripe's refund docs recommend handling the dispute instead, so nobody gets paid back twice.

Check that the refund rules in your terms match what your code does; whether those terms suit your business is a question for an attorney. Assume you'll miss an event one day: a scheduled job comparing who has access in your database with what Stripe reports catches the drift before a customer does.

How LaunchGate tests payment logic

Before any testing starts, you sign a written permission form that sets out what we may test. When the scope includes payments, we work through the six areas above: edited prices, quantities and discounts; unsigned and repeated webhook events; double-submitted checkouts; where card data travels; and a test subscription taken through failed renewals, cancellation, refunds and disputes. Each finding comes with evidence and a recommended fix.

LaunchGate identifies issues based on the checks performed. We report what we tested and what we found. We don't decide which PCI questionnaire applies to you or whether your refund terms are right: some findings may require attorney or specialist review, and LaunchGate does not provide legal advice.

The Free Launch Check is a preliminary public-surface review and is not equivalent to a full audit. For deeper testing, compare our audit packages, try the launch readiness self-check, or read our security checklist for vibe-coded apps.

Article checklist · 10 items

Check your own app

Tick each item once you have checked it on your app. Ticks stay in this browser only.

Want LaunchGate to check the rest?

Get a free launch check

The Free Launch Check is a preliminary public-surface review — not a full audit.

About the author

LaunchGate Team

Practical guides based on the checks in every LaunchGate audit.

References

  1. How products and prices work Stripe Docs
  2. Supported currencies Stripe Docs
  3. Receive Stripe events in your webhook endpoint Stripe Docs
  4. Fulfill orders Stripe Docs
  5. Idempotent requests Stripe API Reference
  6. The Payment Intents API Stripe Docs
  7. Integration security guide Stripe Docs
  8. Coupons and promotion codes Stripe Docs
  9. Using webhooks with subscriptions Stripe Docs
  10. Respond to disputes Stripe Docs
  11. Refund and cancel payments Stripe Docs
  12. Just Published: PCI DSS v4.0.1 PCI Security Standards Council
  13. FAQ Clarifies New SAQ A Eligibility Criteria for E-Commerce Merchants PCI Security Standards Council
  14. WSTG-BUSL-02: Test Ability to Forge Requests OWASP Web Security Testing Guide
  15. WSTG-BUSL-05: Test Number of Times a Function Can Be Used Limits OWASP Web Security Testing Guide
  16. API6:2023 Unrestricted Access to Sensitive Business Flows OWASP API Security Top 10