Kommo + Razorpay: Payment Gateway for India and Emerging Markets from the Sales Pipeline

Razorpay is India’s leading payment gateway, covering 100+ payment methods: UPI, NetBanking, cards, wallets (Paytm, PhonePe), and EMI. It operates in India, Malaysia, and Singapore. For B2B SaaS companies targeting the Indian market, integrating Razorpay with Kommo solves a standard problem: create a Payment Link when a deal reaches a specific stage, and automatically move the deal to Closed Won once payment is received.

Razorpay API uses Basic Auth (Key ID + Key Secret). Payment Links API: POST /v1/payment_links - create a payment link. Webhooks: payment_link.paid and payment.captured - payment notifications. All amounts in Razorpay are in paise (1 INR = 100 paise).

Razorpay Payment Link - a payment page supporting all Indian payment methods. You can pass notes with arbitrary metadata - we use it to store kommo_lead_id.

Architecture

Kommo: deal -> stage "Send Invoice"
  -> Kommo webhook: leads.status.changed
  -> Your server

Your server
  -> Razorpay API: POST /v1/payment_links
     {amount_paise, description, notes.kommo_lead_id}
  -> Kommo: record link as a note

Client pays via UPI/Card/NetBanking
  -> Razorpay webhook: payment_link.paid
  -> Your server: verify signature
  -> Kommo: Closed Won + note with payment_id
import requests, os, hmac, hashlib, json as json_mod
from flask import Flask, request, jsonify

app = Flask(__name__)

RZP_KEY_ID     = os.environ["RAZORPAY_KEY_ID"]
RZP_KEY_SECRET = os.environ["RAZORPAY_KEY_SECRET"]
RZP_BASE       = "https://api.razorpay.com/v1"
RZP_AUTH       = (RZP_KEY_ID, RZP_KEY_SECRET)

KOMMO_SUBDOMAIN   = os.environ["KOMMO_SUBDOMAIN"]
KOMMO_TOKEN       = os.environ["KOMMO_ACCESS_TOKEN"]
INVOICE_STAGE_ID  = int(os.environ["KOMMO_INVOICE_STAGE_ID"])
CLOSED_WON_STAGE  = int(os.environ["KOMMO_CLOSED_WON_STAGE_ID"])
KOMMO_BASE        = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HDR         = {"Authorization": f"Bearer {KOMMO_TOKEN}", "Content-Type": "application/json"}

def get_lead(lead_id: int) -> dict:
    r = requests.get(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        params={"with": "contacts"},
    )
    return r.json()

def get_contact_details(contact_id: int) -> tuple[str, str, str]:
    r = requests.get(
        f"{KOMMO_BASE}/contacts/{contact_id}",
        headers=KOMMO_HDR,
        params={"with": "custom_fields_values"},
    )
    c = r.json()
    email = ""
    phone = ""
    for cf in c.get("custom_fields_values", []) or []:
        code = cf.get("field_code", "")
        vals = cf.get("values", [])
        if code == "EMAIL" and vals:
            email = vals[0].get("value", "")
        elif code == "PHONE" and vals:
            phone = vals[0].get("value", "")
    return c.get("name", ""), email, phone

def inr_to_paise(inr: float) -> int:
    return int(inr * 100)

def create_payment_link(amount_inr: float, desc: str, lead_id: int,
                        contact_name: str, contact_email: str, contact_phone: str) -> str:
    payload = {
        "amount":      inr_to_paise(amount_inr),
        "currency":    "INR",
        "description": desc[:255],
        "customer": {
            "name":  contact_name,
            "email": contact_email,
            "contact": contact_phone,
        },
        "notes": {
            "kommo_lead_id": str(lead_id),
        },
        "reminder_enable": True,
        "notify": {
            "sms":   bool(contact_phone),
            "email": bool(contact_email),
        },
    }
    r = requests.post(f"{RZP_BASE}/payment_links", auth=RZP_AUTH, json=payload)
    r.raise_for_status()
    return r.json().get("short_url", "")

def add_note(lead_id: int, text: str):
    requests.post(
        f"{KOMMO_BASE}/notes",
        headers=KOMMO_HDR,
        json=[{
            "entity_id":   lead_id,
            "entity_type": "leads",
            "note_type":   "common",
            "params":      {"text": text},
        }],
    )

@app.route("/webhooks/kommo", methods=["POST"])
def kommo_webhook():
    data = request.json or {}
    for lead_data in data.get("leads", {}).get("status", []):
        lead_id    = lead_data.get("id")
        new_status = lead_data.get("status_id")
        if new_status != INVOICE_STAGE_ID:
            continue

        lead     = get_lead(lead_id)
        budget   = lead.get("price", 0) or 0
        name     = lead.get("name", f"Deal #{lead_id}")
        contacts = lead.get("_embedded", {}).get("contacts", [])

        cname = cemail = cphone = ""
        if contacts:
            cname, cemail, cphone = get_contact_details(contacts[0]["id"])

        if budget <= 0:
            add_note(lead_id, "Razorpay: deal amount not specified, please create a Payment Link manually.")
            continue

        link = create_payment_link(float(budget), name, lead_id, cname, cemail, cphone)
        add_note(lead_id, f"Razorpay Payment Link: {link}")

    return jsonify({"status": "ok"}), 200

