Client Area

Razorpay Integration in PHP and Node.js: Complete Guide

ByDomain India Team
8 min read22 Apr 20264 views

In this article

  • 1What you will build
  • 2The payment flow, step by step
  • 3Prerequisites
  • 4PHP implementation
  • 5Install the SDK

Razorpay Integration in PHP and Node.js: Complete Guide

Razorpay is the default payment gateway for Indian SaaS, e-commerce, and subscription apps. Most integration guides online either skip the security-critical signature verification step or use deprecated APIs. This guide does it properly — server-side order creation, Razorpay Checkout on the frontend, signature verification, and webhook handling — with working code for both PHP and Node.js.

What you will build

A working "Pay ₹X" flow that:

  1. Creates a Razorpay order on your server (never on the client)
  2. Opens Razorpay's hosted Checkout UI in a modal
  3. Verifies the payment signature on your server — never trust client-reported success
  4. Listens to Razorpay webhooks for redundancy and refund events

Skipping any of these four steps leaves a hole an attacker can exploit.

The payment flow, step by step

1. User clicks "Pay" on your site.
2. Your server calls Razorpay Orders API → gets order_id.
3. Your server returns (order_id, public key_id) to the browser.
4. Browser loads Razorpay Checkout with those values.
5. User completes payment inside Razorpay's modal.
6. Razorpay redirects back with (payment_id, order_id, signature).
7. Your server VERIFIES the signature against your key_secret.
8. Server marks the order paid, provisions service, emails the user.
9. (Redundancy) Razorpay also fires a webhook. Server verifies + marks paid if not already.

Steps 7 and 9 are both critical. Clients can be tampered; signatures and webhook signatures can be verified.

Prerequisites

  • Razorpay account (razorpay.com). Test mode is free and works without KYC; live mode needs business KYC (PAN, bank account, address proof).
  • Dashboard → Settings → API Keys → generate test keys. Copy Key Id (starts rzp_test_) and Key Secret (keep private).
  • In your app, add these to .env:
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxx
RAZORPAY_WEBHOOK_SECRET=           # set this when you configure webhooks later

Never check the secret into git. See Environment Variables & Secrets Management.

Test cards:

  • Success: 4111 1111 1111 1111, any future expiry, any CVV
  • Failure: 4000 0000 0000 0002

PHP implementation

Install the SDK

bash
composer require razorpay/razorpay

Create the order

create-order.php:

php
<?php
require __DIR__ . '/vendor/autoload.php';
use Razorpay\Api\Api;

$api = new Api($_ENV['RAZORPAY_KEY_ID'], $_ENV['RAZORPAY_KEY_SECRET']);

$order = $api->order->create([
    'receipt'  => 'rcpt_' . uniqid(),
    'amount'   => 50000,                // in paise — ₹500.00
    'currency' => 'INR',
    'notes'    => [
        'customer_email' => '[email protected]',
        'product'        => 'Domain Renewal (.com, 1 year)',
    ],
]);

// Persist the order_id in your DB linked to the user + cart.
// Then return payment config to the browser:

header('Content-Type: application/json');
echo json_encode([
    'order_id' => $order->id,
    'key_id'   => $_ENV['RAZORPAY_KEY_ID'],
    'amount'   => 50000,
    'name'     => 'Your Company',
    'email'    => '[email protected]',
]);

Important: amount is in paise, not rupees. ₹500 is 50000. Getting this wrong underpays by 100× — a common bug.

Frontend — open Checkout

html
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
<button id="pay-btn">Pay ₹500</button>

<script>
document.getElementById('pay-btn').addEventListener('click', async () => {
  const res = await fetch('/create-order.php', { method: 'POST' });
  const data = await res.json();

  const options = {
    key:       data.key_id,
    amount:    data.amount,
    currency:  'INR',
    name:      'Your Company',
    description: 'Domain Renewal',
    order_id:  data.order_id,
    prefill:   { name: data.name, email: data.email },
    handler:   async function (response) {
      // response contains: razorpay_payment_id, razorpay_order_id, razorpay_signature
      const verifyRes = await fetch('/verify-payment.php', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(response),
      });
      const result = await verifyRes.json();
      if (result.verified) alert('Payment successful! Your order is confirmed.');
      else alert('Payment verification failed. Contact support.');
    },
    theme: { color: '#0a75ff' },
  };
  new Razorpay(options).open();
});
</script>

