Kommo + Tableau: Sales BI Dashboard with Live CRM Data

Kommo + Tableau: Sales BI Dashboard with Live CRM Data

Tableau is the enterprise standard for BI analytics at companies with 50+ employees. Kommo is a CRM that holds your sales pipeline data. Without an integration, sales analytics falls back to CSV exports - done manually, once a week, with stale data. For companies where revenue decisions are driven by dashboards, a data lag means a decision lag.

Tableau cannot query the Kommo REST API directly - Tableau works with databases, files, or dedicated connectors. This is why the integration architecture always requires an intermediate layer: data from Kommo first lands in a relational database (PostgreSQL), and Tableau connects to it as a live data source. This layer also enables historical analytics storage - something that is not possible with a direct Kommo connection.

In this article, we walk through a Kommo -> PostgreSQL -> Tableau ETL pipeline with incremental sync and automatic dashboard refresh via the Tableau REST API.

Why You Cannot Connect Tableau Directly to Kommo

Tableau supports three classes of data sources: native connectors (SQL databases, files), JDBC/ODBC connectors, and Tableau Web Data Connector (WDC). The Kommo REST API does not fit any of these without an additional layer.

Pagination is another key issue: the Kommo API returns a maximum of 250 deals per request using cursor-based pagination. Tableau cannot handle paginated API responses - that is the ETL layer’s job.

PostgreSQL as an intermediate layer provides three advantages: a full history of deal changes (Kommo only stores the current state), the ability to JOIN with other sources (finance, marketing), and fast query performance for Tableau without hitting API rate limits.

Architecture

Kommo REST API -> ETL Python (cron 1h) -> PostgreSQL -> Tableau (live connection)
                                                     -> Tableau REST API (refresh trigger)

The ETL runs on a schedule, performs incremental sync of new and updated deals, and upserts into PostgreSQL. Tableau connects directly to PostgreSQL - data is never more than 1 hour old.

ETL Implementation

Step 1 - create the PostgreSQL schema:

