> ## Documentation Index
> Fetch the complete documentation index at: https://razorpay-881012b3.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Standard Checkout Integration Guide | Razorpay

> Complete end-to-end guide to integrate Razorpay Standard Checkout. Create orders, verify payments, set up webhooks, capture funds and go live safely.

<div style={{display:"flex",flexWrap:"wrap",alignItems:"center",gap:"0.35rem 0.9rem",border:"1px solid rgba(128,128,128,0.28)",borderRadius:"0.5rem",padding:"0.45rem 0.75rem",margin:"0 0 1.25rem",fontSize:"0.875rem"}}>
  <span style={{fontWeight:600}}>Available in</span>
  <span>🇮🇳 India</span>
</div>

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.

<Info>
  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 [types of checkout available at Razorpay](/docs/payments/payment-gateway#types-of-checkout).
</Info>

**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.

| What YOU build                                          | What Razorpay handles for you                                                  |
| ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Server: create the Order and verify the signature       | Payment UI modal and all payment methods (card, UPI, netbanking, wallets, EMI) |
| Client: load `checkout.js` and pass the `order_id`      | OTP flow, bank redirects and payment retries                                   |
| Webhook listener for asynchronous payment confirmations | PCI-DSS compliance and card data security                                      |
| Post-payment: capture and fulfil the order              | Settlement to your bank account                                                |

<Info>
  **Handy Tips**

  If you use WooCommerce, Shopify, WordPress, Magento or another ecommerce platform, use our [ecommerce plugins](/docs/payments/payment-gateway/ecommerce-plugins) instead of building this integration yourself.
</Info>

## How does the payment flow work?

```bash Payment Sequence theme={null}
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 |
   |                     |                       |<---------------------|
```

1. **Create an order** on your server (Step 1) and pass the `order_id` to the browser.
2. **Open Checkout** with `checkout.js` (Step 2). Razorpay handles the payment UI, OTP and redirects.
3. **Verify the signature** returned after payment on your server (Step 3).
4. **Capture and confirm** the payment with webhooks (Step 4).

## What are the payment states in Razorpay Checkout?

Every payment moves through a set of states. The state tells you exactly when to deliver goods or services and when to hold back.

| State        | What it means                                                 | Your action                                                                                                       |
| ------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `created`    | Order exists on Razorpay. The customer has not paid yet.      | Pass the `order_id` to Checkout.                                                                                  |
| `authorized` | The customer completed payment. Funds are held by Razorpay.   | Capture before the auto-refund window expires or the payment is auto-refunded. Enable auto-capture to avoid this. |
| `captured`   | Money is confirmed and queued for settlement to your account. | Safe to deliver goods or services now.                                                                            |
| `failed`     | The payment was not successful.                               | Do not fulfil. Show the customer an error with a retry option.                                                    |
| `refunded`   | Money has been returned to the customer.                      | No further action needed.                                                                                         |

<Warning>
  **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**.
</Warning>

## What do you need before you start?

Make sure these are in place before you write any code. Skipping one of them blocks you later.

<AccordionGroup>
  <Accordion title="1. Razorpay Dashboard access">
    * Log in at [dashboard.razorpay.com](https://dashboard.razorpay.com).
    * 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.
  </Accordion>

  <Accordion title="2. Generate Test API Keys">
    API Keys are the credentials your code uses to talk to Razorpay. You need a Key ID and a Key Secret.

    1. Go to **Account & Settings** → **API Keys** in the Dashboard.
    2. Make sure you are in **Test Mode** using the toggle at the top of the Dashboard.
    3. Select **Generate Key** and note your Key ID (`rzp_test_…`) and Key Secret.

    <Warning>
      **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.
    </Warning>
  </Accordion>

  <Accordion title="3. Technical prerequisites">
    * 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.
  </Accordion>
</AccordionGroup>

## Step 1: How do you create an order before the customer pays?

Before you show the checkout form, your server must create an Order on Razorpay using the [Orders API](/docs/api/orders/create). 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.

<Warning>
  **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.
</Warning>

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_by` is rejected by the API.
* Control expiry by closing the checkout modal with the `timeout` option.

### Create an Order (server-side API call)

```bash API Endpoint theme={null}
POST https://api.razorpay.com/v1/orders
Authentication: HTTP Basic Auth
Username: YOUR_KEY_ID
Password: YOUR_KEY_SECRET
```

**Request parameters**

| Parameter                  | Required?   | Type    | Description                                                                                                                                                        |
| -------------------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `amount`                   | Mandatory   | integer | Amount in the smallest currency subunit (paise for INR). Minimum 100 (₹1), maximum 50,000,000 (₹5,00,000 or ₹5 lakh). Never use decimals. Example: ₹500 = `50000`. |
| `currency`                 | Mandatory   | string  | ISO 4217 code. For example, INR (min 100 paise), USD (min 50 cents). See the currency minimums table below.                                                        |
| `receipt`                  | Optional    | string  | Your internal reference. Maximum 40 characters.                                                                                                                    |
| `notes`                    | Optional    | object  | Key-value metadata. Up to 15 pairs, 256 characters per key and value. Example: `{"customer_id": "cust_123", "plan": "gold"}`.                                      |
| `partial_payment`          | Optional    | boolean | Allow the customer to pay in instalments. Default: `false`.                                                                                                        |
| `first_payment_min_amount` | Conditional | integer | Minimum first instalment in paise. Required when `partial_payment` is `true`. Example: `23000` = ₹230.                                                             |
| `capture`                  | Optional    | string  | Per-order capture setting. Use `automatic` to capture immediately or `manual` to call the capture API yourself. Overrides Dashboard settings for this order.       |

**Currency-specific minimum amounts**

| Currency         | Code | Minimum amount | Notes                                                                |
| ---------------- | ---- | -------------- | -------------------------------------------------------------------- |
| Indian Rupee     | INR  | 100 paise (₹1) | Most common. Any amount below 100 paise returns `BAD_REQUEST_ERROR`. |
| US Dollar        | USD  | 50 cents       | International. Use for cross-border payments.                        |
| Euro             | EUR  | 50 cents       | NA                                                                   |
| British Pound    | GBP  | 30 pence       | NA                                                                   |
| Singapore Dollar | SGD  | 50 cents       | NA                                                                   |

<Warning>
  **Watch Out: Maximum single transaction amount**

  The maximum single transaction amount is ₹5,00,000 (50,000,000 paise). For amounts above this, contact [Razorpay Support](https://razorpay.com/support) to discuss enterprise limits.
</Warning>

```bash cURL theme={null}
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"
 }'
```

```javascript Node.js theme={null}
const Razorpay = require('razorpay');
const instance = new Razorpay({ key_id: 'YOUR_KEY_ID', key_secret: 'YOUR_SECRET' });

instance.orders.create({
 amount: 50000,
 currency: 'INR',
 receipt: 'rcpt_001',
 notes: { customer_id: 'cust_abc123', plan: 'gold' }
});
```

```python Python theme={null}
import razorpay
client = razorpay.Client(auth=("YOUR_KEY_ID", "YOUR_SECRET"))

client.order.create({
 "amount": 50000,
 "currency": "INR",
 "receipt": "rcpt_001",
 "notes": { "customer_id": "cust_abc123", "plan": "gold" }
})
```

```php PHP theme={null}
$api = new Api($key_id, $secret);

$api->order->create([
 'amount' => 50000,
 'currency' => 'INR',
 'receipt' => 'rcpt_001',
 'notes' => ['customer_id' => 'cust_abc123', 'plan' => 'gold']
]);
```

```java Java theme={null}
RazorpayClient razorpay = new RazorpayClient("YOUR_KEY_ID", "YOUR_SECRET");

JSONObject orderRequest = new JSONObject();
orderRequest.put("amount", 50000);
orderRequest.put("currency", "INR");
orderRequest.put("receipt", "rcpt_001");
Order order = razorpay.orders.create(orderRequest);
```

```ruby Ruby theme={null}
require "razorpay"
Razorpay.setup('YOUR_KEY_ID', 'YOUR_SECRET')

Razorpay::Order.create(amount: 50000, currency: 'INR', receipt: 'rcpt_001')
```

```csharp .NET theme={null}
RazorpayClient client = new RazorpayClient("YOUR_KEY_ID", "YOUR_SECRET");

Dictionary<string, object> options = new Dictionary<string, object>();
options.Add("amount", 50000);
options.Add("currency", "INR");
options.Add("receipt", "rcpt_001");
Order order = client.Order.Create(options);
```

```go Go theme={null}
import ( razorpay "github.com/razorpay/razorpay-go" )
client := razorpay.NewClient("YOUR_KEY_ID", "YOUR_SECRET")

data := map[string]interface{}{
 "amount": 50000,
 "currency": "INR",
 "receipt": "rcpt_001",
}
body, err := client.Order.Create(data, nil)
```

```json Success Response theme={null}
{
 "id": "order_IluGWxBm9U8zJ8",
 "entity": "order",
 "amount": 50000,
 "amount_paid": 0,
 "amount_due": 50000,
 "currency": "INR",
 "receipt": "rcpt_001",
 "offer_id": null,
 "status": "created",
 "attempts": 0,
 "partial_payment": true,
 "first_payment_min_amount": 23000,
 "notes": {
 "customer_id": "cust_abc123",
 "plan": "gold",
 "source": "web_checkout"
 },
 "created_at": 1642662092
}
```

### What does a Razorpay API error look like?

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.

```json Error Response theme={null}
{
 "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"
 }
}
```

| `error.code`        | Meaning               | Common cause                                                              |
| ------------------- | --------------------- | ------------------------------------------------------------------------- |
| `BAD_REQUEST_ERROR` | Invalid input         | Missing required parameter, amount below the minimum or invalid currency. |
| `GATEWAY_ERROR`     | Payment gateway issue | Bank refused the transaction or the card was declined.                    |
| `SERVER_ERROR`      | Razorpay server error | Retry with exponential backoff. Contact support if it persists.           |

<Info>
  **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.
</Info>

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.attempts` field 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.

