Payments fail in boring ways
The money succeeding while your UI shows an error is more common than founders admit.
A student pays. The popup closes weirdly. Your verify call never runs. Access is not granted. Support gets a screenshot and a bad review.
Razorpay is fine. Your state machine is what saves you.
The mental model (memorize this)
- Authenticated user asks to buy (
itemType+itemId, optional discount). - Your API validates session, price, and stock/rules.
- You create a DB
Orderwithstatus: pendingand a Razorpay order id. - Frontend opens Checkout with that order id + key.
- On success, frontend calls verify with payment ids + signature.
- Server verifies signature, marks
paid, grants access (dashboard item, enrollment, subscription). - Webhook events do the same grant path idempotently if step 5 never happens.
If step 7 does not exist, you do not have a payment system — you have a demo.
Why pending orders matter
Never invent the order after money moves. Persist intent first:
- You can show “Pending / Failed / Paid” honestly in the dashboard.
- Support can search by Razorpay ids.
- Webhooks have a row to attach to.
- Double-clicks create fewer duplicate “mystery charges.”
Verify signature like an adult
Frontend can be lied to. Signature verification is server-side only with your key secret.
After a valid signature:
- Transition
pending → paidonce - Grant access once (unique constraints help: one dashboard item per user/item)
- Send purchase email once (or accept at-least-once with dedupe keys)
If verify fails, leave the order pending and let the webhook or support tools resolve it — do not delete evidence.
Webhooks as backup, not “extra credit”
Configure payment.captured (and failure events you care about) to hit something like /api/payments/webhook.
Good webhook handlers:
- Validate the webhook secret/signature
- Log the raw event (we keep a
WebhookEventtable for this reason) - Load the order by Razorpay order id
- Call the same grant function used by verify
- Return 200 quickly when work is done or already applied
Idempotency is the whole game: the same captured event may retry.
UX details buyers feel
- Show order history with status badges that match reality
- Offer an invoice page for paid orders
- Explain “Payment received, access unlocking…” if grant is slightly delayed
- Never say “Success” before verify or webhook confirms
Test keys vs live keys
Local and staging use rzp_test_*. Production uses rzp_live_*. Swap all of:
RAZORPAY_KEY_IDRAZORPAY_KEY_SECRETRAZORPAY_WEBHOOK_SECRETNEXT_PUBLIC_RAZORPAY_KEY_ID
Mismatch here produces signature failures that look random.
Checklist before you sell anything
- Create-order requires login
- Price comes from DB, not the client body alone
- Pending order written before Checkout
- Verify route checks signature
- Grant access is idempotent
- Webhook logs + processes captures
- Failed payments do not unlock content
- You ran a full test payment on test mode end-to-end
Want to see it in a real codebase?
Desi Stack products are built around this flow for projects, courses, and subscriptions. Study a full project or ship your own store knowing the rails underneath are boring on purpose — boring payments are the goal.
Keep building
Related reads
More posts coming soon.