Kommo + Loops: modern B2B email for SaaS teams from the sales pipeline

Kommo + Loops: modern B2B email for SaaS teams from the sales pipeline

Loops is an email platform built specifically for SaaS products: event-driven campaigns based on user actions, transactional emails, and onboarding sequences. It differs from Mailchimp or GetResponse by its focus on product-led growth: instead of “contact lists”, Loops works with events (user.signed_up, trial.started, plan.upgraded). There is no native Kommo integration — we break down the architecture of an event-driven connection: action in Kommo -> event in Loops -> email series.

Loops vs Mailchimp vs Customer.io for SaaS B2B

ParameterLoopsMailchimpCustomer.io
ModelEvent-driven (SaaS-first)List-basedEvent-driven (enterprise)
Transactional emailsYes (native)Via Mandrill (extra cost)Yes
Onboarding sequencesYesWith limitationsYes
API-firstYes (REST v1)Yes, but more complexYes
Pricefrom $49/monthfrom $13/monthfrom $100/month
UI simplicityHighMediumComplex
Native KommoNoNoNo

Loops is chosen by SaaS teams with up to 5,000 contacts that need a simple event model without the overhead of enterprise tools.

Architecture: what is synchronized

Kommo -> Loops:
— New contact (lead) -> POST /contacts/create in Loops
— Email/name change -> PUT /contacts/update
— Won -> POST /events/send with event deal_won -> trigger Welcome Series
— Stage change -> POST /events/send -> corresponding email series

Loops -> Kommo:
— Webhook email.bounced -> Note + Task: “Email not delivered”
— Webhook contact.unsubscribed -> Note: “Unsubscribed from mailing list”

Loops REST API v1: basic requests

Base URL: https://app.loops.so/api/v1. Authentication: Authorization: Bearer {api_key} (from Loops -> Settings -> API).

import requests

LOOPS_API_KEY = "your_loops_api_key"
LOOPS_BASE    = "https://app.loops.so/api/v1"
LOOPS_HEADERS = {
    "Authorization": f"Bearer {LOOPS_API_KEY}",
    "Content-Type":  "application/json",
}

