Introduction
We've all seen it: a mobile app that subtracts a discount client-side, a checkout form where the total is calculated in JavaScript, or a payment request where the amount comes straight from user input. Never trust the client with prices: a step-by-step guide to preventing revenue leaks, fraud, and compliance headaches that keep payment engineers up at night. If you're building anything that handles money, this principle isn't optional—it's foundational.
The stakes are high. A single misconfigured frontend calculation can turn into thousands of dollars in lost revenue, chargebacks, or legal liability. We'll walk through exactly how to implement server-side price authority in your Stripe integration, covering everything from basic lookups to complex scenarios with coupons and dynamic pricing.
Look up prices server-side
The moment a client requests a payment, your server must independently verify every component of the price. Don't assume the frontend is honest. Don't assume the API call payload is accurate. Always fetch the canonical price from your database or Stripe before creating any charge.
Start by storing prices in a single source of truth. For most teams using Stripe, that's either your database or Stripe's Price objects themselves. When a user initiates checkout, they send you minimal information: the product ID, maybe a quantity. Your server does the rest.
Here's a practical flow in a Node.js + Express backend:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post('/api/checkout', async (req, res) => {
const { productId, quantity } = req.body;
// Never trust the client's price
// Look it up from your source of truth
const product = await db.products.findById(productId);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
// Validate quantity is reasonable
if (quantity < 1 || quantity > 1000) {
return res.status(400).json({ error: 'Invalid quantity' });
}
// Calculate total server-side
const unitPriceInCents = product.priceInCents;
const totalInCents = unitPriceInCents * quantity;
// Create payment intent with server-calculated amount
const paymentIntent = await stripe.paymentIntents.create({
amount: totalInCents,
currency: 'usd',
metadata: {
productId,
quantity,
userId: req.user.id,
},
});
res.json({ clientSecret: paymentIntent.client_secret });
});Notice what we're doing: accepting minimal data from the client, looking up the authoritative price, validating it exists and is reasonable, then creating the charge with our calculated amount. The client never had a say in the final number.
This pattern scales. Whether you're selling a single product or a complex marketplace order, the principle remains: fetch all prices server-side, calculate totals server-side, pass only the final amount to Stripe.
Recalculate totals on the server
Recalculation is different from lookup. Lookup is a point-in-time verification of a price. Recalculation is the full assembly of an order: unit price times quantity, plus tax, minus discount, adjusted for region or customer tier. Everything happens on your server.
Why separate recalculation from lookup? Because an order often has multiple line items, each with its own price, and combining them involves business logic that the client must never control.
Let's expand our example to a cart with multiple items:
app.post('/api/create-order', async (req, res) => {
const { items, customerId } = req.body;
// items = [{ productId, quantity }, ...]
// Fetch customer to check eligibility for discounts
const customer = await db.customers.findById(customerId);
if (!customer) {
return res.status(404).json({ error: 'Customer not found' });
}
let orderTotal = 0;
const lineItems = [];
// Recalculate every line item
for (const item of items) {
const product = await db.products.findById(item.productId);
if (!product) {
return res.status(400).json({
error: `Product ${item.productId} not found`
});
}
const linePrice = product.priceInCents * item.quantity;
orderTotal += linePrice;
lineItems.push({
productId: item.productId,
quantity: item.quantity,
unitPrice: product.priceInCents,
lineTotal: linePrice,
});
}
// Apply tax (region-based)
const taxAmount = calculateTax(orderTotal, customer.region);
const grandTotal = orderTotal + taxAmount;
// Store order in database before charging
const order = await db.orders.create({
customerId,
lineItems,
subtotal: orderTotal,
tax: taxAmount,
total: grandTotal,
status: 'pending_payment',
});
// Create payment intent with recalculated total
const paymentIntent = await stripe.paymentIntents.create({
amount: grandTotal,
currency: 'usd',
customer: customer.stripeId,
metadata: {
orderId: order.id,
},
});
res.json({
orderId: order.id,
clientSecret: paymentIntent.client_secret,
amount: grandTotal,
});
});Key points here: we fetch every product, validate every quantity, add them all up, calculate region-specific tax, and only then do we tell Stripe what to charge. The client sent us a list of item IDs. We sent back the final amount, which we alone calculated.
This recalculation pattern also makes debugging easier. If a customer disputes a charge, you have a clean audit trail: the exact order details, the prices that were active at that moment, and the exact calculation.
Use Price objects for catalog items
Stripe's Price object is designed precisely to prevent this class of bug. Instead of storing prices in your database and syncing them manually, create Prices in Stripe and reference them from your backend.
A Price in Stripe is immutable. Once created, its amount never changes. This is powerful: you can charge against a historical Price, and that exact amount is locked in forever. It's an audit trail built into your payment processor.
Here's how to set this up in practice. First, create a Product and Price in Stripe:
// One-time setup: create a product and price in Stripe
const product = await stripe.products.create({
name: 'Premium Widget',
description: 'Our finest widget',
});
const price = await stripe.prices.create({
product: product.id,
currency: 'usd',
unit_amount: 2999, // $29.99
type: 'fixed_price',
});
// Store the price ID in your database
await db.products.create({
name: 'Premium Widget',
stripePriceId: price.id,
});Now in your checkout flow, reference that Price object by ID:
app.post('/api/checkout-with-stripe-price', async (req, res) => {
const { productId, quantity } = req.body;
const product = await db.products.findById(productId);
// Fetch the actual Price from Stripe
const stripePrice = await stripe.prices.retrieve(product.stripePriceId);
// Validate the price still exists and is active
if (stripePrice.deleted) {
return res.status(410).json({ error: 'Price is no longer available' });
}
const totalInCents = stripePrice.unit_amount * quantity;
const paymentIntent = await stripe.paymentIntents.create({
amount: totalInCents,
currency: 'usd',
metadata: {
stripePriceId: stripePrice.id,
quantity,
},
});
res.json({ clientSecret: paymentIntent.client_secret });
});Why is this better? Stripe's Price object is the single source of truth. There's no sync lag between your database and Stripe. If you need to change a price, you create a new Price object and transition customers to it, but old Prices remain immutable. This is especially valuable in SaaS with recurring billing: you can update subscription prices without affecting existing subscriptions.
For line-item based checkouts (like a shopping cart), you can even use Stripe's Session API with line_items that reference Price objects directly:
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price: 'price_1234567890', // Stripe Price ID
quantity: 2,
},
],
mode: 'payment',
success_url: 'https://yoursite.com/success',
cancel_url: 'https://yoursite.com/cancel',
});
// Stripe validates all prices server-side automaticallyThis approach moves price validation entirely into Stripe's infrastructure. You're not calculating anything; Stripe is. For many teams, this is the safest route.
Validate coupons on the server
Coupons and discounts are where many teams slip up. A client applies a coupon code, reduces the total in the UI, and sends that reduced amount to your server. This is a classic fraud vector.
Always validate coupons server-side. In Stripe, that means retrieving the coupon or promotion code, checking its validity, and recalculating the total based on Stripe's rules—not the client's claim about what the discount was.
Here's the right approach:
app.post('/api/apply-coupon', async (req, res) => {
const { couponCode, orderId } = req.body;
// Fetch and validate the coupon from Stripe
const coupon = await stripe.coupons.retrieve(couponCode)
.catch(() => null);
if (!coupon) {
return res.status(400).json({ error: 'Coupon not found' });
}
// Check if coupon is still valid
if (coupon.deleted) {
return res.status(400).json({ error: 'Coupon has been removed' });
}
if (coupon.redeem_by && coupon.redeem_by < Math.floor(Date.now() / 1000)) {
return res.status(400).json({ error: 'Coupon has expired' });
}
if (coupon.max_redemptions && coupon.times_redeemed >= coupon.max_redemptions) {
return res.status(400).json({ error: 'Coupon limit reached' });
}
// Fetch the order
const order = await db.orders.findById(orderId);
// Recalculate total with discount
let discountAmount = 0;
if (coupon.percent_off) {
discountAmount = Math.floor(order.subtotal * (coupon.percent_off / 100));
} else if (coupon.amount_off) {
discountAmount = coupon.amount_off;
}
const newTotal = Math.max(0, order.subtotal - discountAmount);
// Update the order with discount info
const updatedOrder = await db.orders.update(orderId, {
couponCode: couponCode,
discountAmount,
total: newTotal,
});
res.json({
discountAmount,
newTotal,
});
});Notice the validations: we check that the coupon exists, hasn't been deleted, hasn't expired, and hasn't exceeded its redemption limit. We recalculate the discount ourselves using Stripe's data, not the client's claim. The client never dictates the final discount amount.
When you create the payment intent, include the validated coupon:
app.post('/api/finalize-order', async (req, res) => {
const { orderId } = req.body;
const order = await db.orders.findById(orderId);
const paymentIntent = await stripe.paymentIntents.create({
amount: order.total, // This includes our server-side discount calc
currency: 'usd',
customer: order.customer.stripeId,
metadata: {
orderId: order.id,
couponCode: order.couponCode || 'none',
},
});
res.json({ clientSecret: paymentIntent.client_secret });
});Alternatively, for subscription-based discounts, you can apply the coupon directly when creating a subscription:
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: stripePriceId }],
coupon: couponCode, // Stripe validates this server-side
});Stripe will validate the coupon on your behalf and apply the discount. Your code doesn't even need to calculate the discount; Stripe does it and returns the final amount in the subscription invoice.
Common mistakes
Mistake 1: Trusting the amount from the client. Some teams receive a payment request that includes an amount field and charge exactly that. This is an open door to fraud. Always recalculate.
Mistake 2: Caching prices without TTL. If you fetch a price once and cache it indefinitely, you risk charging outdated amounts. Use short TTLs (seconds to minutes) or hit your database/Stripe on every transaction.
Mistake 3: Applying coupons client-side before validation. A developer sees a 20% discount code in a screenshot and hardcodes it into the app. Later, the code is deleted from your system, but the app still applies it. Validate every single code server-side.
Mistake 4: Not reconciling after payment. You calculate a total, charge it, but never verify that the amount debited matches your records. Use Stripe webhooks to check this. If amounts diverge, you've found a bug.
Mistake 5: Forgetting tax and fees. A recalculation that ignores regional tax or processing fees will always be wrong. Include every component that affects the final amount.
Key takeaways
- Never accept a price amount from the client. Always look it up or calculate it server-side.
- Recalculate totals for every transaction: fetch all products, validate quantities, apply discounts, add tax, then charge.
- Use Stripe Price objects as your source of truth. They're immutable and audit-friendly.
- Validate every coupon against Stripe's data. Check expiry, redemption limits, and status before applying any discount.
- Store complete order details before charging. You'll need them for disputes, audits, and debugging.
- Use webhooks to reconcile: confirm that every successful payment matches an order in your system.






Comments
Loading comments…