Kommo + Plane: Auto-Create Projects from Won Deals via Open-Source PM

Plane is an open-source alternative to Jira and Linear for project management. Available as self-hosted or cloud (plane.so). Unlike Jira - no vendor lock-in and no licensing costs of $20-45 per user. For teams that manage sales in Kommo and delivery in Plane, the typical problem is: a deal closes, but the project in Plane gets created manually - a delay of 1-3 days and loss of deal context. A custom integration solves this: when a deal moves to Closed Won, a Plane project is automatically created with a set of starter issues.

The Plane REST API uses the X-Api-Key header. Key objects: Workspace (organization), Project, Issue, Cycle (sprint). Plane has no native “create from template” API endpoint - issues are created individually via POST /api/v1/workspaces/{slug}/projects/{id}/issues/.

Plane Workspace Slug - the unique organization identifier in the URL (e.g. my-company from app.plane.so/my-company/). Used in all API calls.

Architecture

Kommo: deal -> Closed Won
  -> Kommo webhook: leads.status.changed (status_id = won)
  -> Your server

Your server
  -> Plane API: create Project (name = deal.name + client)
  -> Plane API: create Issues from template (onboarding checklist)
  -> Plane API: assign owner (matching manager -> Plane member)
  -> Kommo API: save plane_project_url in deal custom field

Implementation

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 meeting with client", "priority": "urgent"},
    {"name": "Set up access and environment", "priority": "high"},
    {"name": "Prepare technical brief", "priority": "high"},
    {"name": "Align on timeline and milestones", "priority": "medium"},
    {"name": "Add project to documentation", "priority": "medium"},
    {"name": "Send welcome email to client", "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 limit: 100 characters
        "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,  # will be assigned the 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: project created - {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"Deal #{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}. Value: ${deal_value}. Source: 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"])

        # Project URL in 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: Changing BASE_URL

For self-hosted Plane, set PLANE_BASE_URL=https://plane.yourcompany.com. The API path remains identical. The self-hosted version is free for an unlimited number of users - this is the key advantage for teams of 10+.

Plane Cycles (Sprints) from a Deal

If you need to create not just issues but also a first sprint:

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 ""

Call this after creating the project, then add issues to the cycle via POST .../cycles/{cycle_id}/cycle-issues/.

Real-World Case

A development agency with 5 developers and 2 sales managers. Kommo for sales, Plane (self-hosted) for projects. Before the integration: a 2-3 day delay between closing a deal and starting the project - developers waited for task assignments. After: when a deal moves to Closed Won, a project with 6 onboarding issues is created within 30 seconds. The team sees the new project immediately.

Who This Is For

Agencies, IT teams, consulting firms - companies where sales happen in a CRM and delivery happens in a PM tool. Plane is especially well-suited for teams that want to avoid Jira licensing costs and have the capacity for self-hosting.

Similar integrations are described for Kommo + Notion and Kommo + Linear.

Frequently Asked Questions

How do I get a Plane API Key?

Plane (cloud): Settings -> API Tokens -> Create Token. Self-hosted: Settings -> API Tokens (same path in the UI). The token is created at the user level - use a service account, not a personal one.

Are there API rate limits in Plane?

Cloud version: 60 requests per minute per API Key. For the Kommo integration (creating a project + 6 issues = ~8 requests per deal), the limit is negligible even at 50 deals per day. The self-hosted version has no limits.

How do I assign an owner to a Plane issue?

When creating an issue, add "assignees": ["{plane_member_id}"]. Get the member ID via GET /api/v1/workspaces/{slug}/members/. Create a mapping in advance: kommo_user_id -> plane_member_id - and assign the owner based on the responsible_user from the Kommo deal.

Summary

Kommo + Plane - automatic project creation on Closed Won:

  • X-Api-Key header, POST /api/v1/workspaces/{slug}/projects/
  • Create Issues from a template list with priority
  • Save plane_url in a Kommo custom field
  • Self-hosted: change PLANE_BASE_URL, API path is identical
  • Plane is free self-hosted for teams of any size

If you need a Kommo integration with Plane or another open-source PM tool - describe your requirements to the Exceltic.dev team.

More articles

All →