## Step 2: How do you show the payment form to your 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.

### Load the Razorpay script

```html Load Checkout theme={null}
<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. -->
```

### Which approach should you use to open Checkout?

There are two ways to open Checkout. Choose one based on how your app is built.

| Approach                    | Best for                                          | How it works                                                                                                  |
| --------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Callback URL (Option A)     | PHP, Django, Rails or any server-rendered app     | Razorpay POSTs payment details to your server URL on success. Simpler to implement.                           |
| Handler function (Option B) | React, Angular, Vue or custom JS single-page apps | The payment response is returned as a JavaScript object in the browser. Gives you more control over the flow. |

```html Option A - Callback URL theme={null}
<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>
```

```html Option B - Handler Function (SPAs) theme={null}
<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
 "handler": function(response) {
 // Send ALL THREE fields to your server for verification (Step 3)
 fetch("/payment/verify", {
 method: "POST",
 headers: { "Content-Type": "application/json" },
 body: JSON.stringify({
 razorpay_payment_id: response.razorpay_payment_id,
 razorpay_order_id: response.razorpay_order_id,
 razorpay_signature: response.razorpay_signature
 })
 });
 },
 "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);
rzp1.on("payment.failed", function(response) {
 console.error("Payment failed:", {
 code: response.error.code,
 description: response.error.description,
 source: response.error.source,
 step: response.error.step,
 reason: response.error.reason,
 order_id: response.error.metadata.order_id,
 payment_id: response.error.metadata.payment_id,
 });
 // Show the error to the customer and offer a retry.
});
document.getElementById("rzp-button1").onclick = function(e) {
 rzp1.open();
 e.preventDefault();
}
</script>
```

