Kommo + Paddle: Automatic Deal Closing on SaaS Subscription Payment

Kommo + Paddle: Automatic Deal Closing on SaaS Subscription Payment

Paddle is a Merchant of Record (MoR) for SaaS: it processes payments, handles VAT in each country, and issues invoices under its own name. Kommo is the CRM where your sales pipeline lives. Without an integration between them, data is fragmented: a customer pays their subscription in Paddle, but the deal in Kommo still sits at “Negotiation” status. The sales rep doesn’t know about the payment, and no automations fire.

In projects with SaaS teams, we consistently see the same scenario: sales reps manage leads in Kommo through the “Contract / Payment” stage, and then everything becomes manual. Someone on the team monitors the Paddle Dashboard, someone else messages the rep on Slack saying “payment went through,” and the rep closes the deal by hand. This breaks down at scale and only works while the team is small.

This article shows how to connect Paddle to Kommo via webhook and custom_data - so that a payment automatically closes the deal, attaches the invoice, and kicks off onboarding.

Why There Is No Native Integration

Paddle does not have a ready-made widget for Kommo in its App Marketplace. This is standard for payment MoR platforms: their job is to process the transaction, not to manage a CRM pipeline. A Zapier integration exists, but it does not solve the main problem: Zapier cannot reliably link a payment to a specific Kommo deal without additional identification logic.

Merchant of Record is a model in which Paddle legally acts as the seller of the product. It collects VAT for each country where the buyer is located and remits your revenue minus taxes and fees. For EU and US SaaS this means you do not need to register as a VAT payer in 40+ countries.

The Key Paddle Feature: custom_data

Paddle Billing v2 supports custom_data - an arbitrary JSON object that can be passed when creating a transaction or checkout session. This object is automatically copied to the subscription when it is created and appears in the body of every webhook event.

This solves the core integration problem: instead of looking up a deal by customer email after the fact, you pass kommo_lead_id at the moment of payment - and the webhook knows exactly which deal to close.

// When initializing Paddle Checkout
Paddle.Checkout.open({
  items: [{ priceId: "pri_01abc...", quantity: 1 }],
  customer: { email: customerEmail },
  customData: {
    kommo_lead_id: "12345678",      // Deal ID in Kommo
    kommo_responsible_id: "42",     // Manager ID in Kommo
    plan: "pro"
  }
});

This data will appear in data.custom_data of every webhook event - transaction.completed, subscription.activated, and throughout the subscription lifecycle.

Integration Architecture

Data flow:

  1. The sales rep manages the deal in Kommo through the “Payment” stage
  2. A Paddle Checkout is generated with kommo_lead_id in custom_data
  3. The customer pays - Paddle sends a transaction.completed webhook
  4. Our service updates the deal in Kommo: status, fields, note
  5. On subscription cancellation - Kommo receives a notification

Implementation

Step 1 - Paddle webhook verification:

from flask import Flask, request, abort
import hashlib, hmac, time

app = Flask(__name__)
PADDLE_WEBHOOK_SECRET = "pdl_ntfset_..."  # from Paddle Dashboard -> Notifications
KOMMO_DOMAIN          = "yourdomain.kommo.com"
KOMMO_TOKEN           = "your_kommo_long_lived_token"

def verify_paddle_signature(payload: bytes, signature_header: str) -> bool:
    """Paddle uses ts=TIMESTAMP;h1=HMAC_SHA256 format."""
    parts = dict(item.split("=", 1) for item in signature_header.split(";"))
    ts    = parts.get("ts", "")
    h1    = parts.get("h1", "")

    # Replay attack protection: reject events older than 5 minutes
    if abs(time.time() - int(ts)) > 300:
        return False

    signed_payload = f"{ts}:{payload.decode()}"
    expected = hmac.new(
        PADDLE_WEBHOOK_SECRET.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, h1)

@app.route("/paddle/webhook", methods=["POST"])
def paddle_webhook():
    sig  = request.headers.get("Paddle-Signature", "")
    body = request.get_data()

    if not verify_paddle_signature(body, sig):
        abort(401)

    event = request.json
    etype = event.get("event_type", "")

    if etype == "transaction.completed":
        handle_payment(event["data"])
    elif etype == "subscription.activated":
        handle_subscription_activated(event["data"])
    elif etype == "subscription.canceled":
        handle_subscription_canceled(event["data"])

    return "ok", 200

