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:
- Creates a Razorpay order on your server (never on the client)
- Opens Razorpay's hosted Checkout UI in a modal
- Verifies the payment signature on your server — never trust client-reported success
- 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(startsrzp_test_) andKey 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 laterNever 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
composer require razorpay/razorpayCreate the order
create-order.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
<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
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
npm install razorpayCreate the order
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
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
.envasRAZORPAY_WEBHOOK_SECRET - Events:
payment.captured,payment.failed,order.paid,refund.created,refund.processed
PHP webhook handler
webhooks/razorpay.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
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:
- 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. - Idempotency. Razorpay retries on non-200 responses. Your handler WILL be called more than once for the same event occasionally. Make
markOrderPaidsafe 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
.envon 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
- Amount in rupees instead of paise.
500means 5 paise, not ₹500. Always multiply by 100. - Trusting the success callback without signature verification. Browser can lie. Always verify server-side.
- Key secret in frontend JS. Only the
key_id(public) goes to the browser. - Skipping webhooks. Studies show 3–5% of customers close the browser before the success handler fires. Without webhooks, those orders are stuck forever.
- Test mode vs live mode confusion. Check the
key_idprefix:rzp_test_for test,rzp_live_for live. Mixing these causes silent failures. - Webhook URL on HTTP. Razorpay requires HTTPS. Install Let's Encrypt SSL first.
- Not handling idempotent retries. Same webhook event arriving twice = double-credited order. Always check before marking paid.
- 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.