Kommo + Grafana: sales dashboard based on CRM data with no vendor lock-in

Kommo + Grafana: sales dashboard based on CRM data with no vendor lock-in

Grafana is an open-source data visualization platform: 50+ datasource plugins (Postgres, InfluxDB, Prometheus, Elasticsearch, MySQL), flexible dashboards, alerts. Widely used for infrastructure monitoring, but works excellently for business analytics when CRM data is exported to a SQL database. Unlike Metabase or Redash, Grafana is the tool for teams that already have Postgres and have no desire to pay for a BI SaaS. No vendor lock-in, no licenses — just your data and SQL.

Grafana vs Metabase vs Redash for CRM analytics

ParameterGrafanaMetabaseRedash
Open-sourceYesYes (Community)Yes
Time-series focusYesNoNo
Datasources50+ (Postgres, InfluxDB, Prometheus…)SQL databases, MongoDBSQL databases
Self-hostedYesYesYes
AlertsNative, multi-channelVia SlackLimited
Grafana CloudYesNoNo
Best forDevOps + business metrics in oneBusiness analyticsSQL dashboards

Grafana is chosen by teams that already have DevOps dashboards in Grafana and want to add business metrics to the same tool.

Architecture: Kommo -> ETL -> Postgres -> Grafana

There is no direct Grafana datasource for Kommo. The correct architecture:

Kommo REST API  ->  Python ETL (cron)  ->  Postgres  ->  Grafana
  1. Python ETL (cron every 30–60 min): pulls data from the Kommo API (deals, contacts, statuses)
  2. Postgres (or TimescaleDB for time-series): stores normalized data
  3. Grafana: PostgreSQL datasource -> SQL queries -> panels and dashboards

ETL: exporting Kommo data to Postgres

import requests
import psycopg2
from datetime import datetime, timezone, timedelta

KOMMO_SUBDOMAIN = "youraccount"
KOMMO_TOKEN     = "your_access_token"
KOMMO_BASE_URL  = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HEADERS   = {"Authorization": f"Bearer {KOMMO_TOKEN}"}

DB_DSN = "postgresql://user:pass@localhost:5432/analytics"

def fetch_leads(updated_after: datetime) -> list:
    # Paginated fetch of all deals updated after the given date
    ts    = int(updated_after.timestamp())
    leads = []
    page  = 1
    while True:
        resp = requests.get(
            f"{KOMMO_BASE_URL}/leads",
            headers=KOMMO_HEADERS,
            params={
                "updated_at[from]": ts,
                "page": page,
                "limit": 250,
            },
        )
        if resp.status_code == 204:
            break
        resp.raise_for_status()
        data = resp.json().get("_embedded", {}).get("leads", [])
        if not data:
            break
        leads.extend(data)
        page += 1
    return leads

def upsert_leads_to_postgres(leads: list):
    conn = psycopg2.connect(DB_DSN)
    cur  = conn.cursor()
    create_sql = (
        "CREATE TABLE IF NOT EXISTS kommo_leads ("
        "id BIGINT PRIMARY KEY, name TEXT, status_id INT, "
        "pipeline_id INT, responsible_user_id BIGINT, price NUMERIC, "
        "created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, "
        "closed_at TIMESTAMPTZ, loss_reason_id INT)"
    )
    cur.execute(create_sql)
    for lead in leads:
        insert_sql = (
            "INSERT INTO kommo_leads "
            "(id, name, status_id, pipeline_id, responsible_user_id, "
            "price, created_at, updated_at, closed_at, loss_reason_id) "
            "VALUES (%s,%s,%s,%s,%s,%s,"
            "to_timestamp(%s),to_timestamp(%s),to_timestamp(%s),%s) "
            "ON CONFLICT (id) DO UPDATE SET "
            "status_id=EXCLUDED.status_id, price=EXCLUDED.price, "
            "updated_at=EXCLUDED.updated_at, closed_at=EXCLUDED.closed_at, "
            "loss_reason_id=EXCLUDED.loss_reason_id"
        )
        cur.execute(insert_sql, (
            lead["id"], lead.get("name"),
            lead.get("status_id"), lead.get("pipeline_id"),
            lead.get("responsible_user_id"), lead.get("price"),
            lead.get("created_at"), lead.get("updated_at"),
            lead.get("closed_at"), lead.get("loss_reason_id"),
        ))
    conn.commit()
    cur.close(); conn.close()

def run_etl():
    updated_after = datetime.now(timezone.utc) - timedelta(hours=2)
    leads = fetch_leads(updated_after)
    if leads:
        upsert_leads_to_postgres(leads)
        print(f"ETL: {len(leads)} leads upserted")

if __name__ == "__main__":
    run_etl()

SQL queries for Grafana panels

After configuring the Postgres datasource in Grafana — build panels via SQL.

Conversion funnel (Bar Chart):

