To add payments to a web app, use a payment provider's hosted checkout page or embedded fields so card numbers never touch your server, create each charge on the server from your own price list, and treat the provider's signed webhook as the only proof of payment. Never store card numbers, and test every failure case before launch.
Three of our products take money, and each does it differently. Seal My Record Now sells tiered service levels through a checkout. CoverageCobra moves money between two attorneys through Stripe Connect. Our defensive driving school platform, still in build, takes payment for a course.
The code that takes a payment is short. The decisions around it are where projects go wrong: where the card form lives, what your app believes when the browser says "paid", and what happens when a charge fails at two in the morning. This is how we approach those decisions.
How do you add payments to a web app?
You add payments to a web app by connecting it to a payment provider, letting the provider collect the card details, and recording the result in your own database when the provider confirms it. Your app decides what is being sold and for how much. The provider handles the card.
That division of labor is the whole design. Your server knows the order, the price, and the customer. The provider knows the card number, runs the charge, and deals with banks. Your app should never see a full card number, and it has no reason to.
All three use Stripe. The principles below apply to any modern provider.
Hosted checkout or embedded payment form: which should you use?
Use hosted checkout unless you have a clear reason not to. A hosted checkout page is built, secured, and updated by the payment provider, and your app only redirects to it. An embedded form keeps the customer on your page and gives you design control, at the cost of more code and testing.
| Hosted checkout | Embedded form | |
|---|---|---|
| Where the customer pays | On the provider's page | On your page |
| Who builds the form | The provider | You, using the provider's fields |
| Card data on your server | Never | Never, if you use the provider's fields |
| Design control | Limited to logo and colors | Full |
| New payment methods | Added by the provider | You enable and test each one |
| Work to build | Least | More |
| Best for | Most first versions | Checkouts that are part of a longer flow |
Both are safe when done properly, because in both cases the card fields belong to the provider. The mistake to avoid is a third option: building your own card inputs and posting them to your own server.
For a first version, the hosted page is nearly always right. It is the cheaper, simpler choice, and customers are used to it.
One-time payments vs subscriptions: what changes?
A one-time payment has one outcome to handle: it succeeded or it did not. A subscription is an ongoing relationship with many events, including renewals, failed cards, plan changes, cancellations, and refunds. Each event needs a rule in your app for what the customer can access afterward.
With a one-time payment, the logic is short. The order is unpaid, then paid, then fulfilled. A course enrollment or a document order works this way.
With a subscription, you have to answer questions before writing code:
- What happens when a renewal charge fails? Is access cut off at once, or after a grace period?
- Can a customer change plans partway through a period, and what do they owe?
- When someone cancels, does access end now or at the end of the period they paid for?
None of these are technical questions. They are business rules, and the provider will not choose them for you. If a one-time payment fits your product, prefer it. Subscriptions are more work to build and to support.
Why are webhooks the source of truth for payments?
Webhooks are the source of truth because they come from the payment provider directly to your server, signed so you can verify them. The customer's browser can close, lose signal, or be tampered with before it returns to your site. The provider's message arrives regardless and cannot be forged without your secret.
A webhook is a request the provider sends to a URL on your server when something happens: a checkout completed, a renewal failed, a refund went through. Your app should mark an order as paid only when that message arrives and its signature checks out. The "thank you" page a customer lands on is a courtesy, not evidence.
A minimal handler looks like this in Node with Express:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send('Invalid signature');
}
if (event.type === 'checkout.session.completed') {
await markOrderPaid(event.data.object.client_reference_id);
}
res.sendStatus(200);
});
Two details matter. The signature check needs the raw request body, not parsed JSON. And providers may deliver the same event more than once, so markOrderPaid must be safe to run twice without fulfilling the order twice.
What are the steps to add payments safely?
The safe sequence is: choose a provider, keep prices on the server, send the customer to the provider to pay, wait for the signed webhook, then fulfill the order. Test the failures as carefully as the successes, and keep secret keys out of your code.
- Pick the provider and the simplest product that fits. Hosted checkout and one-time payments unless you need otherwise.
- Keep the price list on your server. Never accept an amount from the browser. The browser sends what was chosen. The server looks up what it costs.
- Create the checkout on the server and attach your own order ID to it, so the webhook can be matched to an order.
- Send the customer to pay. Redirect to the hosted page or mount the provider's embedded fields.
- Handle the webhook. Verify the signature, find the order, mark it paid, and make the handler safe to repeat.
- Fulfill after confirmation. Grant access, send the receipt, start the work.
- Test the failures. Declined cards, abandoned checkouts, duplicate webhooks, and refunds, all in the provider's test mode.
- Store keys as secrets. Use environment variables, with separate keys for testing and for live payments.
Paying other people out, as a marketplace does, adds onboarding and verification for every recipient. We cover that in how marketplace payments work.
Frequently asked questions
Is it safe to store credit card numbers in my own database?
No. Never store card numbers. The payment provider stores the card and gives you a reference to it, which is all you need to charge the customer again or issue a refund. Keeping card numbers yourself creates a serious security obligation with nothing gained.
Do I still have compliance duties if I use a payment provider?
Yes, though far fewer. Using the provider's hosted page or fields keeps card data off your servers, which reduces what applies to you. Your provider documents what remains your responsibility, so check their guidance for your exact setup.
How do I test payments without spending real money?
Payment providers offer a test mode with its own keys and a set of test card numbers that simulate success, decline, and other outcomes. Build and test everything there first, including webhooks, then switch to live keys only at launch.
How should refunds be handled?
Issue refunds through the provider, either from its dashboard or through its API, and let the refund webhook update your records. Decide your refund policy before launch and write it where customers can read it. The software should follow the policy, not invent one.
Can I add payments to an app that is already live?
Yes. Payments attach to an existing app as a new set of server routes, a webhook, and a paid status on your orders or accounts.
If you are planning a product that takes money, tell us how you want to charge and we will suggest the simplest setup that does it correctly.