Razorpay Integration
Razorpay is the online payment rail for both the public booking flow and the "send-a-link" admin flow. We use the standard Order → Payment → Webhook → Refund cycle with strict signature verification and idempotent webhook processing.
Why Razorpay
- One integration covers cards (Visa/MC/Rupay/Amex), UPI, netbanking (60+ banks), wallets, and EMI.
- Strong India focus: instant UPI collect requests, eMandate for repeat customers, native invoice generation.
- Card-data tokenisation keeps ABc out of PCI scope.
- Mature webhook + refund APIs and good documentation.
- Per-route settlements support future "auto-payout to owner" feature.
Three integration points
Public checkout
Public site renders Razorpay Checkout JS modal. Guest pays. Booking confirmed via webhook.
Payment link
Admin clicks "Send Razorpay link" → Razorpay generates a hosted page → SMS + email to guest.
Refunds
Server-to-server refund call. Settles back to the original payment method on Razorpay's schedule.
End-to-end flow — public checkout
-
Public site requests a quote
POST /api/quotesreturns total + line items + aquote_idthat expires in 10 minutes. -
Public site creates a hold
POST /api/holds {quote_id}reserves inventory for 10 min. Returns abooking_idinheldstate. -
Server creates Razorpay order
POST /api/payments/orderscalls Razorpay's/v1/ordersinternally. Returnsorder_id,key, and the prefill data for the modal. -
Client opens Checkout modal
Razorpay JS handles UI: card, UPI, netbanking, wallets. Guest authenticates. On success, Razorpay POSTs success back to a return URL with a signature.
-
Client posts signature for verification
POST /api/payments/verify. Server verifies HMAC SHA-256 oforder_id|payment_idusing the secret key. -
Webhook lands (parallel safety net)
POST /api/webhooks/razorpaywithpayment.captured. Verified by signature header. Idempotent on event ID. -
Booking confirmed
Either path (verify call or webhook) flips the booking to
confirmed. Both paths are safe — the second one is a no-op.
Sequence diagram
Server endpoints we expose
| Endpoint | Purpose |
|---|---|
POST /api/payments/orders | Create a Razorpay order. Auth required (public users + admins). |
POST /api/payments/verify | Verify signature, confirm booking. |
POST /api/payments/refunds | Issue a refund. Manager or higher. |
POST /api/payments/links | Create a payment link to send to guest. Admin. |
POST /api/webhooks/razorpay | Razorpay → ABc events. Public, signature-verified, idempotent. |
Order creation — code
// Request { "booking_id": "bk_01HX…", "amount": 22230, // in paise — we convert internally "currency": "INR" } // Server-side (Node example) const order = await razorpay.orders.create({ amount: amountInPaise, // 22_230 * 100 = 2_223_000 currency: "INR", receipt: booking.reference, // "ABC-24817" payment_capture: 1, // auto-capture notes: { booking_id: booking.id, property: booking.property_id } }); // Response back to client { "order_id": "order_M…", "key": "rzp_live_...", "amount": 2223000, "currency": "INR", "prefill": { "name": "Priya Iyer", "email": "priya@...", "contact": "+9198..." } }
Signature verification
function verifySignature(orderId, paymentId, signature) { const expected = crypto .createHmac("sha256", razorpaySecret) .update(`${orderId}|${paymentId}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature) ); } // Webhooks use the X-Razorpay-Signature header against the raw body bytes
Webhooks we listen to
| Event | Action |
|---|---|
payment.captured | Mark payment status captured; if it covers the booking total → confirm booking. |
payment.failed | Log failure; keep booking in pending_payment; user can retry. |
refund.processed | Mark refund completed; release inventory if not already; email guest. |
refund.failed | Flag for manual review; alert support. |
order.paid | Belt-and-braces with payment.captured; identical handling. |
payment.dispute.created | Chargeback alert. Lock the booking, notify finance. |
Webhook handler — defensive pattern
app.post("/api/webhooks/razorpay", rawBody, async (req, res) => { // 1. Verify signature against raw body bytes if (!verifyWebhook(req.body, req.headers["x-razorpay-signature"])) { return res.status(400).send("bad sig"); } const event = JSON.parse(req.body); // 2. Idempotency — short-circuit if we've seen this event_id if (await webhookSeen(event.id)) { return res.status(200).send("ok (dup)"); } // 3. Process atomically await db.transaction(async (tx) => { await tx.webhooks.recordReceived(event); switch (event.event) { case "payment.captured": await onCaptured(tx, event); break; case "payment.failed": await onFailed(tx, event); break; case "refund.processed": await onRefunded(tx, event); break; // ... } }); res.status(200).send("ok"); });
Test mode vs live mode
| Setting | Test | Live |
|---|---|---|
| Key | rzp_test_xxx | rzp_live_xxx |
| Webhook URL | https://stage.abc.com/api/webhooks/razorpay | https://api.abc.com/api/webhooks/razorpay |
| Currency | INR (sandbox) | INR (real) |
| Test card | 4111 1111 1111 1111 / any CVV / future exp | — |
| Test UPI | success@razorpay | — |
The single most common Razorpay integration bug: trusting the client's "payment success" callback without server signature verification. ABc rejects any verify call without a valid signature, even in test mode.
Reconciliation
Nightly job at 03:30 IST:
- Fetches all
capturedpayments from Razorpay for the previous day. - Compares to our
paymentstable. - Logs any mismatch — e.g. payment exists in Razorpay but not in ABc → potential missed webhook → backfill.
- Logs ABc payment with no Razorpay counterpart → potential test data leak in prod → alert.
- Generates a CSV the finance team can email to their CA.