<Info>
  **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.
</Info>

### Checkout options reference

| Option                     | Required?   | Description                                                                                                              |
| -------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `key`                      | Mandatory   | Your Key ID from the Dashboard. Use the Test key during development and the Live key in production.                      |
| `amount`                   | Mandatory   | Amount in paise (for INR). Must match your Order amount exactly. A mismatch causes an error.                             |
| `currency`                 | Mandatory   | 3-letter ISO currency code. Must match your Order currency.                                                              |
| `name`                     | Mandatory   | Your business or brand name shown on the checkout form.                                                                  |
| `order_id`                 | Mandatory   | The `id` field from the Orders API response in Step 1. Do not fabricate this.                                            |
| `handler` / `callback_url` | Mandatory   | Use one of these. Use `handler` for SPAs (JS callback) or `callback_url` for server-rendered apps (POST to your server). |
| `description`              | Recommended | Short description of the purchase shown below your business name. Example: "Gold Plan - Monthly Subscription".           |
| `image`                    | Recommended | HTTPS URL to your brand logo, shown at the top of the checkout modal. Recommended size: 256x256px.                       |
| `prefill.contact`          | Recommended | Customer phone with the country code (`+91XXXXXXXXXX`). Reduces form fill time and boosts conversions.                   |
| `theme.color`              | Optional    | Your brand hex colour for the checkout modal accents and CTA button. Example: `#3399cc`.                                 |
| `modal.confirm_close`      | Optional    | Set `true` to show a confirmation prompt when the customer tries to close the modal.                                     |
| `modal.escape`             | Optional    | Set `false` to disable closing the modal with the Escape key. Default: `true`.                                           |
| `modal.backdropclose`      | Optional    | Set `false` to prevent closing the modal by clicking outside it. Default: `true`.                                        |
| `modal.animation`          | Optional    | Set `false` to disable the open and close animation. Default: `true`.                                                    |
| `timeout`                  | Optional    | Session expiry in seconds. The customer cannot pay after this time. Use it to enforce order expiry.                      |
| `retry.enabled`            | Optional    | Set `false` to disable the built-in retry option after a failed payment. Default: `true`.                                |
| `retry.max_count`          | Optional    | Maximum number of retry attempts allowed. Default: 4. Set to 0 to disallow retries.                                      |

### Can you integrate Standard Checkout on mobile apps?

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.

<CardGroup cols={2}>
  <Card title="Android" icon="dev-sign" href="/docs/payments/payment-gateway/android-integration/standard">
    Standard Checkout for native Android apps.
  </Card>

  <Card title="iOS" icon="dev-sign" href="/docs/payments/payment-gateway/ios-integration/standard">
    Standard Checkout for native iOS apps.
  </Card>

  <Card title="React Native" icon="dev-sign" href="/docs/payments/payment-gateway/react-native-integration/standard">
    Standard Checkout for React Native apps.
  </Card>

  <Card title="Flutter" icon="dev-sign" href="/docs/payments/payment-gateway/flutter-integration/standard">
    Standard Checkout for Flutter apps.
  </Card>
