Kommo + Plane: автоматическое создание проектов из выигранных сделок через open-source PM

Plane - open-source альтернатива Jira и Linear для управления проектами. Self-hosted или облако (plane.so). В отличие от Jira - нет вендор-локина и лицензионных расходов на $20-45/пользователя. Для команд, которые ведут продажи в Kommo и доставку в Plane, типичная проблема: сделка закрывается, но проект в Plane создаётся вручную - задержка 1-3 дня, потеря контекста сделки. Кастомная интеграция решает: при переходе сделки в Closed Won автоматически создаётся проект в Plane с набором стартовых задач.

Plane REST API использует заголовок X-Api-Key. Ключевые объекты: Workspace (организация), Project, Issue (задача), Cycle (спринт). Для создания проекта из шаблона в Plane нет нативного API “создать из шаблона” - задачи создаются по списку через POST /api/v1/workspaces/{slug}/projects/{id}/issues/.

Plane Workspace Slug - уникальный идентификатор организации в URL (например my-company из app.plane.so/my-company/). Используется во всех API-вызовах.

Архитектура

Kommo: сделка -> Closed Won
  -> Kommo webhook: leads.status.changed (status_id = won)
  -> Ваш сервер

Ваш сервер
  -> Plane API: создать Project (название = deal.name + клиент)
  -> Plane API: создать Issues по шаблону (онбординг чеклист)
  -> Plane API: назначить ответственного (matching менеджер -> Plane member)
  -> Kommo API: сохранить plane_project_url в custom field сделки

Реализация

import requests, os, json as json_lib
from flask import Flask, request, jsonify

app = Flask(__name__)

PLANE_API_KEY     = os.environ["PLANE_API_KEY"]
PLANE_BASE_URL    = os.environ.get("PLANE_BASE_URL", "https://api.plane.so")
PLANE_WORKSPACE   = os.environ["PLANE_WORKSPACE_SLUG"]
PLANE_TEMPLATE_PROJECT_ID = os.environ["PLANE_TEMPLATE_PROJECT_ID"]

KOMMO_SUBDOMAIN   = os.environ["KOMMO_SUBDOMAIN"]
KOMMO_TOKEN       = os.environ["KOMMO_ACCESS_TOKEN"]
KOMMO_WON_STATUS  = int(os.environ["KOMMO_WON_STATUS_ID"])
KOMMO_CF_PLANE    = int(os.environ["KOMMO_CF_PLANE_URL"])

