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

  1. Public site requests a quote

    POST /api/quotes returns total + line items + a quote_id that expires in 10 minutes.

  2. Public site creates a hold

    POST /api/holds {quote_id} reserves inventory for 10 min. Returns a booking_id in held state.

  3. Server creates Razorpay order

    POST /api/payments/orders calls Razorpay's /v1/orders internally. Returns order_id, key, and the prefill data for the modal.

  4. 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.

  5. Client posts signature for verification

    POST /api/payments/verify. Server verifies HMAC SHA-256 of order_id|payment_id using the secret key.

  6. Webhook lands (parallel safety net)

    POST /api/webhooks/razorpay with payment.captured. Verified by signature header. Idempotent on event ID.

  7. 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

Guestbrowser
Public SiteNext.js
ABc APIbooking-svc
Razorpaycheckout + API
1 · Get a price + reserve inventory
Click "Continue"
POST /api/quotes
200 · quote + total
POST /api/holds (10-min)
201 · booking_id (held)
2 · Create a Razorpay order
POST /api/payments/orders
POST /v1/orders (server-to-server)
order_id
order_id + checkout key
Open Razorpay modal
3 · Guest pays
Guest selects UPI / card · authenticates
4 · Verify & confirm
Success callback (signed)
POST /api/payments/verify
Fetch payment status
captured · amount
5 · Side-effects (server-side)
booking → confirmed · email guest · push OTAs
6 · Webhook (parallel safety net)
payment.captured (idempotent)
Request Response Async / user action Internal event

Server endpoints we expose

EndpointPurpose
POST /api/payments/ordersCreate a Razorpay order. Auth required (public users + admins).
POST /api/payments/verifyVerify signature, confirm booking.
POST /api/payments/refundsIssue a refund. Manager or higher.
POST /api/payments/linksCreate a payment link to send to guest. Admin.
POST /api/webhooks/razorpayRazorpay → ABc events. Public, signature-verified, idempotent.

Order creation — code

POST /api/payments/orders
// 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

POST /api/payments/verify
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

EventAction
payment.capturedMark payment status captured; if it covers the booking total → confirm booking.
payment.failedLog failure; keep booking in pending_payment; user can retry.
refund.processedMark refund completed; release inventory if not already; email guest.
refund.failedFlag for manual review; alert support.
order.paidBelt-and-braces with payment.captured; identical handling.
payment.dispute.createdChargeback alert. Lock the booking, notify finance.

Webhook handler — defensive pattern

handler
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

SettingTestLive
Keyrzp_test_xxxrzp_live_xxx
Webhook URLhttps://stage.abc.com/api/webhooks/razorpayhttps://api.abc.com/api/webhooks/razorpay
CurrencyINR (sandbox)INR (real)
Test card4111 1111 1111 1111 / any CVV / future exp
Test UPIsuccess@razorpay
Never short-circuit signature verification.

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 captured payments from Razorpay for the previous day.
  • Compares to our payments table.
  • 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.