SELECT
    s.name AS stage,
    COUNT(*) AS leads,
    SUM(l.price) AS pipeline_value
FROM kommo_leads l
JOIN kommo_statuses s ON l.status_id = s.id
WHERE l.pipeline_id = $pipeline_id
  AND l.created_at >= $__timeFrom()
GROUP BY s.name, s.sort
ORDER BY s.sort;

Lead Velocity (leads created per week):

SELECT
    date_trunc('week', created_at) AS week,
    COUNT(*) AS new_leads
FROM kommo_leads
WHERE created_at >= $__timeFrom()
  AND created_at <= $__timeTo()
GROUP BY 1
ORDER BY 1;

Manager comparison (Won deals):

SELECT
    u.name AS manager,
    COUNT(*) AS won_deals,
    SUM(l.price) AS total_revenue,
    AVG(EXTRACT(EPOCH FROM (l.closed_at - l.created_at))/86400) AS avg_days_to_close
FROM kommo_leads l
JOIN kommo_users u ON l.responsible_user_id = u.id
WHERE l.status_id = $won_status_id
  AND l.closed_at >= $__timeFrom()
GROUP BY u.name
ORDER BY total_revenue DESC;

Win Rate by month:

SELECT
    date_trunc('month', closed_at) AS month,
    COUNT(*) FILTER (WHERE status_id = $won_status_id) AS won,
    COUNT(*) FILTER (WHERE status_id = $lost_status_id) AS lost,
    ROUND(
        100.0 * COUNT(*) FILTER (WHERE status_id = $won_status_id)
        / NULLIF(COUNT(*), 0), 1
    ) AS win_rate_pct
FROM kommo_leads
WHERE closed_at >= $__timeFrom()
GROUP BY 1
ORDER BY 1;

Alerts: Grafana -> team notification

Grafana Alert Rules allow: if new_leads_today = 0 -> Slack notification. If avg_deal_age > 30 days -> email to team lead.

Setup: Grafana -> Alerting -> Alert Rules -> create rule based on any SQL query.

Real-world case

SaaS startup (EU, 30 people, Kommo + Grafana + Postgres):

  • Before: sales analytics — Excel once a week. Conversion rate, lead velocity — manual. The DevOps team was already using Grafana for infrastructure monitoring.
  • After: added business metrics to the same Grafana. Python ETL every hour -> Postgres -> Grafana. One dashboard with infrastructure and sales on the same screen. No new tool — just a datasource added.
  • Key insight: lead velocity dropped 40% in one week -> Grafana alert -> retrospective held. Turned out: one lead generation channel had been switched off. Discovered in 2 days, not 2 weeks.

Who this is relevant for

  • Technical teams with Grafana for DevOps — easier to add business metrics than adopt a new BI tool
  • Startups with Postgres in the stack — no additional BI SaaS costs
  • Companies with self-hosted analytics requirements (finance, healthcare)
  • Teams that need analytics at the custom SQL level without BI tool constraints

Frequently asked questions

Grafana Cloud vs self-hosted — which to choose for CRM analytics?

Grafana Cloud Free: 3 users, 10k metrics, sufficient for a small team. Self-hosted: full control, no limits, server needed (Docker or Kubernetes). For small team CRM analytics — Grafana Cloud Free + Postgres on the same server as the ETL.

Kommo API rate limits — how to avoid exceeding them during ETL?

Kommo: 7 requests per second, 5,000 requests per hour. With incremental ETL (only updated in the last N hours) — typically 1–5 requests per run. With full sync (all deals) — pagination by 250, with a 0.2 sec delay between requests. Full sync is only needed on the first run.

Grafana Variables — how to parameterize the dashboard?

Grafana -> Dashboard Settings -> Variables. Type Query: SQL query from Postgres. For example, SELECT DISTINCT pipeline_id FROM kommo_leads -> dropdown in the dashboard. Used as $pipeline_id in SQL queries for panels. This allows switching pipeline/manager without editing SQL.

How do I add Kommo custom fields to Postgres?

Custom fields in Kommo are stored in _embedded.custom_fields_values as an array. During ETL: parse the array by field_id -> write to separate table columns or to a JSONB column. JSONB allows queries: WHERE custom_fields->>'plan' = 'growth'.

Summary

  • Architecture: Kommo API -> Python ETL (cron) -> Postgres -> Grafana PostgreSQL datasource
  • ETL: incremental by updated_at[from], upsert by id, pagination 250 leads per request
  • Key panels: funnel, lead velocity, win rate, manager comparison — all via SQL
  • Grafana Variables: parameterize by pipeline_id, manager, period
  • Alerts: Lead velocity = 0, avg deal age > threshold — Slack/email notification

If you use Kommo and want to build a Grafana dashboard — describe which metrics are critical and whether Postgres already exists in your infrastructure. Exceltic.dev will configure the ETL and a baseline set of panels.

More articles

All →