</CardGroup>

## Step 3: How do you verify the payment is genuine?

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.

```json What Razorpay returns after a successful payment theme={null}
{
 "razorpay_payment_id": "pay_29QQoUBi66xm2f",
 "razorpay_order_id": "order_IluGWxBm9U8zJ8",
 "razorpay_signature": "9ef4dffbfd84f1318..."
}
```

<Warning>
  **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.
</Warning>

### Verify the signature on your server (mandatory)

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.

```bash Verification Logic theme={null}
// Algorithm: HMAC-SHA256
// Input: order_id + "|" + razorpay_payment_id
// Key:   YOUR_KEY_SECRET
// Compare: generated_signature == razorpay_signature

generated_signature = HMAC_SHA256(
 key  = YOUR_KEY_SECRET,
 data = razorpay_order_id + "|" + razorpay_payment_id
)

if generated_signature == razorpay_signature:
 # Payment is authentic -> fulfil order
else:
 # Signature mismatch -> reject, do NOT fulfil
```

```javascript Sample Code - Node.js theme={null}
const crypto = require('crypto');

function verifyPaymentSignature(orderId, paymentId, receivedSig, keySecret) {
 const body = orderId + '|' + paymentId;
 const expectedSig = crypto
 .createHmac('sha256', keySecret)
 .update(body)
 .digest('hex');
 // timing-safe comparison to prevent timing attacks
 return crypto.timingSafeEqual(
 Buffer.from(expectedSig, 'hex'),
 Buffer.from(receivedSig, 'hex')
 );
}

// In your POST /payment/verify handler:
const { razorpay_payment_id, razorpay_order_id, razorpay_signature } = req.body;

// CRITICAL: look up the order by razorpay_order_id, but pass order.razorpayOrderId
// (from YOUR database, not from req.body) to verifyPaymentSignature. That is what
// prevents a spoofed order_id from passing verification.
const order = await db.orders.findByRazorpayOrderId(razorpay_order_id);
if (!order) return res.status(404).json({ error: 'Order not found' });

const isValid = verifyPaymentSignature(
 order.razorpayOrderId, // <-- from YOUR database
 razorpay_payment_id,
 razorpay_signature,
 process.env.RAZORPAY_KEY_SECRET
);

if (isValid) {
 // Update order status to PAID in your DB
 // Send confirmation email / trigger fulfilment
} else {
 return res.status(400).json({ error: 'Signature verification failed' });
}
```

```python Sample Code - Python theme={null}
import hmac, hashlib, os

def 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 attacks
 return 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 field
order = 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`.

```javascript Node.js theme={null}
const { validatePaymentVerification } = require('razorpay/dist/utils/razorpay-utils');
validatePaymentVerification(
 { order_id: order.razorpayOrderId, payment_id: razorpay_payment_id },
 razorpay_signature,
 process.env.RAZORPAY_KEY_SECRET
);
```

```java Java theme={null}
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);
```

```php PHP theme={null}
$api = new Api($key_id, $secret);

$api->utility->verifyPaymentSignature([
 'razorpay_order_id' => $razorpayOrderId,
 'razorpay_payment_id' => $razorpayPaymentId,
 'razorpay_signature' => $razorpaySignature
]);
```

```python Python theme={null}
import razorpay
client = razorpay.Client(auth=("YOUR_KEY_ID", "YOUR_SECRET"))

client.utility.verify_payment_signature({
 'razorpay_order_id': razorpay_order_id,
 'razorpay_payment_id': razorpay_payment_id,
 'razorpay_signature': razorpay_signature
})
```

```ruby Ruby theme={null}
require "razorpay"
Razorpay.setup('YOUR_KEY_ID', 'YOUR_SECRET')

Razorpay::Utility.verify_payment_signature({
 razorpay_order_id: razorpay_order_id,
 razorpay_payment_id: razorpay_payment_id,
 razorpay_signature: razorpay_signature
})
```

```csharp .NET theme={null}
Dictionary<string, string> options = new Dictionary<string, string>();
options.Add("razorpay_order_id", razorpayOrderId);
options.Add("razorpay_payment_id", razorpayPaymentId);
options.Add("razorpay_signature", razorpaySignature);

Utils.verifyPaymentSignature(options);
```

```go Go theme={null}
params := map[string]interface{}{
 "razorpay_order_id": razorpayOrderId,
 "razorpay_payment_id": razorpayPaymentId,
}
signature := razorpaySignature
secret := "YOUR_KEY_SECRET"
utils.VerifyPaymentSignature(params, signature, secret)
```

<Warning>
  **Watch Out: Three things that protect you**

  1. Use `crypto.timingSafeEqual()` (Node.js) or `hmac.compare_digest()` (Python) to prevent timing attacks. Plain string equality (`===`) leaks information about how many characters match.
  2. Use the `order_id` from your own server's database, not the `razorpay_order_id` returned in the browser callback, which could be spoofed by a malicious user.
  3. Store `razorpay_payment_id`, `razorpay_order_id` and `razorpay_signature` in your database for audit trails and idempotency checks.