Step 2 - closing the deal on payment:

import requests

KOMMO_BASE = f"https://{KOMMO_DOMAIN}/api/v4"

def get_kommo_headers():
    return {
        "Authorization": f"Bearer {KOMMO_TOKEN}",
        "Content-Type":  "application/json"
    }

def handle_payment(txn: dict):
    custom_data = txn.get("custom_data") or {}
    lead_id     = custom_data.get("kommo_lead_id")

    if not lead_id:
        # No lead_id - search by customer email
        email = txn.get("customer", {}).get("email", "")
        lead_id = find_lead_by_email(email)

    if not lead_id:
        return  # could not identify the deal

    # Extract payment data
    amount      = txn.get("details", {}).get("totals", {}).get("total", "0")
    currency    = txn.get("currency_code", "USD")
    invoice_num = txn.get("invoice_number", "")
    paddle_txn  = txn.get("id", "")
    items       = txn.get("items", [])
    plan_name   = items[0].get("price", {}).get("name", "") if items else ""

    # Update deal: won status + custom fields
    hs = requests.Session()
    hs.headers.update(get_kommo_headers())

    hs.patch(f"{KOMMO_BASE}/leads", json=[{
        "id":     int(lead_id),
        "status_id": 142,       # 142 = Successfully Completed (won)
        "sale":   int(float(amount) * 100),  # Kommo accepts value in cents
        "custom_fields_values": [
            {"field_code": "PADDLE_TXN_ID",    "values": [{"value": paddle_txn}]},
            {"field_code": "INVOICE_NUMBER",   "values": [{"value": invoice_num}]},
            {"field_code": "PLAN_NAME",        "values": [{"value": plan_name}]},
            {"field_code": "PAYMENT_CURRENCY", "values": [{"value": currency}]},
        ]
    }])

    # Add a note with payment details
    hs.post(f"{KOMMO_BASE}/leads/notes", json=[{
        "entity_id":  int(lead_id),
        "note_type":  "common",
        "params":     {
            "text": (
                f"Paddle payment confirmed\n"
                f"Amount: {amount} {currency}\n"
                f"Invoice: {invoice_num}\n"
                f"Plan: {plan_name}\n"
                f"Transaction ID: {paddle_txn}"
            )
        }
    }])

Step 3 - subscription lifecycle handling:

def handle_subscription_activated(sub: dict):
    """Subscription activated - update fields in Kommo."""
    custom_data = sub.get("custom_data") or {}
    lead_id     = custom_data.get("kommo_lead_id")
    if not lead_id:
        return

    sub_id      = sub.get("id", "")
    next_charge = sub.get("next_billed_at", "")[:10]  # YYYY-MM-DD

    hs = requests.Session()
    hs.headers.update(get_kommo_headers())

    hs.patch(f"{KOMMO_BASE}/leads", json=[{
        "id":    int(lead_id),
        "custom_fields_values": [
            {"field_code": "PADDLE_SUB_ID",      "values": [{"value": sub_id}]},
            {"field_code": "NEXT_BILLING_DATE",  "values": [{"value": next_charge}]},
            {"field_code": "SUBSCRIPTION_STATUS","values": [{"value": "active"}]},
        ]
    }])

def handle_subscription_canceled(sub: dict):
    """Subscription canceled - create a task for the sales rep."""
    custom_data = sub.get("custom_data") or {}
    lead_id     = custom_data.get("kommo_lead_id")
    if not lead_id:
        return

    responsible_id = int(custom_data.get("kommo_responsible_id", 0)) or None
    cancel_reason  = sub.get("scheduled_change", {}).get("reason", "customer_request")

    hs = requests.Session()
    hs.headers.update(get_kommo_headers())

    hs.patch(f"{KOMMO_BASE}/leads", json=[{
        "id":    int(lead_id),
        "custom_fields_values": [
            {"field_code": "SUBSCRIPTION_STATUS", "values": [{"value": "canceled"}]},
        ]
    }])

    # Task for the rep to handle churn
    hs.post(f"{KOMMO_BASE}/tasks", json=[{
        "task_type_id":   1,       # call
        "entity_type":    "leads",
        "entity_id":      int(lead_id),
        "responsible_user_id": responsible_id,
        "text":           f"Customer canceled Paddle subscription. Reason: {cancel_reason}. Follow up on win-back.",
        "complete_till":  int(time.time()) + 86400,  # deadline +24 hours
    }])

