Kommo + OpenPhone: звонки и SMS из sales phone системы в карточку сделки

Почему нативная интеграция не работает

OpenPhone - относительно молодая sales phone платформа, которая быстро набирает популярность среди стартапов и SMB в Западной Европе и США. Она предлагает командные номера, запись звонков, совместный inbox, AI-транскрипцию. Для многих компаний OpenPhone заменяет классические системы IP-телефонии вроде Aircall или RingCentral - при меньшей стоимости.

Готовой нативной интеграции OpenPhone + Kommo нет. OpenPhone имеет интеграции с HubSpot и Salesforce через официальные коннекторы, но Kommo в этом списке отсутствует. Без интеграции SDR после каждого звонка вручную открывает Kommo, находит нужную сделку и пишет заметку. При 15-20 звонках в день это 30-45 минут рутины.

Для автоматизации нужно связать OpenPhone Webhooks с Kommo API через кастомный сервис.

Что реализуется - архитектура решения

Основной flow основан на обработке двух типов событий OpenPhone:

OpenPhone: call.completed
    --> Webhook --> Python сервис
        --> Kommo: поиск контакта по номеру
        --> Kommo: создание Note с деталями звонка
        --> Kommo: прикрепление ссылки на запись (если есть)

OpenPhone: message.received (входящий SMS)
    --> Webhook --> Python сервис
        --> Kommo: поиск контакта по номеру
        --> Kommo: создание Task для SDR

Технические детали

OpenPhone API Auth. Bearer token. Получается в OpenPhone Settings -> Integrations -> API. Токен не истекает автоматически, но можно ротировать вручную.

OpenPhone Webhooks. Настраиваются в Settings -> Integrations -> Webhooks. Ключевые события:

  • call.completed - звонок завершён. Payload содержит: from (номер звонящего), to (номер принявшего), direction (inbound/outbound), duration (секунды), recordingUrl (если запись включена), transcript (AI-транскрипция, если включена)
  • message.received - входящий SMS. Payload: from, to, body (текст сообщения)
  • call.ringing - звонок начался (полезно для screen pop в Kommo)

Верификация webhook OpenPhone. OpenPhone подписывает запросы заголовком OpenPhone-Signature. Это HMAC-SHA256 от тела запроса с вашим webhook secret. Аналогично FastSpring.

Матчинг по телефону в Kommo. Kommo API позволяет искать контакты по номеру телефона через GET /api/v4/contacts?query={phone}. Kommo нормализует номера, поэтому +49123456789 и 049123456789 должны находить один контакт. На практике рекомендуется нормализовать номер перед поиском.

Пошаговая реализация

Шаг 1. Нормализация номеров телефона

import re

def normalize_phone(phone: str) -> str:
    """Приводим номер к формату E.164 без + для поиска в Kommo."""
    # Убираем все нецифровые символы
    digits = re.sub(r"\D", "", phone)
    # Убираем ведущий 0 (европейский формат)
    if digits.startswith("0") and not digits.startswith("00"):
        digits = "0" + digits  # оставляем для локального формата
    return digits

Шаг 2. Поиск контакта и сделки в Kommo

import os
import requests

KOMMO_DOMAIN = os.environ["KOMMO_DOMAIN"]
KOMMO_TOKEN = os.environ["KOMMO_ACCESS_TOKEN"]


def find_contact_by_phone(phone: str) -> dict | None:
    """Ищем контакт в Kommo по номеру телефона."""
    url = f"https://{KOMMO_DOMAIN}/api/v4/contacts"
    headers = {"Authorization": f"Bearer {KOMMO_TOKEN}"}

    # Пробуем оба формата: с + и без
    for query in [phone, normalize_phone(phone)]:
        r = requests.get(url, params={"query": query}, headers=headers, timeout=10)
        if r.ok:
            contacts = r.json().get("_embedded", {}).get("contacts", [])
            if contacts:
                return contacts[0]
    return None


def get_active_lead_for_contact(contact_id: int) -> int | None:
    """Получаем ID активной сделки для контакта."""
    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 None
    links = r.json().get("_embedded", {}).get("links", [])
    lead_ids = [l["to_entity_id"] for l in links if l.get("to_entity_type") == "leads"]
    return lead_ids[0] if lead_ids else None


def create_call_note(lead_id: int, note_text: str):
    """Создаём заметку о звонке в сделке Kommo."""
    url = f"https://{KOMMO_DOMAIN}/api/v4/leads/{lead_id}/notes"
    headers = {
        "Authorization": f"Bearer {KOMMO_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = [{
        "note_type": "call_in",  # или call_out в зависимости от direction
        "params": {
            "text": note_text,
            "duration": 0,  # в секундах, опционально
        }
    }]
    r = requests.post(url, json=payload, headers=headers, timeout=10)
    return r.ok


def create_sdr_task(lead_id: int, task_text: str, contact_id: int | None = None):
    """Создаём задачу для SDR по входящему SMS."""
    url = f"https://{KOMMO_DOMAIN}/api/v4/tasks"
    headers = {
        "Authorization": f"Bearer {KOMMO_TOKEN}",
        "Content-Type": "application/json",
    }
    import time
    # Срок - 4 часа с текущего момента
    due_at = int(time.time()) + 4 * 3600

    payload = [{
        "task_type_id": 1,  # 1 = Follow up в Kommo
        "text": task_text,
        "complete_till": due_at,
        "entity_id": lead_id,
        "entity_type": "leads",
    }]
    requests.post(url, json=payload, headers=headers, timeout=10)

Шаг 3. Обработка webhook событий OpenPhone

import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)

OPENPHONE_WEBHOOK_SECRET = os.environ["OPENPHONE_WEBHOOK_SECRET"]


