Kommo + FastSpring: Accepting SaaS Payments from the Sales Pipeline

Why the native integration doesn’t work

FastSpring has no ready-made integration with Kommo. You won’t find an official FastSpring widget in the Kommo marketplace - and that’s not an accident. FastSpring positions itself as a Merchant of Record, meaning it takes on legal responsibility for the sale, issues invoices under its own name, and independently calculates and remits VAT for each country. For EU SaaS this is critical: you don’t need to register as a VAT payer in each of the 27 EU countries.

Kommo, on the other hand, is a sales management system where deal context lives: negotiation history, the responsible manager, the pipeline stage. Without an integration, your SDR sees a deal in “Negotiation” status and has no idea the client already paid. Data is scattered across two systems with no connection.

If you work with custom integrations for Kommo CRM, you know: standard no-code tools won’t help here - FastSpring webhooks have a specific structure and require proper signature verification.

What gets built - solution architecture

The architecture is simple: FastSpring sends a webhook on a payment event, a Python service validates the signature, and calls the Kommo API to update the deal.

FastSpring --> Webhook (order.completed / subscription.activated)
    --> Python service (HMAC-SHA256 verification)
        --> Kommo API (update deal / create note)

Technical details

FastSpring Webhooks. FastSpring signs every webhook with the X-FS-Signature header. The signature is HMAC-SHA256 of the request body using the key you set in FastSpring settings (Settings -> Webhooks). Key events:

  • order.completed - one-time payment or first subscription payment
  • subscription.activated - subscription activated
  • subscription.deactivated - subscription cancelled (useful for downgrade tracking in Kommo)
  • subscription.payment.overdue - payment overdue

FastSpring API Auth. For outgoing requests to the FastSpring API, Basic Auth is used: API username as login, empty password (string ""). This is documented FastSpring API v2 behavior.

Kommo API. Bearer token (Long-lived access token or OAuth 2.0). To update a deal - PATCH /api/v4/leads/{id}. To create a note - POST /api/v4/leads/{lead_id}/notes.

Deal matching. FastSpring passes the buyer’s email in the order.customer.email field. Use it to search for a contact in Kommo via GET /api/v4/contacts?query={email}, then retrieve the associated deal.

Step-by-step implementation

Step 1. Configure FastSpring Webhook

  1. Log into FastSpring Dashboard -> Settings -> Webhooks
  2. Add your service endpoint URL, e.g. https://your-service.example.com/webhooks/fastspring
  3. Set an HMAC Secret (remember it - needed for validation)
  4. Select events: order.completed, subscription.activated, subscription.deactivated

Step 2. Python service for webhook handling

import hmac
import hashlib
import json
import os
import requests
from flask import Flask, request, abort

app = Flask(__name__)

FASTSPRING_SECRET = os.environ["FASTSPRING_WEBHOOK_SECRET"]
KOMMO_DOMAIN = os.environ["KOMMO_DOMAIN"]  # yourcompany.kommo.com
KOMMO_TOKEN = os.environ["KOMMO_ACCESS_TOKEN"]


def verify_fastspring_signature(payload: bytes, signature: str) -> bool:
    """Verify HMAC-SHA256 signature of FastSpring webhook."""
    expected = hmac.new(
        FASTSPRING_SECRET.encode("utf-8"),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


def find_kommo_contact_by_email(email: str) -> dict | None:
    """Search for a contact in Kommo by email."""
    url = f"https://{KOMMO_DOMAIN}/api/v4/contacts"
    headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}
    r = requests.get(url, params={"query": email}, headers=headers, timeout=10)
    if not r.ok:
        return None
    data = r.json()
    contacts = data.get("_embedded", {}).get("contacts", [])
    return contacts[0] if contacts else None


def get_contact_leads(contact_id: int) -> list:
    """Get open deals for a contact."""
    url = f"https://{KOMMO_DOMAIN}/api/v4/contacts/{contact_id}/links"
    headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}
    r = requests.get(url, headers=headers, timeout=10)
    if not r.ok:
        return []
    links = r.json().get("_embedded", {}).get("links", [])
    return [l["to_entity_id"] for l in links if l.get("to_entity_type") == "leads"]


def update_lead_status(lead_id: int, status_id: int, note_text: str):
    """Update deal status and add a note."""
    headers = {
        "Authorization": f"Bearer {KOMMO_TOKEN}",
        "Content-Type": "application/json",
    }
    # Update stage
    patch_url = f"https://{KOMMO_DOMAIN}/api/v4/leads"
    requests.patch(
        patch_url,
        json=[{"id": lead_id, "status_id": status_id}],
        headers=headers,
        timeout=10,
    )
    # Add note
    notes_url = f"https://{KOMMO_DOMAIN}/api/v4/leads/{lead_id}/notes"
    requests.post(
        notes_url,
        json=[{"note_type": "common", "params": {"text": note_text}}],
        headers=headers,
        timeout=10,
    )


@app.route("/webhooks/fastspring", methods=["POST"])
def fastspring_webhook():
    signature = request.headers.get("X-FS-Signature", "")
    payload = request.get_data()

    if not verify_fastspring_signature(payload, signature):
        abort(403)

    events = request.json.get("events", [])
    for event in events:
        event_type = event.get("type")
        data = event.get("data", {})

        if event_type == "order.completed":
            handle_order_completed(data)
        elif event_type == "subscription.activated":
            handle_subscription_activated(data)

    return {"ok": True}