</Warning>

## Step 4: How do you know when you have actually been paid?

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.

### Enable auto-capture (recommended for most merchants)

1. Log in to Dashboard → **Account & Settings** → **Payment Capture**.
2. Select **Change** next to Automatic Capture.
3. Select **Automatic Capture** and set the time window. The default is immediate.
4. Select **Save**.

<Info>
  **Handy Tips: How capture settings behave**

  * Capture settings only work if you have integrated the Orders API. Payments created without an `order_id` do 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 [Capture Payment API](/docs/api/payments/capture) (`POST /v1/payments/{payment_id}/capture`) with the amount in paise before the capture window expires.
</Info>

### Set up webhooks

Configure webhooks so your server is notified the moment a payment completes, regardless of what happens in the customer's browser.

1. Log in to Dashboard → **Account & Settings** → **Webhooks**.
2. Select **+ Add New Webhook**.
3. Enter your publicly accessible HTTPS endpoint URL.
4. Set a strong Webhook Secret of at least 32 random characters. You use this to validate incoming events.
5. Subscribe to the minimum events below, then select **Save**.

**Events to subscribe (minimum recommended)**

| Event                     | When it fires                                             | Why you need it                                                        |
| ------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------- |
| `payment.authorized`      | The customer completes payment and funds are held.        | Starting point for your payment pipeline.                              |
| `payment.captured`        | The payment is settled and funds are released for payout. | The event that tells you it is safe to deliver goods or services.      |
| `payment.failed`          | A payment attempt failed.                                 | Lets you alert the customer and trigger a retry.                       |
| `payment.dispute.created` | A chargeback or dispute has been raised.                  | Lets you pause fulfilment and respond to the dispute in the Dashboard. |
| `order.paid`              | All payments against an order are complete.               | Useful for partial payment orders.                                     |
| `refund.created`          | A refund has been initiated.                              | Lets you update your order management system.                          |
| `refund.failed`           | A refund could not be processed.                          | Requires manual intervention. Notify the customer to contact support.  |

<Warning>
  **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_at` timestamp. Reject events where `created_at` is 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 [Webhooks](/docs/webhooks) for the current IP list.
</Warning>

### Validate every incoming webhook

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 [language SDK](/docs/payments/server-integration). Pass the raw request body, the `X-Razorpay-Signature` header value and your webhook secret to the helper for your stack:

```java Java theme={null}
/* Java SDK: https://github.com/razorpay/razorpay-java */
Utils.verifyWebhookSignature(webhookBody, webhookSignature, webhookSecret);

```

```php PHP theme={null}
/* PHP SDK: https://github.com/razorpay/razorpay-php */
use Razorpay\Api\Api;
$api = new Api("[YOUR_KEY_ID]", "[YOUR_KEY_SECRET]");

$api->utility->verifyWebhookSignature($webhookBody, $webhookSignature, $webhookSecret);

```

```python Python theme={null}
# Python SDK: https://github.com/razorpay/razorpay-python
import razorpay
client = razorpay.Client(auth=("[YOUR_KEY_ID]", "[YOUR_KEY_SECRET]"))

client.utility.verify_webhook_signature(webhook_body, webhook_signature, webhook_secret)

```

```ruby Ruby theme={null}
# Ruby SDK: https://github.com/razorpay/razorpay-ruby
require "razorpay"

Razorpay::Utility.verify_webhook_signature(webhook_body, webhook_signature, webhook_secret)

```

```c .NET theme={null}
/* .NET SDK: https://github.com/razorpay/razorpay-dot-net */
Utils.verifyWebhookSignature(webhookBody, webhookSignature, webhookSecret);

```

```go Go theme={null}
/* Go SDK: https://github.com/razorpay/razorpay-go */
body := utils.VerifyWebhookSignature(webhookBody, webhookSignature, webhookSecret)

```

```nodejs Node.js theme={null}
/* Node.js SDK: https://github.com/razorpay/razorpay-node */
const { validateWebhookSignature } = require('razorpay/dist/utils/razorpay-utils')

