What you'll learn
- Quick answer
- Three Parties, Not Two
- Step 1: Your Server Creates an Order First
- Step 2: The Actual Checkout — Card Data Never Touches Your Server
- Step 3: Verifying the Signature Before You Trust Anything
- Why Webhooks, Not Just the Checkout Callback, Are the Real Source of Truth
- The Same Shape, Different Names: Stripe's PaymentIntent Flow
- FAQ
Quick Answer
A payment happens in three separate steps that all have to line up: your server creates an order or payment intent first, the user completes payment through the gateway's own checkout UI (never your code touching card details directly), and your server confirms the payment actually succeeded by verifying a cryptographic signature — either from the checkout callback or, more reliably, from an asynchronous webhook the gateway sends independently of what the user's browser does.
Three Parties, Not Two
It's tempting to think of a payment as a conversation between your website and the user: they click "Pay Now," money moves, done. In reality, every card or UPI payment involves at least three parties who all have to agree: your server (the merchant), the user's browser or app, and the payment gateway itself (Razorpay, Stripe, and similar). The gateway, in turn, is talking to banks, card networks, and UPI infrastructure behind the scenes — but from your code's perspective, the gateway is the single party you integrate with, and it abstracts all of that further complexity away.
The reason this can't be a single simple API call is a mix of security and reliability requirements. Your server should never see, store, or transmit raw card numbers — that's a massive compliance burden (PCI-DSS) that gateways exist specifically to take off your hands, which is why card details are entered directly into the gateway's own UI or SDK, never your own form fields. And the user's browser can't be trusted to reliably report back "the payment succeeded," because browsers close, networks drop, and users occasionally get impatient and hit back before a redirect finishes — which is why a second, independent confirmation channel (the webhook, covered later) exists specifically to not depend on the browser telling the truth.
Step 1: Your Server Creates an Order First
Before any payment UI appears, your own server has to create an order (Razorpay's term) or a PaymentIntent (Stripe's term) with the gateway, specifying the amount and currency. This step matters more than it looks: it's the point where you, the merchant, are the one asserting how much money should change hands, computed from your own trusted source (your product's actual price in your database), not from anything the client sent. If the amount were instead accepted from the browser at checkout time, a user could tamper with it in dev tools and pay ₹1 for a ₹999 course.
Here's the actual shape of this call against Razorpay's Node.js SDK, matching their current documentation:
const Razorpay = require("razorpay");
const instance = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
// Step 1: create an order on YOUR server before showing any payment UI.
// Amount is always in the smallest currency subunit - paise for INR.
const order = await instance.orders.create({
amount: 19900, // Rs. 199.00
currency: "INR",
receipt: "receipt_course_199_8f3a",
});
// order.id (e.g. "order_9A33XWu170gUtm") goes to the client next.Two details matter here specifically. The amount is always expressed in the smallest currency subunit — paise for INR, cents for USD — which is a frequent source of an accidental 100x pricing bug for developers integrating a gateway for the first time. And receipt is your own internal reference, not a payment ID; it exists so you can look up which of your own orders a given gateway order corresponds to later.
Step 2: The Actual Checkout — Card Data Never Touches Your Server
With the order created, the browser loads the gateway's own checkout SDK and opens its UI — either an embedded overlay (Razorpay's Standard Checkout) or a redirect to a hosted page, or an embedded element (Stripe Elements) — and the user enters payment details directly into fields the gateway controls, not fields your own JavaScript reads. This is the actual PCI-DSS boundary: your server and your own frontend code are never in the path of raw card numbers, which is what keeps the compliance burden on the gateway rather than on you.
Here's Razorpay's client-side checkout, using the order_id from step 1 — this exact options shape (key, amount, currency, order_id, handler) matches Razorpay's current documented integration:
var options = {
key: "rzp_live_XXXXXXXXXXXX", // public key - safe to expose client-side
amount: "19900",
currency: "INR",
name: "Priodemy",
order_id: "order_9A33XWu170gUtm", // from the server in step 1 - not optional
handler: function (response) {
// response.razorpay_payment_id
// response.razorpay_order_id
// response.razorpay_signature
// send all three to your server to verify before granting access
fetch("/api/verify-payment", {
method: "POST",
body: JSON.stringify(response),
});
},
prefill: { email: user.email },
};
var rzp = new Razorpay(options);
rzp.open();When the payment completes, Razorpay's checkout calls your handler function with three values: razorpay_payment_id, razorpay_order_id, and razorpay_signature. That third value is not decorative — it's what makes the next step possible.
Step 3: Verifying the Signature Before You Trust Anything
Here's the part that's easy to skip and dangerous to skip: the values that come back from the checkout callback arrived through the user's browser, which means, in principle, a motivated user could fabricate them with dev tools and claim a payment succeeded when it never did. The signature exists precisely to make that fabrication impossible without knowing your secret key, which never reaches the browser.
Razorpay signs the payment using HMAC-SHA256, computed over order_id + "|" + payment_id, keyed with your account's secret key. Your server recomputes the same HMAC and checks it matches what came back:
const crypto = require("crypto");
function verifyPaymentSignature(orderId, paymentId, signature, keySecret) {
const expected = crypto
.createHmac("sha256", keySecret)
.update(orderId + "|" + paymentId)
.digest("hex");
return expected === signature; // constant-time compare in production
}
// On POST /api/verify-payment:
const ok = verifyPaymentSignature(
body.razorpay_order_id,
body.razorpay_payment_id,
body.razorpay_signature,
process.env.RAZORPAY_KEY_SECRET
);
if (!ok) return res.status(400).send("signature mismatch - do not fulfil");If the signatures don't match, the payment claim is not trustworthy — full stop, don't fulfil the order. If they match, you have cryptographic proof that whoever generated that signature had your secret key, which only Razorpay's servers do, meaning the payment genuinely happened as claimed.
Why Webhooks, Not Just the Checkout Callback, Are the Real Source of Truth
The checkout-callback verification above is necessary but not sufficient on its own, for a simple reason: it depends on the user's browser successfully calling your server after payment. If the user's connection drops, if they close the tab a half-second too early, or if your frontend JavaScript throws an unrelated error right at that moment, your server never hears about a payment that genuinely succeeded — the money moved, but your database has no idea.
Webhooks solve this by having the gateway notify your server directly and independently of the browser, as a server-to-server call the moment an event like payment.captured happens on their end. This doesn't depend on the user's browser doing anything at all. Both Razorpay and Stripe sign webhook payloads the same conceptual way (HMAC over the raw request body), and both require you to read the raw, unparsed body to verify it — a very common integration bug is a framework middleware that parses the JSON body before your handler sees it, which silently breaks signature verification because the signature was computed over the exact original bytes, not a re-serialized version of the same data. Razorpay's webhook signature arrives in an X-Razorpay-Signature header:
// Webhook body must be read as the RAW bytes, not parsed JSON,
// because the signature is computed over the exact raw payload.
app.post("/webhooks/razorpay", express.raw({ type: "*/*" }), (req, res) => {
const signature = req.headers["x-razorpay-signature"];
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.body) // raw Buffer, not JSON.parse()'d
.digest("hex");
if (expected !== signature) return res.status(400).send("invalid signature");
const event = JSON.parse(req.body);
if (event.event === "payment.captured") {
// this is the source of truth - fulfil the order here,
// even if the client never called back after checkout
}
res.status(200).send("ok");
});The practical pattern that follows from this is: treat the checkout callback as a fast path for showing the user an immediate success screen, and treat the webhook as the actual authoritative trigger for fulfilling the order — granting course access, minting a certificate, shipping a product. A payment your webhook confirms but your callback never fired for should still be fulfilled; a callback that claims success but your webhook never confirms should not be trusted on its own.
The Same Shape, Different Names: Stripe's PaymentIntent Flow
Stripe's flow follows an identical sequence with different vocabulary. Instead of an "order," Stripe's server-side object is a PaymentIntent, created with the amount and currency exactly like Razorpay's order — and the client only ever receives a client_secret, never anything that could authorize a charge on its own:
// Server: create a PaymentIntent for the order amount
const paymentIntent = await stripe.paymentIntents.create({
amount: 1999, // smallest currency unit - cents for USD
currency: "usd",
automatic_payment_methods: { enabled: true },
});
// send paymentIntent.client_secret to the browser - nothing else
// Browser: confirm the payment using Stripe.js, never touching card data directly
const { error } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement },
});Confirmation happens client-side through Stripe.js, which — just like Razorpay's checkout — is the only piece of code that ever touches the actual card fields, via Stripe Elements. A PaymentIntent then moves through documented statuses as confirmation proceeds: it can land on requires_action if additional authentication (like 3D Secure) is needed, succeeded once payment completes, or requires_payment_method if it fails and the customer needs to try a different card.
And just like Razorpay, Stripe's own documentation explicitly recommends webhooks as the reliable confirmation channel, not the client-side confirmation call alone — for exactly the same browser-can't-be-trusted-to-report-back reason. Stripe's webhook verification uses its own SDK helper rather than raw HMAC code, but it needs the same raw, unparsed request body Razorpay's does:
// Stripe also requires the RAW request body for signature verification
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (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(`Webhook signature verification failed`);
}
if (event.type === "payment_intent.succeeded") {
// fulfil the order - this is the authoritative confirmation
}
res.json({ received: true });
});The event to act on is payment_intent.succeeded — the direct Stripe equivalent of Razorpay's payment.captured. The underlying architecture — create first, confirm through the gateway's own UI, verify a signature, trust the webhook as the authoritative signal — is functionally the same integration shape across both gateways, and largely across the industry, because it's solving the same three-party trust problem every card payment has.
