Kommo + Ortto: CDP and Email Automation from Your Sales Pipeline

Ortto (formerly Autopilot) is a Customer Data Platform (CDP) with built-in email automation, SMS, in-app messaging, and customer journey analytics. Unlike standard email tools, Ortto builds a unified user profile from all channels (web, email, CRM, product) and triggers automations based on behavior. For Kommo, the Ortto integration lets you push pipeline stage data into the CDP and launch personalized email sequences based on deal progress.

Ortto’s API uses the X-Api-Key header. Key operations: upsert person (create or update a profile), track activity (record an event), trigger journey (start an automation). Bidirectional: Ortto can send webhooks on specific events (email opened, link clicked, unsubscribed).

Ortto Journey is a visual automation builder: when an event (activity) occurs, launch a sequence of emails/SMS/tasks. The equivalent of Sequences in HubSpot or Automation in ActiveCampaign.

Why Ortto Instead of Mailchimp or ActiveCampaign

Ortto builds a unified person profile from multiple sources. If a contact entered Kommo as a lead, then became a product user, then contacted support - all of that becomes one profile in Ortto. ActiveCampaign and Mailchimp operate as isolated email tools without a CDP layer.

For B2B SaaS with a long sales cycle this means: email campaigns account not only for the stage in Kommo, but also for how the user interacts with the product (logins, features used, depth of usage).

Integration Architecture

Kommo: deal moves to a new stage
  -> Kommo webhook -> Your server
  -> Ortto API: upsert person (email, name, kommo_stage)
  -> Ortto API: track activity "crm_stage_changed"

Ortto Journey: trigger "crm_stage_changed" where stage = "Qualification"
  -> Email 1: "How did the first call go?"
  -> Wait 2 days
  -> Email 2: Case study relevant to the segment
  -> Wait 3 days -> Check: did they open the email?
  -> Branch: opened -> task for manager in Kommo

Ortto webhook: email opened, link clicked
  -> Your server
  -> Kommo: update "Email activity" field

Implementation: upsert person + track activity

import requests, os
from flask import Flask, request, jsonify

app = Flask(__name__)

ORTTO_API_KEY    = os.environ["ORTTO_API_KEY"]
ORTTO_REGION     = os.environ.get("ORTTO_REGION", "api")  # api (US) or api.eu (EU)

KOMMO_SUBDOMAIN  = os.environ["KOMMO_SUBDOMAIN"]
KOMMO_TOKEN      = os.environ["KOMMO_ACCESS_TOKEN"]
KOMMO_STAGE_MAP  = {
    # stage_id -> (ortto_stage_label, journey_activity)
    1234: ("Новый лид",       "crm_stage_new_lead"),
    1235: ("Квалификация",    "crm_stage_qualification"),
    1236: ("КП отправлено",   "crm_stage_proposal_sent"),
    1237: ("Переговоры",      "crm_stage_negotiation"),
    1238: ("Закрыта",         "crm_stage_closed_won"),
}

ORTTO_BASE = f"https://{ORTTO_REGION}.ap3api.com"
ORTTO_HDR  = {"X-Api-Key": ORTTO_API_KEY, "Content-Type": "application/json"}
KOMMO_BASE = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HDR  = {"Authorization": f"Bearer {KOMMO_TOKEN}", "Content-Type": "application/json"}

def get_lead_contact(lead_id: int) -> tuple[dict, dict]:
    r = requests.get(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        params={"with": "contacts,custom_fields_values"},
    )
    lead = r.json()
    contacts = lead.get("_embedded", {}).get("contacts", [])
    contact = {}
    if contacts:
        rc = requests.get(
            f"{KOMMO_BASE}/contacts/{contacts[0]['id']}",
            headers=KOMMO_HDR,
            params={"with": "custom_fields_values"},
        )
        contact = rc.json()
    return lead, contact

def extract_email_and_name(contact: dict) -> tuple[str, str, str]:
    email = ""
    for cf in contact.get("custom_fields_values", []) or []:
        if cf.get("field_code") == "EMAIL":
            vals = cf.get("values", [])
            if vals:
                email = vals[0].get("value", "")
                break
    full_name = contact.get("name", "")
    parts     = full_name.split(" ", 1)
    first     = parts[0] if parts else ""
    last      = parts[1] if len(parts) > 1 else ""
    return email, first, last

def ortto_upsert_person(email: str, first: str, last: str, kommo_lead_id: int, stage: str):
    payload = {
        "people": [{
            "fields": {
                "str::email":          {"v": email},
                "str::first":          {"v": first},
                "str::last":           {"v": last},
                "str::kommo_lead_id":  {"v": str(kommo_lead_id)},
                "str::kommo_stage":    {"v": stage},
            }
        }],
        "merge_by":     ["str::email"],
        "merge_strategy": 2,  # MERGE: update the existing profile
    }
    r = requests.post(f"{ORTTO_BASE}/v1/persons/merge", headers=ORTTO_HDR, json=payload)
    r.raise_for_status()
    return r.json().get("people", [{}])[0].get("id", "")

def ortto_track_activity(person_id: str, activity_id: str, attributes: dict):
    payload = {
        "activities": [{
            "activity_id": activity_id,  # "act:cm:crm-stage-changed" (configured in Ortto)
            "person_id":   person_id,
            "fields":      {k: {"v": v} for k, v in attributes.items()},
        }]
    }
    requests.post(f"{ORTTO_BASE}/v1/activities/create", headers=ORTTO_HDR, json=payload)