def handle_order_completed(data: dict):
    """Handle order completion event."""
    email = data.get("customer", {}).get("email", "")
    order_id = data.get("id", "")
    total = data.get("total", 0)
    currency = data.get("currency", "USD")

    if not email:
        return

    contact = find_kommo_contact_by_email(email)
    if not contact:
        # Contact not found in CRM - create new or log
        print(f"Contact not found for email: {email}")
        return

    lead_ids = get_contact_leads(contact["id"])
    if not lead_ids:
        return

    # Take the most recent active deal
    lead_id = lead_ids[0]
    note_text = (
        f"FastSpring: order #{order_id} paid\n"
        f"Amount: {total} {currency}\n"
        f"Email: {email}"
    )
    # STATUS_PAID_ID - the ID of the "Paid" stage in your Kommo pipeline
    STATUS_PAID_ID = int(os.environ.get("KOMMO_STATUS_PAID_ID", 0))
    update_lead_status(lead_id, STATUS_PAID_ID, note_text)


def handle_subscription_activated(data: dict):
    """Handle subscription activation."""
    email = data.get("customer", {}).get("email", "")
    subscription_id = data.get("id", "")
    product = data.get("product", {}).get("display", {}).get("en", "")

    contact = find_kommo_contact_by_email(email)
    if not contact:
        return

    lead_ids = get_contact_leads(contact["id"])
    if not lead_ids:
        return

    note_text = (
        f"FastSpring: subscription activated\n"
        f"Subscription ID: {subscription_id}\n"
        f"Product: {product}"
    )
    STATUS_WON_ID = int(os.environ.get("KOMMO_STATUS_WON_ID", 0))
    update_lead_status(lead_ids[0], STATUS_WON_ID, note_text)


if __name__ == "__main__":
    app.run(port=5000)

Step 3. Idempotency

FastSpring guarantees webhook delivery “at least once”. This means the same order.completed can arrive twice. Add deduplication by order_id:

import redis

r = redis.Redis(host="localhost", port=6379, db=0)

def is_processed(order_id: str) -> bool:
    key = f"fastspring:processed:{order_id}"
    # SETNX + TTL for 24 hours
    return not r.set(key, "1", ex=86400, nx=True)

Step 4. Error handling

Kommo API has a rate limit of 7 requests/second. On a 429 error, use exponential backoff:

import time

def kommo_request_with_retry(method, url, **kwargs):
    for attempt in range(3):
        r = requests.request(method, url, **kwargs)
        if r.status_code == 429:
            time.sleep(2 ** attempt)
            continue
        return r
    raise Exception(f"Kommo API rate limit exceeded after 3 attempts")

Real case with numbers

In a typical project for an EU SaaS with a team of 5-8 SDRs, the Kommo + FastSpring integration solves the following:

Before the integration, a manager learned about a payment from an email notification (if they didn’t miss it). Average time from payment to deal update in the CRM: 4-8 hours. With a team of 5 SDRs and 80 deals per month, that’s 40+ manual updates.

After the integration, the deal moves to “Paid” status within 10-15 seconds of the transaction completing in FastSpring. SDRs see the current status in real time. Typical savings: 6 to 10 hours of manager time per month for the team.

A separate win is EU VAT compliance. FastSpring as MOR automatically adds tax based on the buyer’s country. Sales to Germany add 19% MwSt, France 20% TVA, Hungary 27% ÁFA. You don’t need to code these calculations - FastSpring handles it automatically and reflects them in webhook data.

Who this is for

The Kommo + FastSpring integration suits companies that:

  • Sell SaaS products in the EU and don’t want to deal with VAT per country on their own
  • Use Kommo as the primary CRM and want to see payment status directly in the deal card
  • Work with a subscription model (monthly, annual plans) and want to automatically change the deal stage on subscription activation/cancellation
  • Have an SDR team of 3+ people for whom data accuracy matters

If you already use other payment integrations - for example, Kommo + Stripe - FastSpring occupies a different niche: Stripe is a payment processor, FastSpring is a full Merchant of Record with tax responsibility.

Frequently asked questions

Can I use Zapier instead of custom code? Technically yes, but FastSpring webhooks require HMAC-SHA256 signature verification. Zapier doesn’t support custom signature verification out of the box. Without it, anyone can send a forged webhook to your endpoint. A custom service is recommended for production use.

What happens if the email in FastSpring doesn’t match the email in Kommo? This is a common situation: the client may have used a work email when registering in Kommo and a personal one when paying. It’s recommended to add fallback matching by company domain: if @company.com is not found exactly, search for all contacts with the same domain and select by additional criteria (name, company).

FastSpring supports multiple products. How do I know which deal to attach the payment to? In the order.items field of each FastSpring event there is a product.sku. Add a SKU -> Kommo pipeline ID mapping in a configuration file. This way payment for a “Pro” product goes to the “SaaS Pro” pipeline, and “Enterprise” to “SaaS Enterprise”.

Is handling subscription.deactivated necessary? Recommended. When a subscription is cancelled, you can automatically create a “Winback call” task for an SDR in Kommo - an opportunity for retention.

If you need a Kommo + FastSpring integration - describe your stack and scenario to the Exceltic.dev team. We’ll work through the architecture in one meeting.

More articles

All →