PADDLE_TXN_ID, INVOICE_NUMBER, PLAN_NAME are custom fields in Kommo that need to be created in advance under Settings -> Fields -> Deals. The field_code is set when the field is created.

Real-World Case

A B2B SaaS company: 3 plans (Starter, Pro, Enterprise), 2-4 week sales cycle, team of 4 sales reps. Before the integration: every Paddle payment was handled manually - the rep checked the Paddle Dashboard, changed the deal status, and created a note. On average 20-30 minutes per deal, with data entry errors along the way.

After deploying the custom webhook:

  • The deal closes automatically within 3-5 seconds of payment
  • Plan, amount, and Invoice Number are written to deal fields without manual input
  • Subscription cancellation creates a task for the responsible sales rep
  • 0 missed payments over 4 months of operation

Implementation time: 2 days. custom_data with kommo_lead_id eliminates the main complexity - identifying the deal at the time of payment.

Who This Is For

SaaS companies with a subscription model and a sales pipeline in Kommo: the product is sold by sales reps, payment goes through Paddle. If you run a pure self-serve model with no sales process - CRM integration is less critical. If you have a sales-assisted model (SDR/AE manages the lead through to payment) - this connection is essential.

The same approach applies to other custom Kommo integrations with payment platforms. A similar implementation is described for Kommo + FastSpring - another MoR focused on enterprise B2B.

Frequently Asked Questions

What is the difference between Paddle and FastSpring for SaaS?

Both are Merchants of Record and handle VAT. FastSpring has traditionally been stronger in enterprise B2B with custom pricing and quote-based sales. Paddle leads among developer-led companies: modern API, better developer experience, stronger in PLG models. As of 2026, Paddle holds a larger share among devtools and API-first startups, while FastSpring is more common among mature B2B SaaS companies.

Do I need to use Paddle.js or can I use the API?

For passing custom_data with kommo_lead_id - both options work. Paddle.js (Checkout) is convenient for a self-serve flow. For a sales-assisted model, the better approach is to create the checkout session via POST /transactions in the Paddle API and send the customer the link - that way custom_data is built on the server and contains the current deal ID.

How do I handle a refund in Kommo?

Paddle sends transaction.updated with status: refunded on a refund. In our logic, we add a handler for this event: create a note in Kommo with the refund amount and change the custom field status to refunded. We leave the deal untouched - it has already been closed as won.

How does multi-subscription work (multiple products)?

If a customer purchases multiple products, transaction.items will contain multiple objects. Record all plans in PLAN_NAME separated by commas, or create a separate deal for each product - it depends on your pipeline structure in Kommo.

Can I work with Paddle in test mode?

Yes. Paddle has a sandbox environment at sandbox.paddle.com. Webhook events from the sandbox go to the same endpoint; the payload includes "test": true. In our implementation we check this field and log events without writing to Kommo - convenient for debugging.

Summary

The Kommo + Paddle integration is built on one key mechanism: custom_data with kommo_lead_id is passed when initializing Paddle Checkout and is available in every webhook event. This makes the link between a payment and a deal unambiguous without any email lookup.

The flow:

  • Paddle Checkout with custom_data: { kommo_lead_id } at the point of payment
  • Webhook transaction.completed -> Kommo: deal moved to won status, fields populated
  • Webhook subscription.activated -> Kommo: next billing date, Subscription ID
  • Webhook subscription.canceled -> Kommo: task created for the sales rep to handle churn

If your team sells SaaS through Kommo and uses Paddle for payment processing - describe your requirements to the Exceltic.dev team. We will review the architecture and set up automatic deal closing.

More articles

All →