CREATE TABLE IF NOT EXISTS kommo_leads (
    lead_id          BIGINT PRIMARY KEY,
    name             TEXT,
    price            NUMERIC(12, 2),
    status_id        INTEGER,
    status_name      TEXT,
    pipeline_id      INTEGER,
    pipeline_name    TEXT,
    responsible_id   INTEGER,
    responsible_name TEXT,
    created_at       TIMESTAMP,
    updated_at       TIMESTAMP,
    closed_at        TIMESTAMP,
    -- Custom fields (examples)
    cf_industry      TEXT,
    cf_company_size  TEXT,
    cf_source        TEXT,
    -- Meta
    synced_at        TIMESTAMP DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_leads_updated ON kommo_leads(updated_at);
CREATE INDEX IF NOT EXISTS idx_leads_status  ON kommo_leads(status_id);
CREATE INDEX IF NOT EXISTS idx_leads_created ON kommo_leads(created_at);

Step 2 - incremental sync from Kommo:

import requests, psycopg2, os
from datetime import datetime, timezone

KOMMO_DOMAIN = "yourdomain.kommo.com"
KOMMO_TOKEN  = "your_kommo_token"
KOMMO_BASE   = f"https://{KOMMO_DOMAIN}/api/v4"

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

# Map status IDs to human-readable names
STATUS_NAMES = {
    142: "Successfully closed",
    143: "Closed and lost",
}

def fetch_leads_since(since_ts: int) -> list:
    """Fetch all leads updated since timestamp via paginated API."""
    hs = requests.Session()
    hs.headers["Authorization"] = f"Bearer {KOMMO_TOKEN}"

    leads, page = [], 1
    while True:
        r = hs.get(f"{KOMMO_BASE}/leads", params={
            "updated_at[from]": since_ts,
            "with":             "contacts,loss_reason",
            "limit":            250,
            "page":             page,
        })
        if r.status_code == 204:
            break
        batch = r.json().get("_embedded", {}).get("leads", [])
        if not batch:
            break
        leads.extend(batch)
        if len(batch) < 250:
            break
        page += 1
    return leads

def get_custom_field(lead: dict, field_code: str) -> str:
    for f in lead.get("custom_fields_values") or []:
        if f.get("field_code") == field_code:
            vals = f.get("values", [])
            return str(vals[0]["value"]) if vals else ""
    return ""

def upsert_leads(leads: list, conn):
    sql = """
        INSERT INTO kommo_leads (
            lead_id, name, price, status_id, status_name,
            pipeline_id, responsible_id,
            created_at, updated_at, closed_at,
            cf_industry, cf_company_size, cf_source, synced_at
        ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
        ON CONFLICT (lead_id) DO UPDATE SET
            name             = EXCLUDED.name,
            price            = EXCLUDED.price,
            status_id        = EXCLUDED.status_id,
            status_name      = EXCLUDED.status_name,
            updated_at       = EXCLUDED.updated_at,
            closed_at        = EXCLUDED.closed_at,
            cf_industry      = EXCLUDED.cf_industry,
            cf_company_size  = EXCLUDED.cf_company_size,
            synced_at        = NOW()
    """
    with conn.cursor() as cur:
        for lead in leads:
            closed = lead.get("closed_at")
            cur.execute(sql, (
                lead["id"],
                lead.get("name", ""),
                lead.get("price", 0),
                lead.get("status_id"),
                STATUS_NAMES.get(lead.get("status_id"), f"stage_{lead.get('status_id')}"),
                lead.get("pipeline_id"),
                lead.get("responsible_user_id"),
                datetime.fromtimestamp(lead["created_at"], tz=timezone.utc),
                datetime.fromtimestamp(lead["updated_at"], tz=timezone.utc),
                datetime.fromtimestamp(closed, tz=timezone.utc) if closed else None,
                get_custom_field(lead, "INDUSTRY"),
                get_custom_field(lead, "COMPANY_SIZE"),
                get_custom_field(lead, "LEAD_SOURCE"),
            ))
    conn.commit()

def run_sync():
    conn = psycopg2.connect(DSN)

    # Get the timestamp of the last sync
    with conn.cursor() as cur:
        cur.execute("SELECT COALESCE(EXTRACT(EPOCH FROM MAX(updated_at))::BIGINT, 0) FROM kommo_leads")
        since_ts = cur.fetchone()[0]

    leads = fetch_leads_since(since_ts)
    if leads:
        upsert_leads(leads, conn)
        print(f"Synced {len(leads)} leads")

    conn.close()

if __name__ == "__main__":
    run_sync()

Step 3 - automatic Tableau dashboard refresh via REST API:

The Tableau REST API lets you programmatically trigger an Extract Refresh for datasources:

import tableauserverclient as tsc

TABLEAU_SERVER   = "https://your-tableau-server.com"
TABLEAU_SITE     = ""           # empty string for Default site
TABLEAU_TOKEN_NAME  = "your_token_name"
TABLEAU_TOKEN_VALUE = "your_personal_access_token"

def trigger_tableau_refresh(datasource_name: str = "Kommo Leads"):
    """Trigger Tableau extract refresh after ETL sync."""
    tableau_auth = tsc.PersonalAccessTokenAuth(
        TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE, TABLEAU_SITE
    )
    server = tsc.Server(TABLEAU_SERVER, use_server_version=True)

    with server.auth.sign_in(tableau_auth):
        # Find the datasource by name
        req_opts = tsc.RequestOptions()
        req_opts.filter.add(tsc.Filter(
            tsc.RequestOptions.Field.Name,
            tsc.RequestOptions.Operator.Equals,
            datasource_name
        ))
        all_datasources, _ = server.datasources.get(req_opts)

        if all_datasources:
            ds = all_datasources[0]
            server.datasources.refresh(ds)
            print(f"Refresh triggered for: {ds.name}")

Run run_sync() -> trigger_tableau_refresh() via cron every hour. Tableau automatically updates views once the refresh completes.

Key Metrics for Your Tableau Sales Dashboard

With data in PostgreSQL, a typical sales dashboard includes:

  • Sales funnel - deal count and volume by stage (bar chart)
  • Conversion Rate - won/total ratio by manager and source
  • Deal Velocity - average time from deal creation to close
  • Revenue Forecast - open deals weighted by pipeline stage probability
  • Leaderboard - top managers by revenue for a given period
  • Source Attribution - where won deals are coming from

All of these metrics are built with SQL queries against PostgreSQL through Tableau calculated fields.

Real-World Case

A company with a team of 12 sales managers and 150-200 active deals. Weekly CSV exports from Kommo into Excel were taking 2-3 hours. Data-driven decisions were being made on week-old data.

After rolling out PostgreSQL + Tableau:

  • Data updates every hour with no analyst involvement
  • Revenue forecasts are built on current pipeline data
  • Time spent preparing reports dropped from 2-3 hours to zero
  • The Tableau dashboard runs on a TV screen in the sales office in real time

ETL development time: 2 days. Tableau dashboard: 1-2 days.

Who This Is For

Companies with an established Tableau BI practice and an active pipeline in Kommo. If your Tableau Server is already being used for financial analytics, adding Kommo as a data source is a natural next step. For smaller teams without Tableau licenses, it may be simpler to consider Kommo + Looker Studio (Google’s free tool).

If you use custom integrations in Kommo CRM across multiple systems, PostgreSQL as a central data warehouse lets you consolidate data from all sources in one place.

Frequently Asked Questions

Can I use MySQL instead of PostgreSQL?

Yes. Tableau supports a native connector for MySQL. The ON CONFLICT syntax in our ETL needs to be replaced with INSERT ... ON DUPLICATE KEY UPDATE for MySQL. Everything else works identically.

Does Tableau Cloud (Tableau Online) support PostgreSQL?

Yes, if your PostgreSQL is publicly accessible. For a local PostgreSQL instance, you need Tableau Bridge - an agent installed on your network that connects Tableau Cloud to your local database. Tableau Bridge is included in a standard Tableau Cloud license.

How often can I run ETL without hitting the Kommo rate limit?

Kommo API allows 7 requests per second and 1,000 requests per 5 minutes per account. With 250 deals per request and 10,000 active deals, an incremental sync takes 40-50 requests. This is well within limits even when running every 15 minutes.

How do I store change history to analyze funnel stage transitions?

Add a kommo_lead_history table: on each sync, write a row with (lead_id, status_id, updated_at) whenever the status has changed. This enables Funnel Analysis in Tableau - how many deals passed through each stage over a given period.

Summary

Kommo + Tableau integration via PostgreSQL is a solid enterprise BI architecture. The setup:

  • Cron (every hour): Python ETL -> Kommo API -> upsert into PostgreSQL
  • Tableau connected to PostgreSQL as a live data source
  • After ETL: Tableau REST API datasources.refresh() updates the dashboard
  • Incremental sync via updated_at keeps load minimal

If you are rolling out Tableau for sales analytics and want to see Kommo data in real time - describe your requirements to the Exceltic.dev team. We will design the data schema around your metrics.

More articles

All →