Kommo + Moosend: Email Automation from the Funnel
Integrating Kommo and Moosend via API lets you automatically add contacts to email lists when a deal changes stage, trigger automation workflows, and sync custom fields in both directions. No manual CSV exports, no delays between a CRM update and a campaign send.
Moosend has been actively claiming the EU Mailchimp alternative niche after Mailchimp raised prices and tightened terms for European accounts in 2023-2024. In Kommo-based projects we see the same pattern over and over: contacts from the CRM are added to email lists manually via weekly CSV export. By the time the campaign goes out the data is already stale - the deal is closed, the contact has moved to a different segment, yet the email still goes out with the wrong context. Below is the integration architecture, working Python code, and a real-world case with numbers.
Why the Native Kommo and Moosend Integration Does Not Solve the Problem
Kommo has no native connector to Moosend. Kommo’s official integrations page offers MailChimp and a handful of other ESPs, but Moosend is not on the list. Options via Zapier or Make work, but come with fundamental limitations.
A Zapier trigger fires on a Kommo webhook event but cannot check whether a subscriber already exists in Moosend before adding them - this creates duplicates. Make handles logic better, but at 500+ updates per month the cost of operations grows faster than the value delivered. Neither Zapier nor Make provide tools for two-way sync: if a contact unsubscribes in Moosend, the status in Kommo will not change.
A custom API integration covers all these scenarios: idempotent upsert, custom field mapping, and a reverse webhook from Moosend on unsubscribe.
Integration Architecture
Stack: Python 3.11, Kommo Webhooks, Moosend REST API v3, PostgreSQL for the sync log.
Data flow (Kommo -> Moosend):
- Kommo sends a webhook when a deal changes stage (
lead.status) - Python server receives the event and fetches lead data from the Kommo API
- Field mapping:
name,email,custom_fields(industry, deal_value, stage) - POST to the Moosend Subscribers API - add or update the subscriber
- Trigger an automation workflow via the Moosend API if needed
Reverse flow (Moosend -> Kommo):
- Moosend sends a webhook on
UnsubscribeEvent - Server finds the contact in Kommo by email
- Updates the custom field
email_opt_intofalse - Adds a note to the deal card
Auth: Moosend uses an API Key as the apikey query string parameter. Generate it in account settings: Settings -> API key. Kommo uses a long-lived access token via OAuth2.
import hmac
import hashlib
import logging
import requests
from datetime import datetime, timezone
from flask import Flask, request, jsonify
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
MOOSEND_API_KEY = "your-moosend-api-key"
MOOSEND_BASE = "https://api.moosend.com/v3"
KOMMO_BASE = "https://your-domain.kommo.com"
KOMMO_TOKEN = "your-kommo-long-lived-token"
# Mapping of Kommo funnel stages to Moosend mailing lists
STAGE_TO_LIST = {
142: "mailing-list-id-trial", # Trial
143: "mailing-list-id-demo", # Demo scheduled
144: "mailing-list-id-negotiation", # Negotiation
145: "mailing-list-id-won", # Won
}
def get_kommo_lead(lead_id: int) -> dict:
"""Fetch lead data from the Kommo API."""
url = f"{KOMMO_BASE}/api/v4/leads/{lead_id}?with=contacts,custom_fields_values"
headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
return resp.json()
def get_contact_email(contact_id: int) -> str | None:
"""Get contact email from Kommo."""
url = f"{KOMMO_BASE}/api/v4/contacts/{contact_id}?with=custom_fields_values"
headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
for field in data.get("custom_fields_values", []):
if field["field_code"] == "EMAIL":
return field["values"][0]["value"]
return None
def subscribe_to_moosend(
list_id: str,
email: str,
name: str,
custom_fields: dict
) -> dict:
"""
Add or update a subscriber in Moosend.
POST /v3/subscribers/{MailingListID}/subscribe.json
If the subscriber already exists, Moosend will update their data (upsert semantics).
"""
url = f"{MOOSEND_BASE}/subscribers/{list_id}/subscribe.json"
payload = {
"Name": name,
"Email": email,
"CustomFields": [
f"{k}={v}" for k, v in custom_fields.items()
],
"HasExternalDoubleOptIn": False # double opt-in is handled by Moosend
}
resp = requests.post(
url,
json=payload,
params={"apikey": MOOSEND_API_KEY},
timeout=10
)
resp.raise_for_status()
return resp.json()
def update_kommo_contact_field(contact_id: int, field_id: int, value: str):
"""Update a contact custom field in Kommo."""
url = f"{KOMMO_BASE}/api/v4/contacts/{contact_id}"
headers = {
"Authorization": f"Bearer {KOMMO_TOKEN}",
"Content-Type": "application/json"
}
payload = {
"custom_fields_values": [
{"field_id": field_id, "values": [{"value": value}]}
]
}
resp = requests.patch(url, json=payload, headers=headers, timeout=10)
resp.raise_for_status()
@app.route("/kommo/webhook", methods=["POST"])
def kommo_webhook():
"""Webhook from Kommo: deal stage change."""
data = request.json
leads = data.get("leads", {}).get("status", [])
for lead_event in leads:
lead_id = lead_event.get("id")
status_id = lead_event.get("status_id")
if status_id not in STAGE_TO_LIST:
continue
list_id = STAGE_TO_LIST[status_id]
try:
lead_data = get_kommo_lead(lead_id)
lead = lead_data
# Get email from the first deal contact
contacts = lead.get("_embedded", {}).get("contacts", [])
if not contacts:
logging.warning(f"Lead {lead_id}: no contacts")
continue
contact_id = contacts[0]["id"]
email = get_contact_email(contact_id)
if not email:
logging.warning(f"Contact {contact_id}: no email")
continue
# Map custom fields from Kommo to Moosend
custom_fields = {}
for cf in lead.get("custom_fields_values", []):
if cf["field_code"] == "INDUSTRY":
custom_fields["Industry"] = cf["values"][0]["value"]
elif cf["field_id"] == 123456: # Deal Value field ID
custom_fields["DealValue"] = str(cf["values"][0]["value"])
custom_fields["KommoStage"] = str(status_id)
custom_fields["LastSync"] = datetime.now(timezone.utc).isoformat()
result = subscribe_to_moosend(
list_id=list_id,
email=email,
name=lead.get("name", ""),
custom_fields=custom_fields
)
logging.info(f"Synced lead {lead_id} to Moosend list {list_id}: {result}")
except requests.HTTPError as e:
logging.error(f"HTTP error for lead {lead_id}: {e.response.status_code} {e.response.text}")
except Exception as e:
logging.exception(f"Unexpected error for lead {lead_id}: {e}")
return jsonify({"status": "ok"})
@app.route("/moosend/webhook", methods=["POST"])
def moosend_unsubscribe_webhook():
"""
Webhook from Moosend on unsubscribe (UnsubscribeEvent).
Updates the custom field in Kommo.
"""
data = request.json
event_type = data.get("EventType")
if event_type != "UnsubscribeEvent":
return jsonify({"status": "ignored"})
email = data.get("Email")
if not email:
return jsonify({"status": "no_email"}), 400
# Find the contact in Kommo by email
search_url = f"{KOMMO_BASE}/api/v4/contacts?query={email}"
headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}
resp = requests.get(search_url, headers=headers, timeout=10)
contacts = resp.json().get("_embedded", {}).get("contacts", [])
if not contacts:
logging.warning(f"Unsubscribe: contact not found for {email}")
return jsonify({"status": "not_found"})
contact_id = contacts[0]["id"]
# field_id 654321 - ID of your email_opt_in custom field in Kommo
update_kommo_contact_field(contact_id, field_id=654321, value="false")
logging.info(f"Marked opt-out in Kommo for contact {contact_id} ({email})")
return jsonify({"status": "ok"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
Step-by-Step Implementation
Step 1. Configure Moosend
In your Moosend account: Settings -> API key -> copy the key. Then create mailing lists for each funnel stage. In Moosend Custom Fields add the fields: Industry, DealValue, KommoStage, LastSync. For GDPR compliance, enable double opt-in at the list level - Moosend will automatically send a confirmation email on first subscription.
Step 2. Configure the Webhook in Kommo
Kommo Settings -> Webhooks -> Add webhook. URL: https://your-server.com/kommo/webhook. Events: Lead status changed. Kommo sends a JSON payload with a leads.status array each time a stage changes.
Step 3. Field Mapping
Moosend Custom Fields accept data in the format "FieldName=Value" inside the CustomFields array. Field names in Moosend must match what you created in the list settings. Numeric values (deal value) are passed as strings.
Step 4. Moosend Webhook for Reverse Sync
Moosend Settings -> Integrations -> Webhooks -> Add your server URL for the Unsubscribe event. On unsubscribe, Moosend sends a POST with EventType: "UnsubscribeEvent" and Email. The server finds the contact in Kommo and updates the opt-in field.
Step 5. Error Handling and Idempotency
The Moosend Subscribe endpoint has upsert semantics: if a subscriber with that email already exists in the list, the request will update them. No duplicates are created. On a 429 (rate limit) error, use exponential backoff: first retry after 1 second, second after 4, third after 16. Log all errors with lead_id for retrospective review.
Real-World Case with Numbers
B2B SaaS company in the Netherlands, 25 employees, 600+ active leads in Kommo. Before integration: the marketing manager spent 2-3 hours per week manually exporting CSV from Kommo and uploading to Moosend. Email data lagged 3-7 days. About 15% of emails went to contacts who had already moved to a different funnel stage or whose deals were closed.
After launching the integration: sync latency under 30 seconds. Open rate increased from 21% to 29% in the first month - emails were now sent with the correct context. Manual work: zero. Additional benefit: when a deal moves to Won, the contact is automatically added to the Moosend onboarding list and receives a welcome sequence via Moosend automation.
GDPR: all new contacts go through double opt-in. The confirmation timestamp is stored in Moosend and accessible via API for audit purposes.
Who This Is For
The integration is relevant for B2B companies in the EU with 200+ active leads in Kommo that use email as their primary nurturing channel. Particularly effective for SaaS (trial -> demo -> won segmentation), agencies with multi-stage onboarding, and e-commerce with repeat purchases. If you have been considering custom Kommo integrations as an alternative to Zapier - Moosend via API is a textbook example of that kind of replacement.
Moosend suits teams that prioritize GDPR compliance out of the box, EU data hosting, and more predictable pricing compared to Mailchimp. For comparison with other ESPs: the Kommo + Mailchimp: contact synchronization article covers a similar architecture for Mailchimp; Kommo + Customer.io: triggered emails from the funnel describes a more complex event-driven approach for SaaS.
Frequently Asked Questions
How does Moosend authenticate API requests?
Moosend uses an API Key as the apikey query parameter in all requests to REST API v3. The key is generated in account settings: Settings -> API key. Alternatively, the key can be passed in the request header. The key has full access to the account, so store it in environment variables, not in code. Documentation: docs.moosend.com.
What happens if a subscriber already exists in the Moosend list?
The POST /v3/subscribers/{MailingListID}/subscribe.json endpoint has upsert semantics. If a subscriber with the given email already exists in the list, Moosend will update their data (name, custom fields) instead of creating a duplicate. It is safe to call this on every Kommo change - no duplicate subscribers will appear.
How do you set up GDPR double opt-in together with Kommo?
Double opt-in is enabled at the mailing list level in Moosend: List Settings -> Confirmation Email -> Enable. After that, when a new subscriber is added via API, Moosend automatically sends a confirmation email. The subscriber is activated only after clicking the link. In code, set HasExternalDoubleOptIn: false if you want Moosend to manage the opt-in process itself. The confirmation timestamp is available via the Moosend API for GDPR audits.
How do you sync unsubscribes from Moosend back to Kommo?
Moosend supports outgoing webhooks: Settings -> Integrations -> Webhooks. Configure your server URL for the Unsubscribe event. On unsubscribe, Moosend sends a POST request with the email address. The server finds the contact in Kommo by email via GET /api/v4/contacts?query=email and updates the custom field (e.g., email_opt_in = false). This is important for GDPR: you must stop email communications if a contact has unsubscribed.
Can Moosend automations be triggered from Kommo?
Yes. Moosend Automation supports the Custom Event trigger - you can send an event via API and launch an automation workflow. Endpoint: POST /v3/subscribers/{MailingListID}/trigger.json with the event name. For example, when a lead moves to the Demo scheduled stage in Kommo you can trigger the demo-reminder-sequence automation in Moosend, which will send a series of preparation emails before the meeting.
What’s Next
If you have 200+ leads in Kommo and email is your primary nurturing channel, a delay of several days between a CRM update and a campaign send directly affects conversion. This is solvable in 1-2 weeks of development.
Describe your task to the Exceltic.dev team: which ESP you use, which funnel stages need to be synced, and whether you have GDPR requirements. We will review the architecture and give an estimate of the work involved.