validateWebhookSignature(JSON.stringify(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:

```javascript Correct Async Webhook Handler Pattern theme={null}
// Express middleware: raw body required for HMAC
app.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 events
 const eventAge = Date.now() / 1000 - event.created_at;
 if (eventAge > 300) { // older than 5 minutes
 return res.status(200).send('Stale event ignored'); // still return 200
 }

 // 3. Return 200 IMMEDIATELY, before any heavy processing
 res.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;
 }
}
```

<Warning>
  **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.
</Warning>

### How do you handle webhook edge cases?

| Scenario                             | What happens                                               | How to handle it                                                                                                                            |
| ------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Duplicate webhook events             | The same event can fire more than once (up to 24 retries). | Check `event.id` (`x-razorpay-event-id`). Store processed event IDs and skip duplicates. Otherwise you may fulfil the same order twice.     |
| Out-of-order delivery                | `payment.captured` may arrive before `payment.authorized`. | Always check the current payment state via the [Fetch Payment API](/docs/api/payments/fetch-with-id) before acting. Do not assume sequence. |
| Customer closes browser tab          | The browser callback (handler) never fires.                | Rely on webhooks for all fulfilment. Never use the browser callback alone for order confirmation.                                           |
| Webhook not received within your SLA | Network delays or outages happen.                          | Poll the [Fetch Payment API](/docs/api/payments/fetch-with-id) (`GET /v1/payments/{id}`) as a fallback for critical user-facing flows.      |
| `refund.failed` event received       | The refund could not be processed by the bank.             | Do not re-attempt automatically. Alert your ops team and contact Razorpay Support. Inform the customer that their refund is delayed.        |

## Step 4.5: How do you handle Late Authorisation (Late Auth)?

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.authorized` webhook to your endpoint.
* Your system does not recognise the order as active, and the customer is charged with no fulfilment.

```javascript Recommended Handling Pattern theme={null}
// 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 immediately
 await 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 customer
 await 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 normally
 await db.orders.markPaid(orderId, payment.id);
 await emailService.sendConfirmation(order.customerEmail);
 }
}
```

| Order state when Late Auth arrives   | Action                                                                                                             | Customer communication                                                                                 |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| active / pending                     | Fulfil normally and capture the payment.                                                                           | Send the normal confirmation email.                                                                    |
| expired / cancelled                  | Refund immediately via the [Create Refund API](/docs/api/refunds/create-normal) (`POST /v1/payments/{id}/refund`). | Email the customer: "Your payment arrived after the session expired. We have initiated a full refund." |
| already fulfilled by another payment | Refund immediately (duplicate payment).                                                                            | Email the customer: "Duplicate payment detected. Refund initiated."                                    |

## Step 5: How do you test the integration before going live?

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.

<Info>
  **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.
</Info>

### Test cards

| Card number         | Network    | CVV | Expiry     | Scenario tested                                       |
| ------------------- | ---------- | --- | ---------- | ----------------------------------------------------- |
| 4100 2800 0000 1007 | Visa       | Any | Any future | Successful payment, happy path.                       |
| 5555 5100 0008 1006 | Mastercard | Any | Any future | Successful payment, alternative network.              |
| 4100 2800 0006 0003 | Visa       | Any | Any future | Card declined. Test your failure handling.            |
| 4100 2800 0008 0001 | Visa       | Any | Any future | Insufficient funds. Test your customer error message. |
| 4100 2800 0000 1007 | Visa       | Any | Any past   | Expired card. Test your expiry error message.         |

### International test cards

| Card number         | Country | Network    | Scenario tested                              |
| ------------------- | ------- | ---------- | -------------------------------------------- |
| 4012 8888 8888 1881 | US      | Visa       | Successful international card payment.       |
| 5555 5555 5555 4444 | US      | Mastercard | Successful international Mastercard payment. |
| 3402 560004 01007   | Global  | Amex       | Successful Amex payment (15-digit card).     |

### Test UPI and other methods

| UPI Test VPA       | Expected result                                                                  |
| ------------------ | -------------------------------------------------------------------------------- |
| `success@razorpay` | Payment succeeds immediately.                                                    |
| `failure@razorpay` | Payment fails. Use this to test failure handling.                                |
| `pending@razorpay` | Payment stays in the pending state. Use this to test the async and webhook flow. |

<Info>
  **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.
</Info>

### Test checklist

Run every scenario below in Test Mode before moving to go-live.

| Step | What to test                                                                       | Why this matters                                                    |
| ---- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| 1    | Checkout modal opens on Pay button click.                                          | If this fails, customers can never start a payment.                 |
| 2    | A successful payment returns all 3 fields (`payment_id`, `order_id`, `signature`). | Missing fields mean signature verification will fail.               |
| 3    | Signature verification passes for a successful payment.                            | Core security check. Proves the payment is genuine.                 |
| 4    | Signature verification fails when any field is tampered with.                      | Confirms your security is actually working.                         |
| 5    | A failed payment does not fulfil the order.                                        | Prevents delivering goods without receiving money.                  |
| 6    | A duplicate `payment_id` is rejected (idempotency).                                | Prevents accidental double fulfilment.                              |
| 7    | Webhook received and processed for `payment.captured`.                             | Confirms the async fulfilment pipeline works.                       |
| 8    | Webhook signature verified server-side.                                            | Confirms your endpoint rejects fake events.                         |
| 9    | Payment status shows Captured in the Dashboard.                                    | Visual confirmation that capture is working.                        |
| 10   | Refund flow works in Test Mode.                                                    | Confirms you can handle refund requests.                            |
| 11   | Partial payment or instalment flow tested (if enabled).                            | Prevents issues with partial-payment merchants.                     |
| 12   | Modal close or dismiss handled gracefully.                                         | Prevents broken UX when a customer backs out.                       |
| 13   | Amount mismatch: set the Checkout amount different from the Order amount.          | Should return `BAD_REQUEST_ERROR`. Confirms paise validation works. |
| 14   | Reuse a paid `order_id`: attempt to pay against an already-paid order.             | Should fail. Confirms duplicate order protection works.             |
| 15   | UPI `success@razorpay` flow end-to-end.                                            | Confirms UPI integration and webhook handling.                      |
| 16   | UPI `pending@razorpay`: verify the webhook arrives before fulfilling.              | Confirms you do not fulfil on the browser callback alone.           |

## Step 6: What is the go-live checklist?

All Tier 1 items must be verified before you switch to Live API keys. This checklist is your merchant audit record.

<AccordionGroup>
  <Accordion title="Tier 1.1: Account and API Keys">
    * 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_SECRET` stored in environment variables, not hardcoded in source code.
    * Source code does not contain any API secrets, verified via `git grep` or equivalent.
  </Accordion>

  <Accordion title="Tier 1.2: Integration correctness">
    * Every payment attempt creates a fresh Order via the Orders API.
    * `order_id` is 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.js` loaded 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.
  </Accordion>

  <Accordion title="Tier 1.3: Security (non-negotiable)">
    * Signature verification implemented server-side using HMAC-SHA256.
    * `order_id` used for verification comes from the server DB, not the client callback.
    * Order fulfilled only after signature verification passes.
    * Timing-safe string comparison used (`timingSafeEqual` or `hmac.compare_digest`).
    * `razorpay_payment_id` stored 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.
  </Accordion>

  <Accordion title="Tier 1.4: Capture and webhooks">
    **Capture, so you actually get the money:**

    * Auto-capture configured in the Dashboard, or the Orders API `capture` parameter set.
    * All authorised payments will be captured within the capture window (confirmed).
    * Goods or services not delivered before the payment reaches `captured` state.
    * 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.captured` and `payment.failed` events subscribed and handled.
    * `refund.failed` and `payment.dispute.created` subscribed and handled.
    * Webhook endpoint returns HTTP 200 within 5 seconds. Heavy processing is async.
    * Duplicate webhook events handled idempotently (`event.id` stored 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.
  </Accordion>

  <Accordion title="Tier 1.5: Go-live execution">
    * 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).
  </Accordion>

  <Accordion title="Tier 2.1: Operational hardening (within 30 days)">
    * 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 `image` parameter and theme colour matches brand guidelines.
  </Accordion>
