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-Keyheader,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.