@app.route("/webhooks/kommo", methods=["POST"])
def kommo_webhook():
    data = request.json or {}
    for lead_data in data.get("leads", {}).get("status", []):
        lead_id    = lead_data.get("id")
        new_status = lead_data.get("status_id")

        stage_info = KOMMO_STAGE_MAP.get(new_status)
        if not stage_info:
            continue

        stage_label, activity_id = stage_info
        lead, contact            = get_lead_contact(lead_id)
        email, first, last       = extract_email_and_name(contact)

        if not email:
            continue

        person_id = ortto_upsert_person(email, first, last, lead_id, stage_label)

        ortto_track_activity(person_id, activity_id, {
            "str::lead_id":    str(lead_id),
            "str::stage":      stage_label,
            "dbl::deal_value": str(lead.get("price", 0) or 0),
        })

    return jsonify({"status": "ok"}), 200

Feedback Loop: Ortto Webhook -> Kommo

Ortto sends a webhook on: email opened, link clicked, unsubscribed, journey completed.

KOMMO_CF_EMAIL_ACTIVITY = int(os.environ["KOMMO_CF_EMAIL_ACTIVITY"])

@app.route("/webhooks/ortto", methods=["POST"])
def ortto_webhook():
    event    = request.json or {}
    ev_type  = event.get("type", "")

    if ev_type not in ("email.opened", "email.link_clicked"):
        return jsonify({"status": "ignored"}), 200

    person   = event.get("person", {})
    fields   = person.get("fields", {})
    lead_id  = fields.get("str::kommo_lead_id", {}).get("v", "")
    email_subject = event.get("email", {}).get("subject", "")

    if not lead_id:
        return jsonify({"status": "no_lead_id"}), 200

    action_label = "opened email" if ev_type == "email.opened" else "clicked in email"
    note_text    = f"Ortto: contact {action_label}: '{email_subject}'"

    requests.post(
        f"{KOMMO_BASE}/notes",
        headers=KOMMO_HDR,
        json=[{
            "entity_id":   int(lead_id),
            "entity_type": "leads",
            "note_type":   "common",
            "params":      {"text": note_text},
        }],
    )

    # Update custom field "Last email activity"
    requests.patch(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        json={"custom_fields_values": [{
            "field_id": KOMMO_CF_EMAIL_ACTIVITY,
            "values":   [{"value": f"{ev_type}: {email_subject}"}],
        }]},
    )

    return jsonify({"status": "ok"}), 200

Setting Up Custom Fields in Ortto

To store data from Kommo you need to add custom fields in Ortto:

  1. Ortto -> Settings -> Data -> Person Fields -> Add Field

    • kommo_lead_id (Text): deal ID in Kommo
    • kommo_stage (Text): current pipeline stage
  2. Ortto -> Activities -> Add Activity

    • crm_stage_changed: stage change event
    • Activity fields: lead_id (Text), stage (Text), deal_value (Number)
  3. Ortto -> Journeys -> New Journey

    • Trigger: Activity crm_stage_changed where stage = “Qualification”
    • Step 1: Send email “First call results”
    • Wait: 2 days, unless email opened (Early exit)
    • Step 2: Send case study email

Real-World Case

B2B SaaS, 150 leads per month. Before Ortto: mass email blasts with no personalization by pipeline stage. Email-to-meeting conversion rate: 2.1%. After Kommo + Ortto integration: email sequences launch automatically on stage change, subject lines personalized by segment. Conversion rate: 4.8%. Additional meetings: +23 per month.

Who This Is For

B2B SaaS with a long sales cycle (30+ days) and a team of 10-50 people. Especially if marketing and sales use different tools and there is a gap between them - Ortto as a CDP bridges it. Ortto is more expensive than Mailchimp ($99+/month), but cheaper than Marketo or Eloqua.

A similar approach to email automation is described for Kommo + Customer.io and Kommo + Campaign Monitor.

Frequently Asked Questions

How does merge_strategy work in the Ortto person upsert?

merge_strategy: 1 (OVERWRITE) - overwrites all fields of the existing profile. merge_strategy: 2 (MERGE) - updates only the fields provided, preserving the rest. For CRM integrations always use MERGE: there is no need to pass all fields from Kommo on every update.

Does Ortto store GDPR data in the EU?

Yes, when using the EU region (api.eu.ap3api.com). Select the EU Data Center when initializing your account. Accounts previously created in the US region do not have their data moved. If GDPR compliance is critical - make sure your Ortto account was created in the EU.

How do I sync unsubscribes from Ortto back to Kommo?

The person.unsubscribed webhook contains person.fields with kommo_lead_id. Update the “Email status” custom field in Kommo with the value “Unsubscribed”. This prevents a manager from manually sending an email through Kommo to someone who has unsubscribed from Ortto campaigns.

Summary

Kommo + Ortto - a CDP layer for B2B email automation:

  • X-Api-Key header, EU region for GDPR
  • POST /v1/persons/merge with merge_strategy: 2 on Kommo stage change
  • POST /v1/activities/create with activity_id - Journey trigger
  • Custom field kommo_lead_id for reverse correlation
  • Ortto webhook email.opened -> note in Kommo + update custom field

If you need a CDP integration between Kommo and Ortto - describe your pipeline to the Exceltic.dev team.

More articles

All →