Server-side signature verification

This is the step most tutorials skip. Without it, a malicious user can forge a "payment successful" callback in the browser and get your goods for free.

verify-payment.php:

php
<?php
require __DIR__ . '/vendor/autoload.php';
use Razorpay\Api\Api;
use Razorpay\Api\Errors\SignatureVerificationError;

$input = json_decode(file_get_contents('php://input'), true);

$api = new Api($_ENV['RAZORPAY_KEY_ID'], $_ENV['RAZORPAY_KEY_SECRET']);

try {
    $api->utility->verifyPaymentSignature([
        'razorpay_order_id'   => $input['razorpay_order_id'],
        'razorpay_payment_id' => $input['razorpay_payment_id'],
        'razorpay_signature'  => $input['razorpay_signature'],
    ]);

    // Signature is valid. Mark order paid in YOUR database.
    markOrderPaid($input['razorpay_order_id'], $input['razorpay_payment_id']);

    header('Content-Type: application/json');
    echo json_encode(['verified' => true]);
} catch (SignatureVerificationError $e) {
    http_response_code(400);
    header('Content-Type: application/json');
    echo json_encode(['verified' => false, 'error' => 'Invalid signature']);
}

Node.js implementation

Install

bash
npm install razorpay

Create the order

javascript
import Razorpay from 'razorpay';

const razorpay = new Razorpay({
  key_id:     process.env.RAZORPAY_KEY_ID,
  key_secret: process.env.RAZORPAY_KEY_SECRET,
});

app.post('/create-order', async (req, res) => {
  const order = await razorpay.orders.create({
    amount:   50000,          // paise
    currency: 'INR',
    receipt:  `rcpt_${Date.now()}`,
    notes:    { customer_email: req.user.email },
  });

  res.json({
    order_id: order.id,
    key_id:   process.env.RAZORPAY_KEY_ID,
    amount:   order.amount,
  });
});

Verify signature

javascript
import crypto from 'crypto';

app.post('/verify-payment', express.json(), (req, res) => {
  const { razorpay_order_id, razorpay_payment_id, razorpay_signature } = req.body;

  const expected = crypto
    .createHmac('sha256', process.env.RAZORPAY_KEY_SECRET)
    .update(`${razorpay_order_id}|${razorpay_payment_id}`)
    .digest('hex');

  const valid = crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(razorpay_signature)
  );

  if (valid) {
    markOrderPaid(razorpay_order_id, razorpay_payment_id);
    res.json({ verified: true });
  } else {
    res.status(400).json({ verified: false, error: 'Invalid signature' });
  }
});

Use crypto.timingSafeEqual for comparison — not ===. Protects against timing attacks; negligible cost.

Webhooks — the essential redundancy layer

User closes the browser after paying. Your success callback never fires. Razorpay has the money; you do not know it. Without webhooks, you have a stuck order.

With webhooks, Razorpay calls your server regardless of what the user's browser did.

Set up the endpoint

In Razorpay Dashboard → Settings → Webhooks → Add New:

  • URL: https://yourdomain.com/webhooks/razorpay (must be HTTPS)
  • Secret: generate a strong random string; save it to .env as RAZORPAY_WEBHOOK_SECRET
  • Events: payment.captured, payment.failed, order.paid, refund.created, refund.processed

PHP webhook handler

webhooks/razorpay.php:

php
<?php
$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_RAZORPAY_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $payload, $_ENV['RAZORPAY_WEBHOOK_SECRET']);

if (!hash_equals($expected, $signature)) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($payload, true);

switch ($event['event']) {
    case 'payment.captured':
        $paymentId = $event['payload']['payment']['entity']['id'];
        $orderId   = $event['payload']['payment']['entity']['order_id'];
        markOrderPaid($orderId, $paymentId);   // idempotent — check if already paid
        break;

    case 'payment.failed':
        $orderId = $event['payload']['payment']['entity']['order_id'];
        $reason  = $event['payload']['payment']['entity']['error_description'] ?? 'unknown';
        markOrderFailed($orderId, $reason);
        break;

    case 'refund.processed':
        $refund = $event['payload']['refund']['entity'];
        markRefundComplete($refund['payment_id'], $refund['amount']);
        break;
}

