If you sell to customers in India, you want UPI, and for that you want Razorpay. If you also sell abroad, Stripe Checkout is the easiest way to take cards. The starter does both from one FastAPI app, with one rule that matters more than either provider: the browser never decides that a payment succeeded.
Prices live on the server
Products and their prices are a dictionary in backend/app/api/payments.py:
PRODUCTS: dict[str, dict] = {
"pro": {
"name": "Pro upgrade",
"plan": "pro", # profiles.plan after payment
"prices": {"stripe": (1900, "usd"), "razorpay": (149900, "inr")},
},
}
Amounts are in minor units (cents and paise). The client sends a product id, never an amount, so nobody can buy the Pro plan for one rupee by editing a request.
Pending until a webhook says otherwise
Checkout creates a pending row in payments and returns the provider's checkout URL or order. When the customer pays, the provider calls a webhook:
- Stripe:
/payments/webhooks/stripe, with thecheckout.session.*events - Razorpay:
/payments/webhooks/razorpay, withorder.paidandpayment.failed
The backend verifies the signature, and only then marks the payment paid and calls on_paid(), which upgrades the user's plan. A success page in the browser is just a page; it changes nothing.
Exactly once, even when providers retry
Payment providers retry webhooks, sometimes several times. Each event is recorded with its id, and applying it twice is a no-op, so a retried order.paid can't grant the plan twice or notify the buyer twice. The tests replay webhooks to prove it.
Money is never added across currencies
Revenue charts and the admin payments list keep each currency separate. Adding ₹499 to $9 gives a number that means nothing, so the starter never does it.
Turning it on
A provider appears in the UI once its keys are in .env. Set the webhook URL in the provider's dashboard, pick the events above, and you can take a test payment in a few minutes. The payments guide has the details.
Top comments (0)