Razorpay is one of the most widely used payment gateways for Indian websites, shops and SaaS apps. Many integration guides skip the security-critical step of verifying the payment on your server. This guide builds the full flow properly, in PHP and Node.js: create the order on your server, open Razorpay Checkout in the browser, verify the signature, and handle webhooks.
Create every Razorpay order on your server, open Checkout in the browser with the order ID, then verify the returned signature on your server with an HMAC-SHA256 of order_id|payment_id using your key secret. Add a webhook, verified the same way over the raw request body, so orders are marked paid even when the customer closes the browser. Keep the key secret out of your code and your front end. On shared hosting, test the PHP SDK on your own plan first, because some servers disable the cURL functions it prefers.
1. How a Razorpay payment works
1. The customer clicks "Pay" on your site.
2. Your server calls the Razorpay Orders API and gets an order_id.
3. Your server sends the order_id and your public key_id to the browser.
4. The browser opens Razorpay Checkout with those values.
5. The customer pays inside Razorpay's window.
6. Checkout returns payment_id, order_id and signature to your page.
7. Your server VERIFIES the signature with your key secret.
8. Your server marks the order paid and delivers the product.
9. Razorpay also sends a webhook; your server verifies it and marks
the order paid if it isn't already.Steps 7 and 9 are the ones that matter for security. Anything the browser reports can be faked; a signature made with your secret cannot.
2. Before you start: keys and secrets
- Create a Razorpay account. Test mode lets you build and test before your business verification is complete; live mode needs Razorpay's KYC.
- In the Razorpay Dashboard, generate test API keys. The Key ID starts with
rzp_test_; the Key Secret is shown once. - Store both where your code can read them but nobody else can.
RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx
RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxxxxx
RAZORPAY_WEBHOOK_SECRET=set-this-when-you-add-the-webhookNever hard-code the key secret or webhook secret in a PHP or JavaScript file, never commit them to Git, and never send them to the browser. Only the Key ID is public. On shared hosting, keep them in a .env or config file outside public_html; on the App Platform, use its environment variables. See environment variables and secrets management.
Use the test card and UPI details from Razorpay's own documentation while you build; they change from time to time.
3. PHP: create the order
Install the official SDK. Composer isn't pre-installed on Domain India shared hosting, so either run it over jailed SSH as php composer.phar require razorpay/razorpay or run it on your computer and upload the project with its vendor folder.
composer require razorpay/razorpaycreate-order.php, with the secrets read from a config file outside the web root:
<?php
require __DIR__ . '/vendor/autoload.php';
$config = require '/home/youruser/private/razorpay.php'; // returns an array
use Razorpay\Api\Api;
$api = new Api($config['key_id'], $config['key_secret']);
// Take the amount from YOUR database, never from the browser.
$amountPaise = 50000; // ₹500.00
$order = $api->order->create([
'receipt' => 'rcpt_' . bin2hex(random_bytes(6)),
'amount' => $amountPaise,
'currency' => 'INR',
]);
// Save $order->id against the customer's cart in your database here.
header('Content-Type: application/json');
echo json_encode([
'order_id' => $order->id,
'key_id' => $config['key_id'],
'amount' => $amountPaise,
]);The amount is in paise: ₹500 is 50000. Sending 500 asks for ₹5.
4. PHP on shared hosting: when the SDK can't connect
The Razorpay PHP SDK makes its API calls through an HTTP library that prefers cURL. On our cPanel servers curl_exec works. On our DirectAdmin servers curl_exec and curl_multi_exec are disabled on most sites, while allow_url_fopen is on. So test order creation on your own plan before you go live. If the SDK fails there, call the Orders API through PHP's HTTPS stream wrapper instead:
<?php
function razorpay_post(string $path, array $body, array $config): array
{
$auth = base64_encode($config['key_id'] . ':' . $config['key_secret']);
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Basic $auth\r\nContent-Type: application/json\r\n",
'content' => json_encode($body),
'timeout' => 20,
'ignore_errors' => true, // read Razorpay's error body too
]]);
$raw = file_get_contents('https://api.razorpay.com/v1' . $path, false, $ctx);
$status = isset($http_response_header[0]) ? (int) explode(' ', $http_response_header[0])[1] : 0;
if ($raw === false || $status < 200 || $status >= 300) {
throw new RuntimeException('Razorpay API error ' . $status . ': ' . $raw);
}
return json_decode($raw, true);
}
$order = razorpay_post('/orders', [
'amount' => 50000, 'currency' => 'INR', 'receipt' => 'rcpt_123',
], $config);Signature checks (section 6) need no network access at all, so they work on every plan. If neither route works on your plan, run the payment code on the App Platform or a VPS, where nothing is disabled. Background: PHP disabled functions on shared hosting.
5. The browser: 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 data = await (await fetch('/create-order.php', { method: 'POST' })).json();
new Razorpay({
key: data.key_id,
amount: data.amount,
currency: 'INR',
name: 'Your Company',
order_id: data.order_id,
handler: async (response) => {
// razorpay_payment_id, razorpay_order_id, razorpay_signature
const r = await fetch('/verify-payment.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(response),
});
const result = await r.json();
alert(result.verified ? 'Payment received. Thank you!' : 'We could not confirm the payment yet.');
},
}).open();
});
</script>The page only shows a message. It never marks anything as paid; your server does that after verifying.
6. Verify the payment signature on your server
Razorpay signs order_id + "|" + payment_id with HMAC-SHA256 using your key secret. Recompute it and compare in constant time.
PHP, with hash_hmac and hash_equals:
<?php
$config = require '/home/youruser/private/razorpay.php';
$in = json_decode(file_get_contents('php://input'), true) ?: [];
$orderId = (string) ($in['razorpay_order_id'] ?? '');
$paymentId = (string) ($in['razorpay_payment_id'] ?? '');
$signature = (string) ($in['razorpay_signature'] ?? '');
// The order must be one YOU created for this customer and is still unpaid.
// if (!orderBelongsToCurrentCart($orderId)) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $orderId . '|' . $paymentId, $config['key_secret']);
header('Content-Type: application/json');
if ($signature !== '' && hash_equals($expected, $signature)) {
markOrderPaid($orderId, $paymentId); // idempotent
echo json_encode(['verified' => true]);
} else {
http_response_code(400);
echo json_encode(['verified' => false]);
}The SDK's $api->utility->verifyPaymentSignature() does the same calculation if you prefer it.
Node.js (install with npm install razorpay):
import crypto from 'node:crypto';
function safeEqualHex(a, b) {
const x = Buffer.from(a, 'utf8');
const y = Buffer.from(String(b || ''), 'utf8');
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
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');
if (safeEqualHex(expected, razorpay_signature)) {
markOrderPaid(razorpay_order_id, razorpay_payment_id); // idempotent
return res.json({ verified: true });
}
res.status(400).json({ verified: false });
});crypto.timingSafeEqual throws if the two buffers differ in length, so check the length first as shown.
7. Webhooks: the safety net
If the customer closes the browser straight after paying, your handler never runs, but Razorpay has the money. A webhook tells your server anyway.
In the Razorpay Dashboard, add a webhook with your HTTPS URL (for example https://yourdomain.com/webhooks/razorpay.php), a long random secret saved as RAZORPAY_WEBHOOK_SECRET, and the events you need, such as payment.captured, payment.failed, order.paid and refund.processed.
PHP handler:
<?php
$config = require '/home/youruser/private/razorpay.php';
$payload = file_get_contents('php://input'); // raw body
$signature = $_SERVER['HTTP_X_RAZORPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, $config['webhook_secret']);
if ($signature === '' || !hash_equals($expected, $signature)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($payload, true);
if ($event['event'] === 'payment.captured') {
$p = $event['payload']['payment']['entity'];
markOrderPaid($p['order_id'], $p['id']); // safe to call twice
}
http_response_code(200);
echo 'ok';Node.js handler. Use the raw body, not express.json(), because the signature covers the exact bytes Razorpay sent:
app.post('/webhooks/razorpay', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.RAZORPAY_WEBHOOK_SECRET)
.update(req.body) // Buffer
.digest('hex');
if (!safeEqualHex(expected, req.headers['x-razorpay-signature'])) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body.toString());
if (event.event === 'payment.captured') {
const p = event.payload.payment.entity;
markOrderPaid(p.order_id, p.id);
}
res.status(200).send('ok');
});Razorpay retries a webhook that does not get a 2xx answer, so the same event can arrive more than once. Make markOrderPaid idempotent: check the order's state and update it only if it is not already paid.
8. Going live
- Finish Razorpay KYCand add your settlement bank account.
- Generate live keys(
rzp_live_) and put them in your server-side config, not in code. - Add a production webhookwith a new secret, and confirm deliveries in the Dashboard's webhook logs.
- Check payment capture settingsin the Dashboard, so payments are captured automatically or by your code.
- Check the amount on the serveragainst your own order record, never a value sent by the browser.
- Make a small real paymentend to end, then refund it.
9. Common mistakes
10. Running this on Domain India
- Shared hosting (cPanel, DirectAdmin, Webuzo): PHP integrations run well. Build
vendoron your computer, keep secrets outsidepublic_html, and test the SDK on your plan as described in section 4. Free SSL is included, which the webhook URL needs. - App Platform: Node.js apps are detected and built automatically; PHP needs a
Dockerfile. Set the keys as environment variables. See App Platform: getting started. - VPS: full root access and no disabled functions, for larger shops or custom stacks.
Prices on the cards are live and exclude 18% GST.
- 25 GB NVMe SSD Storage
- 50 GB Monthly Bandwidth
- 1 Website
- 10 Email Accounts
- 512 MB RAM per app
- 1 vCPU
- 5 GB NVMe SSD
- PostgreSQL Database
How do I verify a Razorpay payment signature in PHP?
Compute hash_hmac('sha256', order_id . '|' . payment_id, key_secret) on your server and compare it with the razorpay_signature value using hash_equals. Use the order ID you saved when you created the order, and mark the order paid only if the two match.
Why does Razorpay's PHP SDK fail on my shared hosting?
The SDK prefers cURL for its API calls, and on Domain India DirectAdmin servers curl_exec is disabled on most sites. Test on your plan. If it fails, call the Orders API with file_get_contents and a stream context, since allow_url_fopen is on, or run the code on the App Platform or a VPS.
Do I need webhooks if I already verify the signature?
Yes. If the customer closes the browser after paying, your verification request never runs. A webhook tells your server about the payment anyway. Verify the webhook signature over the raw request body and make your order update safe to run twice.
Where should I keep my Razorpay key secret?
On the server only, in a .env or config file outside public_html, or in your platform's environment variables. Never commit it to Git and never send it to the browser. Only the Key ID is public.
Is the Razorpay amount in rupees or paise?
In paise, the smallest currency unit. ₹500 is sent as 50000. Take the amount from your own database, not from the browser.
Can I use Razorpay without a registered business?
Test mode works while you build. Live payments need Razorpay's KYC, and the documents required depend on your business type; check Razorpay's current requirements.
What does Razorpay charge?
Fees depend on the payment method and your plan, and they change. Check Razorpay's pricing page and your Razorpay Dashboard for the current rates.
Ready to take payments? Compare cPanel hosting, the App Platform or a VPS for your app, and open a ticket if you want help choosing where your integration should run.
Free SSL for your webhook URL, a choice of PHP versions and weekly backups on every shared hosting plan.
See hosting plans