def verify_openphone_signature(payload: bytes, signature: str) -> bool:
    expected = hmac.new(
        OPENPHONE_WEBHOOK_SECRET.encode("utf-8"),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.route("/webhooks/openphone", methods=["POST"])
def openphone_webhook():
    signature = request.headers.get("OpenPhone-Signature", "")
    if not verify_openphone_signature(request.get_data(), signature):
        abort(403)

    event = request.json
    event_type = event.get("type")
    data = event.get("data", {}).get("object", {})

    if event_type == "call.completed":
        handle_call_completed(data)
    elif event_type == "message.received":
        handle_message_received(data)

    return {"ok": True}


def handle_call_completed(data: dict):
    """Обрабатываем завершённый звонок."""
    direction = data.get("direction", "inbound")
    duration_sec = data.get("duration", 0)
    recording_url = data.get("recordingUrl", "")
    transcript = data.get("transcript", "")

    # Определяем номер клиента
    if direction == "inbound":
        client_phone = data.get("from", "")
        note_type = "call_in"
    else:
        client_phone = data.get("to", "")
        note_type = "call_out"

    contact = find_contact_by_phone(client_phone)
    if not contact:
        print(f"Contact not found for phone: {client_phone}")
        return

    lead_id = get_active_lead_for_contact(contact["id"])
    if not lead_id:
        return

    # Формируем текст заметки
    direction_label = "Входящий" if direction == "inbound" else "Исходящий"
    duration_min = duration_sec // 60
    duration_sec_rem = duration_sec % 60

    note_parts = [
        f"OpenPhone: {direction_label} звонок",
        f"Длительность: {duration_min}:{duration_sec_rem:02d}",
        f"Телефон: {client_phone}",
    ]

    if recording_url:
        note_parts.append(f"Запись: {recording_url}")

    if transcript:
        # Обрезаем транскрипт до 500 символов
        short_transcript = transcript[:500] + "..." if len(transcript) > 500 else transcript
        note_parts.append(f"\nТранскрипт:\n{short_transcript}")

    note_text = "\n".join(note_parts)

    url = f"https://{KOMMO_DOMAIN}/api/v4/leads/{lead_id}/notes"
    headers = {
        "Authorization": f"Bearer {KOMMO_TOKEN}",
        "Content-Type": "application/json",
    }
    payload = [{"note_type": note_type, "params": {
        "text": note_text,
        "duration": duration_sec,
        "phone": client_phone,
    }}]
    requests.post(url, json=payload, headers=headers, timeout=10)


def handle_message_received(data: dict):
    """Обрабатываем входящий SMS."""
    from_phone = data.get("from", "")
    message_body = data.get("body", "")

    contact = find_contact_by_phone(from_phone)
    if not contact:
        return

    lead_id = get_active_lead_for_contact(contact["id"])
    if not lead_id:
        return

    task_text = (
        f"Входящий SMS от {from_phone}:\n"
        f"{message_body[:200]}"
    )
    create_sdr_task(lead_id, task_text, contact["id"])


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

Реальный кейс с цифрами

В типовом проекте для EU-стартапа с командой из 4 SDR, которые совершают 50-80 звонков в день через OpenPhone, интеграция с Kommo даёт ощутимый результат.

До интеграции: после каждого звонка SDR вручную открывал Kommo, искал сделку, писал заметку. По замерам команды - 2-3 минуты на звонок при хорошем раскладе, 5+ если контакт не находился быстро. При 60 звонках в день - 2-3 часа в день на команду из 4 человек.

После интеграции: заметка появляется автоматически через 5-10 секунд после завершения звонка. Если есть AI-транскрипция OpenPhone - она добавляется в заметку, и менеджер может быстро просмотреть ключевые моменты звонка без прослушивания.

По SMS: раньше SDR проверяли входящие в OpenPhone отдельно от Kommo. После интеграции каждый входящий SMS создаёт задачу «перезвонить» с 4-часовым дедлайном прямо в воронке.

Для кого подходит

Интеграция актуальна для компаний, которые:

  • Используют OpenPhone как основную телефонию для sales-команды
  • Ведут воронку в Kommo и хотят полную историю коммуникаций в карточке сделки
  • Работают в Западной Европе или США, где OpenPhone распространён
  • Имеют команду SDR от 2-3 человек, которые совершают регулярные звонки

Если вы уже используете интеграцию Kommo с Aircall или другой телефонией - принцип тот же, но OpenPhone отличается более простым API и доступной ценовой моделью.

Часто задаваемые вопросы

Что делать, если один контакт в Kommo имеет несколько телефонов? Kommo API возвращает все телефоны контакта в поле custom_fields_values с field_code: PHONE. Для матчинга нормализуйте все номера и проверяйте каждый.

OpenPhone записывает все звонки или нужно включать вручную? Запись можно включить автоматически для всех звонков в OpenPhone Settings -> Recording. В webhook payload поле recordingUrl будет заполнено только если запись была включена и звонок состоялся (не пропущен).

Что если звонок пропущен - нужно ли создавать заметку? Рекомендуется. Пропущенный звонок (direction: inbound, duration: 0) стоит фиксировать как задачу с высоким приоритетом для SDR. Это важный сигнал - клиент пытался дозвониться.

Как обрабатывать звонки, если контакта в Kommo нет? Два варианта: 1) Создавать новый контакт автоматически через POST /api/v4/contacts с номером телефона. 2) Отправлять уведомление в Slack команде с запросом на создание контакта вручную. Второй вариант безопаснее - не засоряет CRM неквалифицированными контактами.

Если вам нужна интеграция Kommo с OpenPhone - опишите ваш стек и сценарий команде Exceltic.dev. Разберём архитектуру за одну встречу.

Ещё статьи

Все →