Implementation: Payment Webhook

def verify_razorpay_webhook(body: bytes, signature: str) -> bool:
    # Razorpay HMAC-SHA256 with Key Secret
    digest = hmac.new(RZP_KEY_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, signature)

@app.route("/webhooks/razorpay", methods=["POST"])
def razorpay_webhook():
    sig = request.headers.get("X-Razorpay-Signature", "")
    if not verify_razorpay_webhook(request.data, sig):
        return jsonify({"error": "invalid signature"}), 401

    event = request.json or {}
    ev    = event.get("event", "")

    if ev not in ("payment_link.paid", "payment.captured"):
        return jsonify({"status": "ignored"}), 200

    if ev == "payment_link.paid":
        pl       = event.get("payload", {}).get("payment_link", {}).get("entity", {})
        notes    = pl.get("notes", {})
        lead_id  = notes.get("kommo_lead_id", "")
        amount   = pl.get("amount", 0) / 100  # paise -> INR
        pay_id   = event.get("payload", {}).get("payment", {}).get("entity", {}).get("id", "")
    else:
        pay_entity = event.get("payload", {}).get("payment", {}).get("entity", {})
        notes      = pay_entity.get("notes", {})
        lead_id    = notes.get("kommo_lead_id", "")
        amount     = pay_entity.get("amount", 0) / 100
        pay_id     = pay_entity.get("id", "")

    if not lead_id:
        return jsonify({"status": "no_lead_id"}), 200

    requests.patch(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        json={"status_id": CLOSED_WON_STAGE},
    )
    add_note(
        int(lead_id),
        f"Razorpay: payment received INR {amount:.2f}. Payment ID: {pay_id}",
    )
    return jsonify({"status": "ok"}), 200

Configuring the Razorpay Webhook

  1. Razorpay Dashboard -> Settings -> Webhooks -> Add New Webhook
  2. URL: https://your-server.com/webhooks/razorpay
  3. Events: payment_link.paid, payment.captured
  4. Secret: any string -> use it as the signing secret

Razorpay signs every webhook with X-Razorpay-Signature - a hex HMAC-SHA256 of the request body using the webhook secret (not the Key Secret). Make sure you are using the correct secret in verify_razorpay_webhook.

UPI and the Diversity of Payment Methods

Razorpay Payment Link automatically displays all available payment methods:

  • UPI (GPay, PhonePe, Paytm, BHIM) - 70%+ of transactions in India
  • NetBanking - all major banks
  • Cards (Visa, Mastercard, RuPay)
  • Wallets (Paytm, Amazon Pay)
  • EMI (card-free, through banks)

No additional configuration needed - everything is available by default.

For International Payments

Razorpay supports 100+ currencies through Razorpay International (requires separate activation). A standard account is sufficient for INR payments. For Malaysia/Singapore - Razorpay Curlec (a separate product).

Real-World Case

A B2B SaaS company focused on India, 40 deals per month, average deal size 25,000 INR. Before the integration: managers created Payment Links manually in the Razorpay Dashboard. After: the link is generated automatically when a deal moves to the “Payment” stage. Razorpay automatically sends a WhatsApp reminder to the client via reminder_enable.

Who This Is For

B2B SaaS and service companies with clients in India. Especially relevant when UPI is the primary payment method for clients. Developers from India often build products on Razorpay as their first payment gateway before expanding to global markets.

A similar integration for the European market is described for Kommo + Mollie and Kommo + GoCardless.

Frequently Asked Questions

How does Razorpay handle GST for B2B deals in India?

Razorpay supports GST in invoices. When creating a Payment Link, you can add line_items with tax_amount. For B2B deals with GST, a separate tax invoice is required - Razorpay generates it automatically when GST registration is properly configured in the Dashboard.

The maximum amount for a single Payment Link is 500,000 INR (~$6,000). For larger deals, create multiple links or use the Razorpay Invoice API with installment splits. For enterprise deals (>10 lakhs INR), Razorpay NACH (direct debit) is the better option.

How does a refund work through the Razorpay API?

POST /v1/payments/{payment_id}/refund with {amount: paise}. Supports partial or full refunds. After issuing a refund, update the deal status in Kommo via the Kommo API - Razorpay does not notify about a successful refund through the same webhook; you need to listen for the separate refund.processed event.

Summary

Kommo + Razorpay - payment gateway for India:

  • Basic Auth (Key ID + Key Secret), amounts in paise (INR x 100)
  • notes.kommo_lead_id for correlating webhook -> deal
  • Webhook payment_link.paid -> HMAC-SHA256 verification -> Closed Won
  • reminder_enable: true - Razorpay automatically reminds the client
  • UPI + NetBanking + cards + wallets out of the box, no extra configuration

If your team works with the Indian market via Razorpay and Kommo - describe your challenge to the Exceltic.dev team.

More articles

All →