def create_loops_contact(email: str, name: str = "",
                          company: str = "", user_group: str = "") -> dict:
    # Create or update a contact (upsert by email)
    first_name = name.split()[0] if name else ""
    last_name  = " ".join(name.split()[1:]) if len(name.split()) > 1 else ""
    payload = {
        "email":       email,
        "firstName":   first_name,
        "lastName":    last_name,
        "companyName": company,
        "userGroup":   user_group,
        "source":      "kommo_crm",
    }
    resp = requests.post(
        f"{LOOPS_BASE}/contacts/create",
        headers=LOOPS_HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json()

def update_loops_contact(email: str, properties: dict) -> dict:
    payload = {"email": email, **properties}
    resp = requests.put(
        f"{LOOPS_BASE}/contacts/update",
        headers=LOOPS_HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json()

def send_loops_event(email: str, event_name: str,
                      properties: dict = None) -> dict:
    # Send an event -> trigger email series
    payload = {
        "email":      email,
        "eventName":  event_name,
        "eventProperties": properties or {},
    }
    resp = requests.post(
        f"{LOOPS_BASE}/events/send",
        headers=LOOPS_HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json()

def send_loops_transactional(email: str,
                              transactional_id: str,
                              data_variables: dict = None) -> dict:
    # Transactional email (contract, invoice, welcome)
    payload = {
        "email":             email,
        "transactionalId":   transactional_id,
        "dataVariables":     data_variables or {},
    }
    resp = requests.post(
        f"{LOOPS_BASE}/transactional",
        headers=LOOPS_HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json()

Kommo webhook -> Loops event

Kommo -> Settings -> Webhooks -> Add triggers deal events. On a stage change or new lead, fire the corresponding Loops event:

@app.route("/webhooks/kommo", methods=["POST"])
def kommo_webhook():
    payload   = request.json
    event     = list(payload.keys())[0]  # "leads[status]", "leads[add]", etc.
    lead_data = payload.get(event, [{}])[0]

    lead_id      = lead_data.get("id")
    pipeline_id  = lead_data.get("pipeline_id")
    status_id    = lead_data.get("status_id")

    contact = get_kommo_contact_for_lead(lead_id)
    if not contact:
        return "", 200

    email = get_contact_email(contact)
    if not email:
        return "", 200

    name    = contact.get("name", "")
    company = get_contact_company(contact)

    if "add" in event:
        # New lead - create contact in Loops
        create_loops_contact(email, name, company, user_group="leads")
        send_loops_event(email, "lead_created", {
            "lead_id":   lead_id,
            "lead_name": lead_data.get("name", ""),
        })

    elif "status" in event:
        # Stage change
        if status_id == WON_STATUS_ID:
            send_loops_event(email, "deal_won", {
                "deal_value": lead_data.get("price", 0),
                "company":    company,
            })
            # Transactional Welcome email
            send_loops_transactional(
                email,
                transactional_id=WELCOME_TEMPLATE_ID,
                data_variables={"name": name, "company": company},
            )
        elif status_id in NURTURE_STAGE_IDS:
            send_loops_event(email, "entered_nurture", {
                "stage": get_stage_name(status_id),
            })

    return "", 200

Loops -> Kommo: handling unsubscribes and bounces

Loops supports webhooks via Settings -> Webhooks. Handling unsubscribes is essential — otherwise you will be emailing people who do not want to receive emails.

@app.route("/webhooks/loops", methods=["POST"])
def loops_webhook():
    payload    = request.json
    event_type = payload.get("type", "")
    contact    = payload.get("contact", {})
    email      = contact.get("email", "")

    lead_id = find_kommo_lead_by_email(email)
    if not lead_id:
        return "", 200

    if event_type == "email.bounced":
        bounce_type = payload.get("bounceType", "")  # "hard" | "soft"
        create_kommo_note(lead_id,
            f"Loops: email not delivered ({bounce_type} bounce) - {email}")
        if bounce_type == "hard":
            create_kommo_task(lead_id,
                f"Loops: update contact email - hard bounce on {email}")

    elif event_type == "contact.unsubscribed":
        create_kommo_note(lead_id,
            f"Loops: contact {email} unsubscribed from mailing list")
        # Add tag in Kommo to avoid contacting again via other channels
        add_kommo_tag(lead_id, "email_unsubscribed")

    return "", 200

SaaS onboarding: sequence from Kommo

Loops performs best in a product-led model: trial -> onboarding emails -> upgrade. If Kommo is the CRM running alongside a SaaS product, the sequence is structured as follows:

ONBOARDING_EVENTS = {
    TRIAL_STAGE_ID:    "trial_started",
    ACTIVE_STAGE_ID:   "account_activated",
    UPGRADE_STAGE_ID:  "upgrade_initiated",
    WON_STATUS_ID:     "deal_won",
}

def on_stage_change(lead_id: int, new_status_id: int):
    contact = get_kommo_contact_for_lead(lead_id)
    email   = get_contact_email(contact)
    if not email:
        return

    event_name = ONBOARDING_EVENTS.get(new_status_id)
    if event_name:
        lead    = get_kommo_lead(lead_id)
        send_loops_event(email, event_name, {
            "plan":       get_custom_field(lead, PLAN_FIELD_ID),
            "trial_days": get_custom_field(lead, TRIAL_DAYS_FIELD_ID),
        })

Real-world case

B2B SaaS (EU, 3 sales managers, Kommo + Loops):

  • Before: after Won, a manager manually added the email to a Mailchimp list and started the onboarding manually. 20–30% of new clients did not receive the onboarding series on the first day.
  • After: Won -> deal_won event in Loops -> automatic start of a 5-email onboarding series. Bounce -> Note in Kommo + Task for the manager. Unsubscribe -> tag in Kommo to avoid email contact.
  • Additionally: Trial -> trial_started -> Loops starts a 3-day urgency series. Upgrade -> upgrade_initiated -> separate sequence with instructions.

Who this is relevant for

  • SaaS companies where Kommo CRM runs alongside a SaaS product and event-driven email automation is needed
  • Teams with up to 5,000 contacts who find Mailchimp’s list-based model inconvenient
  • Product-led growth companies where trial -> onboarding -> upgrade flows through the CRM
  • Startups that need a simple email platform without the complexity of Customer.io

Frequently asked questions

Loops vs Mailchimp — what is the fundamental difference for CRM integration?

Mailchimp works with lists: you add a contact to a list -> they enter a sequence. Loops works with events: you send an event (deal_won, trial_started) -> Loops starts the appropriate series. For CRM integration, Loops’s event model is significantly cleaner — no need to manage list subscriptions; just send events from Kommo on every stage change.

Does Loops support transactional emails (invoice, contract)?

Yes. POST /transactional with a transactionalId (template ID from the Loops UI) and dataVariables (dynamic fields). Transactional emails are sent regardless of the contact’s subscription status — they are not marketing messages. For Kommo integration: Won -> send a transactional welcome + invoice email with data from the deal.

How does Loops handle duplicate contacts?

Loops uses email as the primary key. POST /contacts/create with an already-existing email is an upsert (property update). Duplicates by email are not possible. If a contact in Kommo changes their email — create a new contact in Loops and delete the old one via DELETE /contacts with the email parameter.

Loops tracks opens and clicks — can these be seen in Kommo?

Loops does not provide per-contact webhooks for individual opens (only aggregate data in the dashboard). For email engagement in Kommo, it is better to use Loops -> Zapier/Make (if clicks need to become Notes), or Customer.io where per-event webhooks are richer. Loops provides webhooks: email.bounced, contact.unsubscribed, email.spam_complaint — these are sufficient for list hygiene.

Summary

  • API: Bearer token, base URL https://app.loops.so/api/v1
  • Contacts: POST /contacts/create (upsert by email), PUT /contacts/update
  • Events: POST /events/send with eventName -> trigger email series in Loops
  • Transactional: POST /transactional with transactionalId + dataVariables
  • Loops -> Kommo webhook: email.bounced -> Note/Task, contact.unsubscribed -> Note + tag

If you have a SaaS product with Kommo and want event-driven onboarding email without Mailchimp — describe your current pipeline and contact volume. Exceltic.dev will set up the integration in 1–2 business days.

More articles

All →