</AccordionGroup>

<Info>
  **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.
</Info>

## Step 7: What should you monitor after go-live?

Your integration is live. Watch these metrics so you can act before small problems become customer-facing ones.

| Metric                    | Where to find it                                      | Alert threshold                                                      |
| ------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------- |
| Payment success rate      | Dashboard → Transactions → Payments, filter by status | Alert if below 90% over any 15-minute window.                        |
| Webhook delivery failures | Dashboard → Webhooks → Logs                           | Alert if any endpoint shows above 5% failure rate.                   |
| Uncaptured payments       | Dashboard → Payments, filter status: authorized       | Alert if any authorised payment is more than 48 hours old.           |
| Refund failure rate       | Dashboard → Transactions → Refunds                    | Alert on any `refund.failed` event. All require manual intervention. |
| API error rate (5xx)      | Your application error logs                           | Alert if more than 1% of Order creation calls return 5xx.            |

**Daily operations checklist**

* Check Dashboard → Payments for any payments stuck in the `authorized` state.
* Review webhook delivery logs for failed retries.
* Check for any `payment.dispute.created` events and respond within 7 days.
* Verify settlement amounts match expected totals.
* Monitor `refund.failed` events. Each requires manual follow-up.

## Appendix

### Rollback plan template

Fill this in before switching to Live keys. If something goes wrong, you need to act in seconds, not minutes.

| Step | Action                                                                         | Who              | Time estimate  |
| ---- | ------------------------------------------------------------------------------ | ---------------- | -------------- |
| 1    | Detect issue: payment failures spike or webhook errors above 5%.               | On-call engineer | 0 to 5 min     |
| 2    | Switch the environment variable `RAZORPAY_KEY_ID` back to the `rzp_test_` key. | On-call engineer | Under 2 min    |
| 3    | Switch `RAZORPAY_KEY_SECRET` back to the test secret.                          | On-call engineer | Under 2 min    |
| 4    | Restart application servers to pick up the env var changes.                    | On-call engineer | Under 5 min    |
| 5    | Verify Test Mode is active by attempting a test transaction with a test card.  | On-call engineer | Under 5 min    |
| 6    | Notify affected customers: "Payment services temporarily unavailable."         | Customer support | Under 15 min   |
| 7    | Contact Razorpay Support with timeline and error logs.                         | Engineering lead | Under 30 min   |
| 8    | Do not re-enable Live keys until the root cause is identified and fixed.       | Engineering lead | Until resolved |

