Razorpay Integration Standard
Keys, Orders (server-side, paise amounts), Payment verification (HMAC SHA256), Checkout.js, Subscriptions, Webhooks, Notes, Refunds.
Covers Razorpay account setup, Orders, payment verification, Checkout.js, Subscriptions, Webhooks, Notes (metadata), and Refunds.
1. Account Setup and API Keys
Key Types
| Key | Where Used | Exposure |
|---|---|---|
key_id (rzp_test_... / rzp_live_...) |
Frontend (Checkout.js) + Backend | Safe in frontend for Checkout only |
key_secret |
Backend only | NEVER expose to browser |
| Webhook secret | Backend webhook handler only | NEVER expose |
Environment Variables
RAZORPAY_KEY_ID=rzp_test_... # or rzp_live_...
RAZORPAY_KEY_SECRET=... # NEVER in frontend
RAZORPAY_WEBHOOK_SECRET=...
- Use
rzp_test_locally and in staging - Use
rzp_live_in production only - Never commit keys to version control
- Rotate key_secret immediately if exposed
Client Initialization (Backend)
import razorpay
client = razorpay.Client(auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET))
2. Orders — Always Create Server-Side
Never skip order creation. Every Razorpay payment must begin with a server-side Order. The Order ID ties the payment back to your system.
Creating an Order
order = client.order.create({
'amount': 29000, # in paise — ₹290 = 29000 paise
'currency': 'INR',
'receipt': f'order_{workspace.id}_{user.id}',
'notes': {
'workspace_id': str(workspace.id),
'user_id': str(user.id),
'purpose': 'pro_upgrade',
},
})
# Store order['id'] in DB before returning to client
order_record = Order.objects.create(
razorpay_order_id=order['id'],
workspace=workspace,
amount_paise=29000,
status='created',
)
return {'order_id': order['id'], 'amount': 29000, 'currency': 'INR'}
Amount Rules
- Amount is always in paise (smallest Indian currency unit): ₹1 = 100 paise
- Never use floats — use integers only
- ₹290 → 29000 paise
- Validate: minimum ₹1 (100 paise), maximum varies by Razorpay plan
Notes (Metadata)
notes is Razorpay's equivalent of Stripe metadata. Add workspace and user identifiers:
'notes': {
'workspace_id': str(workspace.id),
'user_id': str(user.id),
'plan': 'pro',
'purpose': 'subscription_initial',
}
Rules:
- Maximum 15 key-value pairs
- Maximum 256 characters per value
- Use
snake_casekeys consistently - Convert all values to
str()(no integers or UUIDs directly)
3. Payment Verification — Critical Server-Side Step
Why This Is Non-Negotiable
Razorpay's client-side handler callback fires when payment is complete, but it can be faked. Always verify server-side before activating anything.
Verification Logic
After the client sends the payment details to your server:
import hmac
import hashlib
def verify_razorpay_signature(order_id, payment_id, signature, key_secret):
"""
Verify the Razorpay payment signature.
Returns True if valid, False if tampered.
"""
message = f'{order_id}|{payment_id}'.encode('utf-8')
expected = hmac.new(
key_secret.encode('utf-8'),
message,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Server Endpoint (after client callback)
def verify_payment(request):
data = request.data # from POST body
order_id = data['razorpay_order_id']
payment_id = data['razorpay_payment_id']
signature = data['razorpay_signature']
if not verify_razorpay_signature(order_id, payment_id, signature, settings.RAZORPAY_KEY_SECRET):
return Response({'error': 'INVALID_SIGNATURE', 'message': 'Payment verification failed.'}, status=400)
# Signature valid — update order status
order = Order.objects.get(razorpay_order_id=order_id)
order.razorpay_payment_id = payment_id
order.status = 'paid'
order.save()
activate_subscription(order)
return Response({'message': 'Payment verified.'})
Rules
- Never trust
razorpay_payment_idorrazorpay_order_idfrom the client without verifying the signature - Use
hmac.compare_digest()for timing-safe comparison — never== - Activate subscriptions/orders ONLY after successful server-side verification
4. Checkout.js Integration (Frontend)
Loading Razorpay
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
Opening the Checkout
async function initiatePayment() {
// Step 1: create order server-side
const res = await fetch('/api/create-order/', { method: 'POST', ... });
const { order_id, amount, currency } = await res.json();
// Step 2: open checkout
const options = {
key: RAZORPAY_KEY_ID, // from env — publishable, safe for frontend
amount: amount, // in paise
currency: currency,
order_id: order_id,
name: 'Your App Name',
description: 'Pro Plan Subscription',
handler: async function(response) {
// Step 3: send to server for verification — do NOT activate here
await fetch('/api/verify-payment/', {
method: 'POST',
body: JSON.stringify({
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
}),
});
},
prefill: {
name: user.name,
email: user.email,
},
theme: { color: '#3b82f6' },
};
const rzp = new Razorpay(options);
rzp.on('payment.failed', function(response) {
showError('Payment failed: ' + response.error.description);
});
rzp.open();
}
Rules
- Always pass
order_id(from your server's Order creation) — never open checkout without it handlercallback: send all three fields (order_id,payment_id,signature) to your server for verification — do NOT activate anything client-side- Handle
payment.failedevent — show error to user
5. Subscriptions
Plan Creation (one-time setup)
plan = client.plan.create({
'period': 'monthly',
'interval': 1,
'item': {
'name': 'Pro Plan',
'amount': 29000, # in paise
'unit_amount': 29000,
'currency': 'INR',
},
'notes': {
'plan': 'pro',
},
})
Store plan.id in your config or DB — reference it when creating subscriptions.
Creating a Subscription
subscription = client.subscription.create({
'plan_id': plan_id,
'customer_notify': 1,
'total_count': 12, # number of billing cycles (12 = 1 year)
'quantity': 1,
'notes': {
'workspace_id': str(workspace.id),
'user_id': str(user.id),
},
})
Subscription States
| Status | Meaning | Action |
|---|---|---|
created |
Not yet authorized | Do not grant access |
authenticated |
Authorized, first payment pending | Do not grant access yet |
active |
Paying | Grant access |
pending |
Payment retrying | Warn user |
halted |
Retries exhausted | Restrict access |
cancelled |
Cancelled | Revoke access |
completed |
All billing cycles done | Revoke or renew |
expired |
Past end date | Revoke access |
Subscription Events to Handle via Webhook
subscription.activated → grant access
subscription.charged → record billing
subscription.cancelled → revoke access
subscription.halted → restrict access, notify user
subscription.completed → handle renewal or expiry
6. Webhooks
Webhook Signature Verification
import hmac
import hashlib
def verify_razorpay_webhook(payload_body, razorpay_signature, webhook_secret):
expected = hmac.new(
webhook_secret.encode('utf-8'),
payload_body, # raw bytes — do NOT decode or parse first
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, razorpay_signature)
def razorpay_webhook(request):
payload = request.body # raw bytes
signature = request.META.get('HTTP_X_RAZORPAY_SIGNATURE', '')
if not verify_razorpay_webhook(payload, signature, settings.RAZORPAY_WEBHOOK_SECRET):
return HttpResponse(status=400)
event = json.loads(payload)
handle_event(event)
return HttpResponse(status=200)
Idempotency
def handle_event(event):
event_id = event.get('id') or event['payload']['payment']['entity']['id']
if RazorpayEvent.objects.filter(event_id=event_id).exists():
return # Already processed
RazorpayEvent.objects.create(event_id=event_id, event_type=event['event'])
# process...
Webhook Response Rules
- Always return 200 after receiving (even if skipping the event)
- Never return 500 for business logic failures — log and return 200
- Razorpay retries on non-200 responses — returning 500 causes duplicates
Events to Handle
| Event | Action |
|---|---|
payment.authorized |
Payment authorized — capture if auto-capture disabled |
payment.captured |
Payment captured — activate order |
payment.failed |
Notify user, mark order as failed |
subscription.activated |
Grant subscription access |
subscription.charged |
Record payment in billing history |
subscription.cancelled |
Mark as cancelled, revoke at period end |
subscription.halted |
Restrict access, send payment failure email |
refund.created |
Record refund in DB, update payment status |
7. Refunds
refund = client.payment.refund(payment_id, {
'amount': 15000, # partial refund in paise; omit for full refund
'speed': 'normal', # or 'optimum' for faster (higher cost)
'notes': {
'reason': 'Customer request',
'refunded_by': str(admin_user.id),
},
'receipt': f'refund_{payment_id}',
})
Rules
- Store
refund.idin your database - Confirm via webhook
refund.createdbefore marking fully refunded - Partial refund: provide
amountin paise; omit for full refund - Speed options:
normal(5-7 days),optimum(immediate if supported, higher cost) - Reason is required by Razorpay for some refund types
8. Test Credentials
Use Razorpay's test mode with these test cards:
| Card | Result |
|---|---|
| 4111 1111 1111 1111 | Success |
| 5104 0600 0000 0008 | Success (Mastercard) |
| 4111 1111 1111 1111 + CVV 123 | 3DS challenge |
| Any card with expiry in past | Decline |
UPI test IDs: success@razorpay (success), failure@razorpay (failure).
Net Banking test: select any bank in test mode — all succeed.