Building a marketplace payment system is one of those problems that looks simple until you are three days into it and questioning your life choices. I have integrated Razorpay Route on multiple marketplace projects — a tutor marketplace (NexusEd), a freelance platform, and a service aggregator — and the same set of problems comes up every time. This post is the guide I wish existed before I started.
Razorpay Route is a transfer-based system, not a split-at-source system. When a customer pays ₹1,000:
This distinction matters for your accounting: you are the merchant of record. The vendor receives a transfer, not a direct payment.
Each vendor on your platform needs a Razorpay Linked Account. Do this during onboarding:
// POST /v1/accounts (Route API)
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
async function createLinkedAccount(vendor) {
const account = await razorpay.accounts.create({
email: vendor.email,
profile: {
category: "education", // or "services", "ecommerce", etc.
subcategory: "coaching",
addresses: {
registered: {
street1: vendor.address.street,
city: vendor.address.city,
state: vendor.address.state,
postal_code: vendor.address.pincode,
country: "IN",
},
},
},
legal_business_name: vendor.businessName,
business_type: "individual", // or "route" for a company
legal_info: {
pan: vendor.pan,
},
type: "route",
});
// Store account.id as vendor.razorpayAccountId in your DB
return account;
}After creating the account, you need to create a Stakeholder and submit the account for activation. Until activated, transfers go into a holding state. Build your onboarding flow to collect PAN, bank details, and business category upfront — activation rejections are painful to debug after go-live.
Bank Account Linking
await razorpay.accounts.createStakeholder(accountId, {
name: vendor.name,
email: vendor.email,
phone: { primary: vendor.phone },
relationship: {
director: true,
},
kyc_details: {
pan: vendor.pan,
},
});Standard Razorpay payment capture — nothing special here yet:
// After client-side Razorpay checkout completes:
async function capturePayment(paymentId, amount, currency = "INR") {
return await razorpay.payments.capture(paymentId, amount, currency);
}Store the payment_id against the order in your database. You will need it for the transfer.
This is where Route does its work. After capturing, initiate transfers:
async function transferToVendor(payment, order) {
const platformFeePercent = 0.10; // 10% platform commission
const vendorAmount = Math.floor(order.amount * (1 - platformFeePercent));
const transfer = await razorpay.payments.transfer(payment.id, {
transfers: [
{
account: order.vendor.razorpayAccountId,
amount: vendorAmount, // in paise
currency: "INR",
notes: {
order_id: order.id,
vendor_id: order.vendor.id,
},
linked_account_notes: ["order_id"], // visible to vendor
on_hold: 0, // 0 = release immediately, 1 = hold
},
],
});
return transfer.items[0];
}On-hold transfers are useful if you have a dispute window. Set on_hold: 1 and release with a PATCH call after 7 days if no dispute is raised. We use this pattern on the NexusEd tutor marketplace — tutors receive payment 3 days after the class is marked complete.
Network failures happen. Without idempotency, a retry can double-pay a vendor. Razorpay supports idempotency via the X-Razorpay-Idempotency-Key header on transfer requests. Use a deterministic key derived from your order:
async function safeTransferToVendor(payment, order) {
const idempotencyKey = `transfer-${order.id}-${payment.id}`;
const response = await fetch(
`https://api.razorpay.com/v1/payments/${payment.id}/transfers`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Razorpay-Idempotency-Key": idempotencyKey,
Authorization: "Basic " + Buffer.from(
`${process.env.RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}`
).toString("base64"),
},
body: JSON.stringify({ transfers: [/* ... */] }),
}
);
return response.json();
}If the same key is sent twice, Razorpay returns the original response — no duplicate transfer.
Never poll for payment status. Subscribe to webhooks and process them asynchronously.
Key events for a marketplace:
| Event | Action |
|---|---|
| payment.captured | Trigger transfer to vendor |
| transfer.processed | Mark vendor payout as settled |
| transfer.failed | Alert ops, retry logic |
| payment.failed | Update order status, notify user |
| refund.processed | Reverse vendor transfer if within window |
// Express webhook handler
app.post("/webhooks/razorpay", express.raw({ type: "application/json" }), async (req, res) => {
const signature = req.headers["x-razorpay-signature"];
const secret = process.env.RAZORPAY_WEBHOOK_SECRET;
const isValid = razorpay.webhooks.validateWebhookSignature(
req.body.toString(),
signature,
secret
);
if (!isValid) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body);
// Idempotent processing — check if we've already handled this event
const alreadyProcessed = await db.webhookEvents.findOne({ eventId: event.id });
if (alreadyProcessed) return res.status(200).send("Already processed");
await db.webhookEvents.insert({ eventId: event.id, processedAt: new Date() });
switch (event.event) {
case "payment.captured":
await paymentService.handleCapture(event.payload.payment.entity);
break;
case "transfer.processed":
await payoutService.handleTransferSuccess(event.payload.transfer.entity);
break;
// ...
}
res.status(200).send("OK");
});Always return 200 quickly and do heavy processing in a background queue (BullMQ, Azure Service Bus). Razorpay retries webhooks with exponential backoff for up to 24 hours on non-200 responses.
At month-end, you need to verify that every order's transfer matches what Razorpay actually settled. Build a daily reconciliation job:
async function dailyReconciliation(date) {
// Fetch all orders marked "transfer_initiated" for the date
const orders = await db.orders.findByDate(date, "transfer_initiated");
for (const order of orders) {
const transfer = await razorpay.transfers.fetch(order.transferId);
if (transfer.status === "processed") {
await db.orders.update(order.id, {
status: "transfer_settled",
settledAt: new Date(transfer.processed_at * 1000),
settledAmount: transfer.amount,
});
} else if (transfer.status === "failed") {
await alertOps(`Transfer failed for order ${order.id}`);
await retryTransfer(order);
}
}
}Export this data to your accounting system daily. Razorpay also provides a Settlement API to fetch your own platform settlement data separately from vendor transfers.
A few things that bite people:
on_hold: 1 to simulate the KYC-pending state you will encounter with new vendors in production.vendor_amount + platform_fee + razorpay_fee = payment_amount. Account for this in your reconciliation.Building payment infrastructure correctly takes longer than the "it's just a Stripe integration" conversation in your sprint planning. But done right, it becomes a durable competitive advantage. The payout reliability we built into NexusEd is one of the features tutors specifically call out — they trust the platform because it pays on time, every time.