http_response_code(200);
echo 'ok';

Node.js webhook handler

javascript
import crypto from 'crypto';

// IMPORTANT: use raw body middleware, NOT express.json(), for the webhook route.
// The signature is computed over the raw JSON string.
app.post('/webhooks/razorpay',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const payload   = req.body;                              // Buffer
    const signature = req.headers['x-razorpay-signature'];

    const expected = crypto
      .createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
      .update(payload)
      .digest('hex');

    const valid = crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature)
    );

    if (!valid) return res.status(400).send('invalid signature');

    const event = JSON.parse(payload.toString());

    switch (event.event) {
      case 'payment.captured':
        markOrderPaid(
          event.payload.payment.entity.order_id,
          event.payload.payment.entity.id,
        );
        break;
      case 'payment.failed':
        markOrderFailed(
          event.payload.payment.entity.order_id,
          event.payload.payment.entity.error_description,
        );
        break;
      // ... other events
    }

    res.status(200).send('ok');
  }
);

Two things that trip people up here:

  1. Raw body middleware. Not express.json(). The HMAC is computed over the exact bytes Razorpay sent; if Express has already parsed and re-stringified the JSON, the signature will never match.
  2. Idempotency. Razorpay retries on non-200 responses. Your handler WILL be called more than once for the same event occasionally. Make markOrderPaid safe to call twice — check current state, update only if not already marked.

Going live checklist

Before switching to live mode:

  • Razorpay KYC complete and approved
  • Bank settlement account added and verified
  • Generate live keys (start with rzp_live_)
  • Update .env on production server (NOT in code)
  • Update webhook URL to production domain; regenerate webhook secret for production
  • Test end-to-end with a real ₹1 transaction
  • Confirm webhook delivery in Dashboard → Webhooks → Event Logs
  • Server-side validation of payment amount — never trust the amount field from the client

Common pitfalls

  1. Amount in rupees instead of paise. 500 means 5 paise, not ₹500. Always multiply by 100.
  2. Trusting the success callback without signature verification. Browser can lie. Always verify server-side.
  3. Key secret in frontend JS. Only the key_id (public) goes to the browser.
  4. Skipping webhooks. Studies show 3–5% of customers close the browser before the success handler fires. Without webhooks, those orders are stuck forever.
  5. Test mode vs live mode confusion. Check the key_id prefix: rzp_test_ for test, rzp_live_ for live. Mixing these causes silent failures.
  6. Webhook URL on HTTP. Razorpay requires HTTPS. Install Let's Encrypt SSL first.
  7. Not handling idempotent retries. Same webhook event arriving twice = double-credited order. Always check before marking paid.
  8. Ignoring `payment.failed` webhook. Users who fail once often retry; showing them the failure reason helps.

Frequently asked questions

Do I need a registered business to use Razorpay?

For test mode — no. For live mode — yes. You need PAN, bank account, address proof. Individual KYC works for sole proprietors.

What does Razorpay charge?

Standard rate is 2% + GST on successful transactions (as of 2026; check Razorpay Pricing for current). International cards are higher. UPI is usually flat ₹2 per transaction. Check Razorpay Dashboard → Account Settings → Pricing.

How long until settlements reach my bank account?

Default is T+2 (two working days after capture). Faster settlement available on higher tiers.

Can I use Razorpay for international customers?

Yes, international cards work. INR is the default; multi-currency is a separate feature to enable.

How do I refund a payment?

$api->refund->create(['payment_id' => 'pay_xxx', 'amount' => 50000]); or via Dashboard. Listen for refund.processed webhook to confirm.

What is the difference between Orders API and Payment Links?

Orders API — you host the checkout (your site, our Checkout.js modal). Payment Links — Razorpay hosts a link you send the customer (no integration needed). Use Orders API for anything transactional; Payment Links for one-off invoicing.

Can I customise the checkout colour and logo?

Yes — theme.color in the options object, and the merchant logo is configured in Dashboard → Settings → Branding.


Questions? [email protected]. Our team can review your integration before you go live.

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket