Dashboard from Your Python API (FastAPI, Django)

A Python FastAPI endpoint returning dashboard JSON next to the KPI widget it renders.

You already have the data. It's behind a FastAPI route, a Django view, or a Flask handler that some other part of your system calls. Write one more endpoint that returns a specific JSON shape, paste the URL in, and it renders as a widget.

No frontend. No chart library. No second service to keep running.

The reality

The Python way to put numbers on a screen is usually Streamlit, Dash or Panel — and for what they're for, they're genuinely good. A notebook-shaped analytical app with filters and drilldowns, built by the person who understands the data, is a real strength of this ecosystem.

The trouble starts when that app has to run continuously for other people. Now it's a process you deploy, a session model you reason about, an auth story you didn't plan for, and a re-render on every interaction. What began as a two-hundred-line script becomes a service somebody owns.

The alternative is smaller than it sounds: keep the part you're good at — the query, the aggregation, the business rule that decides what counts as active — and stop owning the part that draws rectangles.

Your data stays in your Python services

This matters more than the frontend argument, and it's the reason most teams here end up switching.

dashboardbase never asks for your database credentials and never stores your data. It calls your endpoint, over HTTPS, and renders what comes back. Your DATABASE_URL, your Django ORM, your read replica, your service account — all of it stays inside your own infrastructure, exactly where your security review already approved it.

Nothing on our side can query your database, because nothing on our side knows how.

That also means your existing access rules still apply. If a queryset is already filtered by tenant, the dashboard endpoint that uses it is filtered by tenant too. You aren't re-implementing authorization in a reporting tool's permission model.

Define Python endpoints

Each widget is one endpoint returning one JSON object. Here's the contract for a KPI tile:

{
  "title": "MRR",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/mrr-details"
    }
  ],
  "data": {
    "header": {
      "title": "$12,345",
      "subtitle": "vs last month",
      "badge": {
        "text": "+$1,120",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}
{
  "title": "MRR",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/mrr-details"
    }
  ],
  "data": {
    "header": {
      "title": "$12,345",
      "subtitle": "vs last month",
      "badge": {
        "text": "+$1,120",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}
{
  "title": "MRR",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/mrr-details"
    }
  ],
  "data": {
    "header": {
      "title": "$12,345",
      "subtitle": "vs last month",
      "badge": {
        "text": "+$1,120",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}

title and data are required; actions and alert are optional. In FastAPI that's a dict and a dependency:

import os
import secrets

from fastapi import Depends, FastAPI, Header, HTTPException

app = FastAPI()

# Fail at import rather than serving revenue data unprotected.
ENDPOINT_SECRET = os.environ["DASHBOARDBASE_ENDPOINT_SECRET"]


def verify(x_dashboardbase_secret: str = Header(default="")) -> None:
    if not secrets.compare_digest(x_dashboardbase_secret, ENDPOINT_SECRET):
        raise HTTPException(status_code=401, detail="unauthorized")


@app.get("/dashboard/mrr", dependencies=[Depends(verify)])
def mrr() -> dict:
    cents = current_mrr_cents()
    delta = cents - last_month_mrr_cents()

    return {
        "title": "MRR",
        "actions": [
            {
                "title": "View Details",
                "type": "link",
                "url": "https://example.com/mrr-details",
            }
        ],
        "data": {
            "header": {
                "title": f"${cents / 100:,.0f}",
                "subtitle": "vs last month",
                "badge": {
                    "text": f"{'+' if delta >= 0 else '-'}${abs(delta) / 100:,.0f}",
                    "icon": "ArrowUp" if delta >= 0 else "ArrowDown",
                    "color": "Success" if delta >= 0 else "Danger",
                },
            }
        },
    }
import os
import secrets

from fastapi import Depends, FastAPI, Header, HTTPException

app = FastAPI()

# Fail at import rather than serving revenue data unprotected.
ENDPOINT_SECRET = os.environ["DASHBOARDBASE_ENDPOINT_SECRET"]


def verify(x_dashboardbase_secret: str = Header(default="")) -> None:
    if not secrets.compare_digest(x_dashboardbase_secret, ENDPOINT_SECRET):
        raise HTTPException(status_code=401, detail="unauthorized")


@app.get("/dashboard/mrr", dependencies=[Depends(verify)])
def mrr() -> dict:
    cents = current_mrr_cents()
    delta = cents - last_month_mrr_cents()

    return {
        "title": "MRR",
        "actions": [
            {
                "title": "View Details",
                "type": "link",
                "url": "https://example.com/mrr-details",
            }
        ],
        "data": {
            "header": {
                "title": f"${cents / 100:,.0f}",
                "subtitle": "vs last month",
                "badge": {
                    "text": f"{'+' if delta >= 0 else '-'}${abs(delta) / 100:,.0f}",
                    "icon": "ArrowUp" if delta >= 0 else "ArrowDown",
                    "color": "Success" if delta >= 0 else "Danger",
                },
            }
        },
    }
import os
import secrets

from fastapi import Depends, FastAPI, Header, HTTPException

app = FastAPI()

# Fail at import rather than serving revenue data unprotected.
ENDPOINT_SECRET = os.environ["DASHBOARDBASE_ENDPOINT_SECRET"]


def verify(x_dashboardbase_secret: str = Header(default="")) -> None:
    if not secrets.compare_digest(x_dashboardbase_secret, ENDPOINT_SECRET):
        raise HTTPException(status_code=401, detail="unauthorized")


@app.get("/dashboard/mrr", dependencies=[Depends(verify)])
def mrr() -> dict:
    cents = current_mrr_cents()
    delta = cents - last_month_mrr_cents()

    return {
        "title": "MRR",
        "actions": [
            {
                "title": "View Details",
                "type": "link",
                "url": "https://example.com/mrr-details",
            }
        ],
        "data": {
            "header": {
                "title": f"${cents / 100:,.0f}",
                "subtitle": "vs last month",
                "badge": {
                    "text": f"{'+' if delta >= 0 else '-'}${abs(delta) / 100:,.0f}",
                    "icon": "ArrowUp" if delta >= 0 else "ArrowDown",
                    "color": "Success" if delta >= 0 else "Danger",
                },
            }
        },
    }

That x_dashboardbase_secret parameter is the workspace endpoint secret. Every workspace has one, and dashboardbase sends it as an x-dashboardbase-secret header on every request to your endpoints, automatically — there is nothing to configure on your side beyond checking it. It authenticates us to you; it is not an API key for calling dashboardbase. Rotating it keeps the old value working for 24 hours.

Django is the same shape with a different decorator:

from django.http import JsonResponse
from django.views.decorators.http import require_GET


@require_GET
@dashboardbase_secret_required
def active_users(request):
    return JsonResponse(
        {
            "title": "Daily active users",
            "data": {
                "header": {
                    "title": f"{count_active_today():,}",
                    "subtitle": "vs last month",
                    "badge": {"text": "+12%", "icon": "ArrowUp", "color": "Success"},
                }
            },
        }
    )
from django.http import JsonResponse
from django.views.decorators.http import require_GET


@require_GET
@dashboardbase_secret_required
def active_users(request):
    return JsonResponse(
        {
            "title": "Daily active users",
            "data": {
                "header": {
                    "title": f"{count_active_today():,}",
                    "subtitle": "vs last month",
                    "badge": {"text": "+12%", "icon": "ArrowUp", "color": "Success"},
                }
            },
        }
    )
from django.http import JsonResponse
from django.views.decorators.http import require_GET


@require_GET
@dashboardbase_secret_required
def active_users(request):
    return JsonResponse(
        {
            "title": "Daily active users",
            "data": {
                "header": {
                    "title": f"{count_active_today():,}",
                    "subtitle": "vs last month",
                    "badge": {"text": "+12%", "icon": "ArrowUp", "color": "Success"},
                }
            },
        }
    )

Charts follow the same envelope with labels and datasets inside data, where each data point is an object with a value. A Pydantic response model is a good way to lock the shape once and stop guessing at it in every route.

Generate Python endpoints faster

You don't have to write these by hand.

  • The dashboardbase Skill is free and open source. It drops into Claude Code, Cursor or any skills-capable agent and teaches it the whole contract, so the agent scaffolds a correctly-shaped FastAPI or Django endpoint instead of inventing JSON that nearly fits.

  • In-app prompt generation covers everyone else. The widget editor produces a copy-pasteable prompt — per widget, or one prompt for a whole dashboard — for whatever AI tool you already use. Copy, paste, run, paste the endpoint URL back.

Both routes hand you a reviewable draft you run yourself. Nothing executes on our side and nothing is sent anywhere.

Then check it: the endpoint validator takes a response and tells you whether the widget can render it. No account needed, so you can confirm the shape before you sign up for anything.

Where this isn't the right fit

  • Ad-hoc exploration. If the job is slicing a dataset a hundred ways to find something, you want a notebook or a BI tool. This renders numbers you've already decided to watch.

  • Interactive apps. Filters, date pickers, drilldowns, anything where the viewer changes the query — that's Streamlit's or Dash's territory, and it should stay there.

  • You can't add an endpoint. If deploying a new route means a two-week change process, the maths stops working. This is built for people who can ship a fifteen-line handler this afternoon.

  • Infrastructure metrics. Prometheus and Grafana already do CPU, memory and scrape intervals properly. Use them for that; use this for the business numbers they were never meant to hold.

Point it at one endpoint and see

Write one route that returns the JSON above, paste the URL into a KPI widget, and decide from something real. If it works, the next widget is another fifteen lines — and you can read the whole board from your phone in the native iOS or Android app.

Worth reading next: why building dashboards yourself is more work than you think if you're weighing the DIY route, and if your API returns structured JSON, you're one step away from a dashboard for the mental model behind all of this.