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
| Parameter | Loops | Mailchimp | Customer.io |
|---|---|---|---|
| Model | Event-driven (SaaS-first) | List-based | Event-driven (enterprise) |
| Transactional emails | Yes (native) | Via Mandrill (extra cost) | Yes |
| Onboarding sequences | Yes | With limitations | Yes |
| API-first | Yes (REST v1) | Yes, but more complex | Yes |
| Price | from $49/month | from $13/month | from $100/month |
| UI simplicity | High | Medium | Complex |
| Native Kommo | No | No | No |
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_wonevent 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/sendwitheventName-> trigger email series in Loops - Transactional:
POST /transactionalwithtransactionalId+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.