### Logging strategy

Log enough to debug payments, but not so much that you create a PCI compliance risk.

| Event                      | What to log                                             | Do NOT log                                          |
| -------------------------- | ------------------------------------------------------- | --------------------------------------------------- |
| Order creation             | `order_id`, `amount`, `currency`, `receipt`, timestamp  | `KEY_SECRET`, customer card details                 |
| Payment callback (browser) | `razorpay_order_id`, `razorpay_payment_id`, timestamp   | `razorpay_signature` (treat as sensitive)           |
| Signature verification     | `order_id`, `payment_id`, result (pass/fail), timestamp | The actual signature string, `KEY_SECRET`           |
| Webhook received           | `event.id`, `event.event`, `payment_id`, timestamp      | Full raw body in plaintext logs                     |
| API errors                 | `error.code`, `error.description`, endpoint, timestamp  | Full request body if it contains customer card data |

```javascript Structured Log Example (Node.js) theme={null}
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
});
```

### Common problems and what they mean

| Symptom                                                  | Root cause                                                                                    | Fix                                                                                                   |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Payment auto-refunded after success, money never arrives | `order_id` not passed to Checkout, or payment not captured in time                            | Always create the Order first and pass the `order_id`. Enable auto-capture.                           |
| Signature verification fails                             | Using `razorpay_order_id` from the client instead of the server DB, or the wrong `KEY_SECRET` | Retrieve the `order_id` from your own server DB. Confirm the `KEY_SECRET` is correct.                 |
| Checkout does not open                                   | `rzp.open()` called outside a user event handler                                              | Call `rzp1.open()` only inside an onclick handler.                                                    |
| Amount mismatch error                                    | Amount in the Orders API and Checkout options differ                                          | Both must be identical and in paise.                                                                  |
| Webhook not received, fulfilment not triggering          | URL not publicly accessible, not HTTPS, or wrong mode (Test vs Live)                          | Ensure the HTTPS URL is live, verify the mode matches and check it returns HTTP 200 within 5 seconds. |
| Payment stuck in authorized, not captured                | Auto-capture not enabled, capture window at risk                                              | Go to Dashboard → Payment Capture and enable auto-capture.                                            |
| API returns 401 Unauthorized                             | Wrong API keys, or Test keys used against the Live API                                        | Regenerate keys from the correct mode in the Dashboard.                                               |
| Duplicate order fulfilment                               | Webhook processed multiple times without an idempotency check                                 | Check `event.id` and skip duplicates you have already processed.                                      |
| Late Auth, customer charged with no fulfilment           | Payment authorised after your order expired and not handled                                   | Implement the Late Auth handler with refund logic (see Step 4.5).                                     |

### API endpoints reference

| Purpose                                           | Endpoint                                            | Method |
| ------------------------------------------------- | --------------------------------------------------- | ------ |
| [Create Order](/docs/api/orders/create)           | `https://api.razorpay.com/v1/orders`                | POST   |
| [Fetch Order](/docs/api/orders/fetch-with-id)     | `https://api.razorpay.com/v1/orders/{order_id}`     | GET    |
| [Fetch Payment](/docs/api/payments/fetch-with-id) | `https://api.razorpay.com/v1/payments/{payment_id}` | GET    |
| [Capture Payment](/docs/api/payments/capture)     | `https://api.razorpay.com/v1/payments/{id}/capture` | POST   |
| [Create Refund](/docs/api/refunds/create-normal)  | `https://api.razorpay.com/v1/payments/{id}/refund`  | POST   |
| [Fetch Refund](/docs/api/refunds/fetch-with-id)   | `https://api.razorpay.com/v1/refunds/{refund_id}`   | GET    |

### Related information

* [Standard Checkout integration steps](/docs/payments/payment-gateway/web-integration/standard/integration-steps)
* [Orders API reference](/docs/api/orders/create)
* [Set up webhooks](/docs/webhooks/setup-edit-payments)
* [Capture settings](/docs/payments/payments/capture-settings)
* [Error codes reference](/docs/errors/payments/list)
* [Troubleshooting and FAQs](/docs/payments/payment-gateway/web-integration/standard/troubleshooting-faqs)

## Frequently asked questions

<AccordionGroup>
  <Accordion title="Do I need to create an order for every payment?">
    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.
  </Accordion>

  <Accordion title="What is the difference between authorized and captured?">
    `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.
  </Accordion>

  <Accordion title="Why must I verify the payment signature on the server?">
    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.
  </Accordion>

  <Accordion title="Why should I use webhooks if I already get a browser callback?">
    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.
  </Accordion>

  <Accordion title="What is Late Auth and how do I handle it?">
    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.
  </Accordion>

  <Accordion title="How do I test the integration without real money?">
    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.
  </Accordion>

  <Accordion title="My customer paid but the money never arrived. What went wrong?">
    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.
  </Accordion>
</AccordionGroup>