PLANE_HDR  = {"X-Api-Key": PLANE_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"}

ONBOARDING_ISSUES = [
    {"name": "Kickoff встреча с клиентом", "priority": "urgent"},
    {"name": "Настройка доступов и окружения", "priority": "high"},
    {"name": "Подготовить технический бриф", "priority": "high"},
    {"name": "Согласовать таймлайн и milestone", "priority": "medium"},
    {"name": "Завести проект в документации", "priority": "medium"},
    {"name": "Выслать клиенту Welcome письмо", "priority": "low"},
]

def get_lead(lead_id: int) -> dict:
    r = requests.get(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        params={"with": "contacts,custom_fields_values"},
    )
    return r.json()

def create_plane_project(name: str, description: str) -> dict:
    payload = {
        "name":        name[:100],  # Plane лимит: 100 символов
        "identifier":  name[:5].upper().replace(" ", ""),  # project key
        "description": description,
        "network":     2,  # 0=Secret, 2=Public
    }
    r = requests.post(
        f"{PLANE_BASE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/",
        headers=PLANE_HDR,
        json=payload,
    )
    r.raise_for_status()
    return r.json()

def create_plane_issue(project_id: str, name: str, priority: str, description: str = "") -> str:
    # priority: "urgent", "high", "medium", "low", "none"
    priority_map = {"urgent": 0, "high": 1, "medium": 2, "low": 3, "none": 4}
    payload = {
        "name":        name,
        "description": description,
        "priority":    priority_map.get(priority, 2),
        "state":       None,  # будет назначен default state
    }
    r = requests.post(
        f"{PLANE_BASE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/{project_id}/issues/",
        headers=PLANE_HDR,
        json=payload,
    )
    if r.status_code == 201:
        return r.json().get("id", "")
    return ""

def update_kommo_lead(lead_id: int, plane_url: str):
    requests.patch(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        json={"custom_fields_values": [{
            "field_id": KOMMO_CF_PLANE,
            "values":   [{"value": plane_url}],
        }]},
    )
    requests.post(
        f"{KOMMO_BASE}/notes",
        headers=KOMMO_HDR,
        json=[{
            "entity_id":   lead_id,
            "entity_type": "leads",
            "note_type":   "common",
            "params":      {"text": f"Plane: проект создан - {plane_url}"},
        }],
    )

@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")
        is_won     = lead_data.get("old_status_id") != 142  # 142 = won in most Kommo accounts

        if new_status != KOMMO_WON_STATUS:
            continue

        lead         = get_lead(lead_id)
        deal_name    = lead.get("name", f"Сделка #{lead_id}")
        deal_value   = lead.get("price", 0) or 0

        contacts = lead.get("_embedded", {}).get("contacts", [])
        company  = ""
        if contacts:
            company = contacts[0].get("name", "")

        project_name = f"{deal_name} - {company}" if company else deal_name
        description  = f"Kommo Deal #{lead_id}. Сумма: ${deal_value}. Источник: Kommo CRM."

        project = create_plane_project(project_name, description)
        project_id = project.get("id", "")

        if not project_id:
            continue

        for issue_tpl in ONBOARDING_ISSUES:
            create_plane_issue(project_id, issue_tpl["name"], issue_tpl["priority"])

        # URL проекта в Plane
        plane_url = f"https://app.plane.so/{PLANE_WORKSPACE}/projects/{project_id}/issues/"
        update_kommo_lead(lead_id, plane_url)

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

Self-hosted Plane: изменение BASE_URL

Для self-hosted Plane установить PLANE_BASE_URL=https://plane.yourcompany.com. API path остаётся идентичным. Self-hosted версия бесплатна для неограниченного числа пользователей - это ключевое преимущество для команд 10+.

Plane Cycles (спринты) из сделки

Если нужно создать не просто задачи, но и первый спринт:

def create_plane_cycle(project_id: str, name: str) -> str:
    import datetime
    today = datetime.date.today()
    end   = today + datetime.timedelta(days=14)
    r = requests.post(
        f"{PLANE_BASE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/{project_id}/cycles/",
        headers=PLANE_HDR,
        json={
            "name":       name,
            "start_date": today.isoformat(),
            "end_date":   end.isoformat(),
        },
    )
    return r.json().get("id", "") if r.status_code == 201 else ""

Вызвать после создания проекта, затем добавить issues в цикл через POST .../cycles/{cycle_id}/cycle-issues/.

Реальный кейс

Агентство разработки, 5 разработчиков + 2 менеджера продаж. Kommo для продаж, Plane (self-hosted) для проектов. До интеграции: задержка 2-3 дня между закрытием сделки и стартом проекта - разработчики ждут постановки задач. После: при переходе в Closed Won в течение 30 секунд создаётся проект с 6 онбординг-задачами. Команда видит новый проект немедленно.

Для кого актуально

Агентства, IT-команды, консалтинг - компании, где продажи в CRM и исполнение в PM-инструменте. Plane особенно подходит командам, которые хотят избежать Jira-лицензий и имеют возможность self-hosting.

Аналогичные интеграции описаны для Kommo + Notion и Kommo + Linear.

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

Как получить Plane API Key?

Plane (облако): Settings -> API Tokens -> Create Token. Self-hosted: Settings -> API Tokens (тот же путь в интерфейсе). Токен создаётся на уровне пользователя - используйте сервисный аккаунт, не личный.

Есть ли лимиты API в Plane?

В облачной версии: 60 запросов в минуту на API Key. Для интеграции с Kommo (создание проекта + 6 задач = ~8 запросов на сделку) - лимит несущественен даже при 50 сделках в день. Self-hosted версия без ограничений.

Как назначить ответственного в Plane issue?

При создании issue добавить "assignees": ["{plane_member_id}"]. Member ID получить через GET /api/v1/workspaces/{slug}/members/. Заранее создайте маппинг: kommo_user_id -> plane_member_id - и назначайте ответственного по responsible_user из сделки Kommo.

Итог

Kommo + Plane - автоматическое создание проекта при Closed Won:

  • X-Api-Key header, POST /api/v1/workspaces/{slug}/projects/
  • Создание Issues из шаблонного списка с priority
  • Сохранить plane_url в Kommo custom field
  • Self-hosted: сменить PLANE_BASE_URL, API path идентичен
  • Plane бесплатен self-hosted для команд любого размера

Если нужна интеграция Kommo с Plane или другим open-source PM - опишите задачу команде Exceltic.dev.

Ещё статьи

Все →