Standard Checkout Integration Guide
Complete end-to-end guide to integrate Razorpay Standard Checkout. Create orders, verify payments, set up webhooks, capture funds and go live safely.
This is a complete, end-to-end guide to accept payments on your website using Razorpay Standard Checkout. It covers everything from your first test order to going live and monitoring payments in production.
Once you finish integrating Razorpay using this guide, you will be able to accept payments from your customers using Standard Checkout. To see how this compares with the other integration options, refer to the
.What you are responsible for:
- Creating a payment record (Order) before checkout.
- Verifying the payment is genuine after checkout.
- Setting up webhooks so your server knows when money arrives.
- Capturing the payment so it settles to your account.
Razorpay handles the rest.
Handy Tips
If you use WooCommerce, Shopify, WordPress, Magento or another ecommerce platform, use our
instead of building this integration yourself.Customer Your Frontend Your Server Razorpay| | | || 1. Start checkout | | ||-------------------->| 2. Create order | || |---------------------->| 3. POST /v1/orders || | |--------------------->|| | | order_id || |<----------------------|<---------------------|| 4. Pay (modal) | | ||-------------------->| 5. checkout.js opens | || |------------------------------------------------>|| | OTP / bank redirect / UPI approval ||<------------------------------------------------------------------->|| | 6. success (handler / callback_url) || | (payment_id, order_id, signature) || |---------------------->| 7. Verify signature || | | 8. Capture + fulfil || | |--------------------->|| | | 9. Webhook: captured || | |<---------------------|
- Create an order on your server (Step 1) and pass the
order_idto the browser. - Open Checkout with
checkout.js(Step 2). Razorpay handles the payment UI, OTP and redirects. - Verify the signature returned after payment on your server (Step 3).
- Capture and confirm the payment with webhooks (Step 4).
Every payment moves through a set of states. The state tells you exactly when to deliver goods or services and when to hold back.
Watch Out: Authorised is not the same as paid
A payment in the authorized state is not yet in your account. You must capture it, or enable auto-capture, before the capture window expires. Uncaptured payments are automatically refunded by Razorpay. Enable auto-capture in Dashboard → Account & Settings → Payment Capture.
Make sure these are in place before you write any code. Skipping one of them blocks you later.
- Log in at .
- Complete KYC and business verification. This is required before you can go live and accept real payments.
- Confirm the account is not restricted or under review.
API Keys are the credentials your code uses to talk to Razorpay. You need a Key ID and a Key Secret.
- Go to Account & Settings → API Keys in the Dashboard.
- Make sure you are in Test Mode using the toggle at the top of the Dashboard.
- Select Generate Key and note your Key ID (
rzp_test_…) and Key Secret.
Watch Out: Never expose your Key Secret
The Key Secret is used only on your server. Never put it in frontend code, browser JavaScript or any public repository. Your Key ID is safe to use in frontend code.
- A server-side backend (Node.js, Python, Java, PHP, Ruby, Go or .NET) to call the Orders API.
- HTTPS on your website. This is required for Live Mode.
- A valid, unexpired SSL/TLS certificate on your server and webhook endpoint.
- DNS propagated and resolving correctly for your webhook hostname.
Before you show the checkout form, your server must create an Order on Razorpay using the
. Think of it as a locked payment receipt. It records the amount and currency server-side so they cannot be changed by the time the customer pays.Watch Out: Do not skip order creation
Razorpay auto-refunds any payment made without an order_id, so you will never receive the money. Never create Orders in browser-side JavaScript, as your Key Secret would be exposed to anyone who views your page source. Create a fresh Order for every unique payment attempt.
Keep these order rules in mind:
- Orders are immutable. Once created, the amount and currency cannot be changed. If you need a different amount, create a new order.
- Orders do not auto-expire, and passing
expire_byis rejected by the API. - Control expiry by closing the checkout modal with the
timeoutoption.
POST https://api.razorpay.com/v1/ordersAuthentication: HTTP Basic AuthUsername: YOUR_KEY_IDPassword: YOUR_KEY_SECRET
Request parameters
Currency-specific minimum amounts
Watch Out: Maximum single transaction amount
The maximum single transaction amount is ₹5,00,000 (50,000,000 paise). For amounts above this, contact
to discuss enterprise limits.curl -X POST https://api.razorpay.com/v1/orders \-u [YOUR_KEY_ID]:[YOUR_KEY_SECRET] \-H 'content-type:application/json' \-d '{"amount": 50000,"currency": "INR","receipt": "rcpt_001","notes": {"customer_id": "cust_abc123","plan": "gold","source": "web_checkout"},"partial_payment": true,"first_payment_min_amount": 23000,"capture": "automatic"}'
All Razorpay API endpoints return errors in the same structure. The code field identifies the error class, description gives a human-readable explanation and field, when present, identifies the parameter at fault.
{"error": {"code": "BAD_REQUEST_ERROR","description": "Order amount less than minimum amount allowed","source": "business","step": "payment_initiation","reason": "input_validation_failed","metadata": {},"field": "amount"}}
Handy Tips: Save the order in your database
Save the order_id (for example, order_IluGWxBm9U8zJ8) linked to your internal order record. Also save the amount, currency and receipt for reconciliation. You will need the order_id in Step 2 and Step 3.
Notes on multi-attempt orders:
- A customer can attempt payment multiple times against the same
order_id, for example, if their first card is declined and they then try UPI. - The
order.attemptsfield increments with each attempt. - You do not need to create a new order for a retry.
- Create a new order only if the fulfilment scenario changes, such as a different amount or a different customer.
Once your server creates the order and sends the order_id to the browser, load Razorpay's checkout.js and open the payment modal. Razorpay handles the entire payment experience from here, including card entry, UPI, OTP and bank redirects.
<script src="https://checkout.razorpay.com/v1/checkout.js"></script><!-- Load this on every page where the Pay button appears. --><!-- Never self-host this script. Always load it from the Razorpay CDN. -->
There are two ways to open Checkout. Choose one based on how your app is built.
<button id="rzp-button1">Pay</button><script src="https://checkout.razorpay.com/v1/checkout.js"></script><script>var options = {"key": "YOUR_KEY_ID","amount": "50000","currency": "INR","name": "Acme Corp","description": "Gold Plan - Monthly Subscription","image": "https://example.com/your_logo.png","order_id": "order_9A33XWu170gUtm", // from Step 1"callback_url": "https://yourserver.com/payment/callback","prefill": {"name": "Customer Name","email": "customer@example.com","contact": "+919876543210"},"notes": { "address": "Your Office" },"theme": { "color": "#3399cc" },"modal": {"confirm_close": true,"escape": false,"backdropclose": false,"animation": true},"retry": { "enabled": true, "max_count": 4 }};var rzp1 = new Razorpay(options);document.getElementById("rzp-button1").onclick = function(e) {rzp1.open();e.preventDefault();}</script>
Handy Tips: Two things that improve your conversion rate
- Always prefill the customer's phone number (
contact) with the country code, for example,+91XXXXXXXXXX. It pre-fills OTP fields and measurably reduces drop-off. rzp1.open()must be called directly from a user action such as a button click. Browsers block programmatic popup opens.
Yes. Beyond the web checkout.js, Standard Checkout is available for native and cross-platform apps. Order creation and signature verification stay on your server and are identical across web and mobile.
When a payment succeeds, Razorpay returns three fields to the browser. Do not deliver goods or services yet. A malicious actor can forge these fields in the browser, so you must verify them on your server before you fulfil any order.
{"razorpay_payment_id": "pay_29QQoUBi66xm2f","razorpay_order_id": "order_IluGWxBm9U8zJ8","razorpay_signature": "9ef4dffbfd84f1318..."}
Watch Out: Do not fulfil the order yet
These three fields arrive in the browser, which you do not control. A malicious actor can send you fake values. Always verify the signature on your server before updating your database or delivering anything.
The signature is an HMAC-SHA256 hash of the order_id and payment_id, signed with your Key Secret. If it matches what Razorpay generated, the payment is authentic.
// Algorithm: HMAC-SHA256// Input: order_id + "|" + razorpay_payment_id// Key: YOUR_KEY_SECRET// Compare: generated_signature == razorpay_signaturegenerated_signature = HMAC_SHA256(key = YOUR_KEY_SECRET,data = razorpay_order_id + "|" + razorpay_payment_id)if generated_signature == razorpay_signature:# Payment is authentic -> fulfil orderelse:# Signature mismatch -> reject, do NOT fulfil
import hmac, hashlib, osdef verify_signature(order_id, payment_id, received_sig, key_secret):body = f'{order_id}|{payment_id}'expected_sig = hmac.new(key_secret.encode(), body.encode(), hashlib.sha256).hexdigest()# hmac.compare_digest prevents timing attacksreturn hmac.compare_digest(expected_sig, received_sig)# In your Flask/Django view:payment_id = request.json["razorpay_payment_id"]received_sig = request.json["razorpay_signature"]# CRITICAL: get order_id from YOUR database using the real Razorpay callback fieldorder = Order.objects.get(razorpay_order_id=request.json["razorpay_order_id"])KEY_SECRET = os.environ.get('RAZORPAY_KEY_SECRET')if verify_signature(order.razorpay_order_id, payment_id, received_sig, KEY_SECRET):order.mark_paid()else:return JsonResponse({"error": "Signature mismatch"}, status=400)
If you prefer not to compute the HMAC yourself, every Razorpay server SDK ships a verification helper. Pass the order_id from your own database, the razorpay_payment_id and the razorpay_signature.
String secret = "YOUR_KEY_SECRET";JSONObject options = new JSONObject();options.put("razorpay_order_id", order.getRazorpayOrderId());options.put("razorpay_payment_id", razorpayPaymentId);options.put("razorpay_signature", razorpaySignature);boolean status = Utils.verifyPaymentSignature(options, secret);
Watch Out: Three things that protect you
- Use
crypto.timingSafeEqual()(Node.js) orhmac.compare_digest()(Python) to prevent timing attacks. Plain string equality (===) leaks information about how many characters match. - Use the
order_idfrom your own server's database, not therazorpay_order_idreturned in the browser callback, which could be spoofed by a malicious user. - Store
razorpay_payment_id,razorpay_order_idandrazorpay_signaturein your database for audit trails and idempotency checks.
Signature verification confirms the payment is genuine, but a payment in the authorized state is not yet in your account. You need to capture it, and you need a reliable way to know when captured status is confirmed, even if the customer closes their browser tab immediately after paying. Webhooks are Razorpay's way of telling your server that a payment is done without relying on the browser.
- Log in to Dashboard → Account & Settings → Payment Capture.
- Select Change next to Automatic Capture.
- Select Automatic Capture and set the time window. The default is immediate.
- Select Save.
Handy Tips: How capture settings behave
- Capture settings only work if you have integrated the Orders API. Payments created without an
order_iddo not respect capture settings. - Capture settings on the Orders API take precedence over Dashboard settings. Override per order by passing
"capture": "automatic"or"capture": "manual"in the order creation request. - For manual capture, call the (
POST /v1/payments/{payment_id}/capture) with the amount in paise before the capture window expires.
Configure webhooks so your server is notified the moment a payment completes, regardless of what happens in the customer's browser.
- Log in to Dashboard → Account & Settings → Webhooks.
- Select + Add New Webhook.
- Enter your publicly accessible HTTPS endpoint URL.
- Set a strong Webhook Secret of at least 32 random characters. You use this to validate incoming events.
- Subscribe to the minimum events below, then select Save.
Events to subscribe (minimum recommended)
Watch Out: Webhook delivery behaviour
- Timeout: Razorpay waits a maximum of 5 seconds for your endpoint to respond. If it does not receive an HTTP 200 within 5 seconds, it marks the delivery as failed.
- Retries: Failed deliveries are retried up to 24 times over a 24-hour period, roughly once per hour. After 24 attempts with no 200, the event is dropped.
- Async pattern (mandatory): Your endpoint must return HTTP 200 immediately, before doing any processing. Enqueue the event body to a job queue (Redis, SQS or similar) and process it in a background worker. Never do database writes, API calls or email sending inline before returning 200.
- Replay attacks: Each event includes a
created_attimestamp. Reject events wherecreated_atis more than 5 minutes in the past. - IP whitelisting (recommended): Razorpay sends webhooks from a fixed set of IPs. Whitelist these in your firewall for extra security. See for the current IP list.
Every webhook payload from Razorpay is signed. You must verify the X-Razorpay-Signature header on each incoming request, or anyone could send fake events to your endpoint.
Razorpay provides a signature validation helper in every
. Pass the raw request body, theX-Razorpay-Signature header value and your webhook secret to the helper for your stack:
/* Java SDK: https://github.com/razorpay/razorpay-java */Utils.verifyWebhookSignature(webhookBody, webhookSignature, webhookSecret);
The signature check is only the first step. In production, verify the signature synchronously, return 200 immediately, then process the event in the background so a slow handler does not trigger Razorpay's retries. The following Node.js (Express) pattern shows the full flow, including a replay-attack guard and idempotent processing:
// Express middleware: raw body required for HMACapp.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {const secret = process.env.WEBHOOK_SECRET;const received = req.headers['x-razorpay-signature'];// 1. Verify signature FIRST (fast, synchronous)try {Razorpay.validateWebhookSignature(req.body.toString(), received, secret);} catch (err) {return res.status(400).send('Invalid signature');}const event = JSON.parse(req.body.toString());// 2. Replay-attack guard: reject stale eventsconst eventAge = Date.now() / 1000 - event.created_at;if (eventAge > 300) { // older than 5 minutesreturn res.status(200).send('Stale event ignored'); // still return 200}// 3. Return 200 IMMEDIATELY, before any heavy processingres.status(200).json({ received: true });// 4. Enqueue for background processing (fire-and-forget with error logging).// Do NOT await here: the response is already committed, so an unhandled throw// would make Express attempt a second response write (headers already sent).jobQueue.enqueue('process_razorpay_event', event).catch(err => {console.error('Failed to enqueue webhook event, manual replay required', {eventId: event.id,error: err.message,});// Alert ops: Razorpay will NOT retry (200 was returned), so this event is lost.});});// Background worker (runs outside the request)async function processRazorpayEvent(event) {const paymentId = event.payload.payment?.entity?.id;// Idempotency: atomic insert-if-not-exists, relies on a unique constraint on event.id.// A single operation leaves no window for a race between the check and the insert.try {await db.processedEvents.insert(event.id);} catch (err) {// A unique constraint violation means another worker already claimed this event.if (err.code === 'UNIQUE_VIOLATION' || err.code === '23505') return;throw err; // unexpected error, re-throw}switch (event.event) {case 'payment.captured':await db.orders.markPaid(paymentId);await emailService.sendConfirmation(paymentId);break;case 'payment.failed':await db.orders.markFailed(paymentId);await emailService.sendFailureAlert(paymentId);break;case 'refund.failed':await alertOpsTeam(paymentId, 'refund_failed');break;case 'payment.dispute.created':await db.orders.flagDisputed(paymentId);await alertOpsTeam(paymentId, 'dispute_raised');break;}}
Watch Out: Pass the raw body to HMAC
Pass the raw request body (bytes) to the HMAC function. Do not parse the JSON first. Parsing changes the byte sequence and causes a signature mismatch, even if the content is identical. In Express, use express.raw(), not express.json(), on the webhook route.
Late Auth occurs when a payment is authorised by the bank after your order session has already expired on your side. For example, the customer starts payment, your order times out after 10 minutes, but the bank approves the transaction 12 minutes later. Without handling this, the customer has been debited but you have not fulfilled, which leads to a dispute.
How Late Auth happens
- The customer starts a payment (UPI, netbanking or card with OTP).
- Your server-side order expires, for example, you mark it abandoned after 10 minutes.
- The bank processes slowly and authorises the payment after your timeout.
- Razorpay sends a
payment.authorizedwebhook to your endpoint. - Your system does not recognise the order as active, and the customer is charged with no fulfilment.
// When you receive payment.authorized for an "expired" order:async function handleLateAuth(event) {const payment = event.payload.payment.entity;const orderId = payment.order_id;const order = await db.orders.findByRazorpayId(orderId);if (!order) {// Unknown order - refund immediatelyawait razorpay.payments.refund(payment.id, { amount: payment.amount });await alertOpsTeam('late_auth_unknown_order', payment.id);return;}if (order.status === 'expired' || order.status === 'cancelled') {// Order is expired - refund and notify customerawait razorpay.payments.refund(payment.id, { amount: payment.amount });await emailService.sendLateAuthRefundNotice(order.customerEmail, payment.id);await db.orders.markLateAuthRefunded(orderId, payment.id);return;}if (order.status === 'created' || order.status === 'pending') {// Order still valid - fulfil normallyawait db.orders.markPaid(orderId, payment.id);await emailService.sendConfirmation(order.customerEmail);}}
All testing is done in Test Mode using test API keys, so no real money moves. Each test scenario corresponds to a real situation that will cost you money or customer trust if it is not handled correctly.
Handy Tips: Test Mode setup
Ensure your Dashboard is in Test Mode using the toggle in the top bar. Use only Test Mode API Keys (rzp_test_XXXXXXXXXXXXXXXX) in your code during testing.
Handy Tips: OTP rules in Test Mode
- A 4-digit OTP always succeeds (for example, 1234 or 0000).
- A 5-digit OTP starting with 1 always fails (for example, 12345).
- A 6 to 10 digit OTP always succeeds.
- For netbanking, select any bank and you will see Success and Failure buttons on the mock bank page.
- For wallets, any test amount works. Select Pay on the mock wallet page.
Run every scenario below in Test Mode before moving to go-live.
All Tier 1 items must be verified before you switch to Live API keys. This checklist is your merchant audit record.
- KYC documents submitted and approved on the Dashboard.
- Business category and website URL correctly set in the Dashboard.
- Live Mode API Keys generated from the Dashboard, not the test keys.
- Test API keys removed from all production environments and config files.
KEY_SECRETstored in environment variables, not hardcoded in source code.- Source code does not contain any API secrets, verified via
git grepor equivalent.
- Every payment attempt creates a fresh Order via the Orders API.
order_idis passed correctly to the Checkout options.- Amount in the Order API matches the amount in the Checkout options exactly, both in paise.
- Currency in the Order API matches the currency in the Checkout options.
checkout.jsloaded from the Razorpay CDN, not self-hosted.- Checkout opens only on a user action (button click), not auto-open on page load.
- Customer prefill (name, email, contact with country code) populated.
- Signature verification implemented server-side using HMAC-SHA256.
order_idused for verification comes from the server DB, not the client callback.- Order fulfilled only after signature verification passes.
- Timing-safe string comparison used (
timingSafeEqualorhmac.compare_digest). razorpay_payment_idstored in the DB for deduplication and idempotency.- API Key Secret never exposed to the frontend or logged in application logs.
- HTTPS enforced on all payment pages and callback or webhook URLs.
- SSL certificate valid and not expiring within 30 days.
Capture, so you actually get the money:
- Auto-capture configured in the Dashboard, or the Orders API
captureparameter set. - All authorised payments will be captured within the capture window (confirmed).
- Goods or services not delivered before the payment reaches
capturedstate. - Settlement schedule confirmed with the merchant.
Webhooks, so your server knows when you have been paid:
- Webhook URL configured in the Dashboard (Live Mode), not just Test Mode.
- Webhook secret configured and signature verified on every incoming event.
payment.capturedandpayment.failedevents subscribed and handled.refund.failedandpayment.dispute.createdsubscribed and handled.- Webhook endpoint returns HTTP 200 within 5 seconds. Heavy processing is async.
- Duplicate webhook events handled idempotently (
event.idstored and checked). - Replay attack guard: events older than 5 minutes are discarded.
- DNS resolves correctly for the webhook URL and the SSL cert is valid.
- All Test Mode scenarios from Step 5 pass with test keys.
- Live API keys generated from the Dashboard (Live Mode).
- Test API keys replaced with Live keys in all environments.
- Environment variables updated, not hardcoded keys.
- A live test transaction completed and verified in the Dashboard.
- Payment visible as Captured in the Live Dashboard.
- Webhook received and processed for the live test transaction.
- Rollback plan documented and tested (see the Appendix).
- Payment failure shown to the customer with a clear message and retry option.
- Checkout modal dismiss handled gracefully.
- Loading states shown while the Order is being created server-side.
- Double-submit prevention on the Pay button (disabled after first click).
- Mobile responsiveness of the payment page verified.
- Timeout handling implemented if the checkout session expires.
- Late Auth scenario handled (payment authorised after the order expires).
- Business logo set in the
imageparameter and theme colour matches brand guidelines.
Handy Tips: API rate limits
The Orders API and Payments API are limited to roughly 100 requests per minute per key. Webhook deliveries are retried up to 24 times over 24 hours. Contact Razorpay to request a higher limit before load testing, and use a dedicated load test key rather than production keys.
Your integration is live. Watch these metrics so you can act before small problems become customer-facing ones.
Daily operations checklist
- Check Dashboard → Payments for any payments stuck in the
authorizedstate. - Review webhook delivery logs for failed retries.
- Check for any
payment.dispute.createdevents and respond within 7 days. - Verify settlement amounts match expected totals.
- Monitor
refund.failedevents. Each requires manual follow-up.
Fill this in before switching to Live keys. If something goes wrong, you need to act in seconds, not minutes.
Log enough to debug payments, but not so much that you create a PCI compliance risk.
logger.info({event: "payment_verified",order_id: order.razorpayOrderId,payment_id: razorpay_payment_id,result: "success",duration_ms: Date.now() - startTime// DO NOT include: signature, key_secret, card_number, cvv});
Yes. Create a fresh Order via the Orders API for every unique payment attempt. Razorpay auto-refunds any payment made without an order_id, so you would never receive the money. You can reuse the same order_id if a customer retries after a failure, but create a new order if the amount or customer changes.
authorized means the customer has paid and the funds are held by Razorpay, but the money is not yet in your account. captured means the money is confirmed and queued for settlement. Deliver goods or services only after the payment reaches captured. Capture an authorised payment, or enable auto-capture, before the capture window expires, or it is auto-refunded.
The three fields returned after a successful payment (razorpay_payment_id, razorpay_order_id and razorpay_signature) arrive in the browser, which you do not control. A malicious actor can forge them. Verifying the HMAC-SHA256 signature on your server with your Key Secret proves the payment is genuine before you fulfil the order. Skipping this is the most common cause of fraudulent orders.
The browser callback does not fire if the customer closes the tab immediately after paying. Webhooks notify your server independently of the browser, so your fulfilment logic works reliably. Use webhooks as the source of truth for fulfilment and never rely on the browser callback alone.
Late Auth happens when the bank authorises a payment after your order session has expired. The customer is charged but your system no longer recognises the order. Handle it in your payment.authorized webhook: if the order is still active, fulfil normally; if it is expired, cancelled or already fulfilled by another payment, refund the customer immediately and notify them. See Step 4.5 for the handling pattern.
Switch your Dashboard to Test Mode and use Test Mode API Keys (rzp_test_…). Use the test cards and UPI VPAs in Step 5 to simulate success, failure, decline and pending scenarios. No real money moves in Test Mode. Run the full test checklist before switching to Live keys.
The two most common causes are: the payment was not captured before the capture window expired, so it was auto-refunded; or the order was created without an order_id being passed to Checkout. Always create the Order first, pass the order_id and enable auto-capture. Check Dashboard → Payments for payments stuck in the authorized state.
Is this integration guide useful?
ON THIS PAGE