Skip to content

API & CLI

FastAPI and CLI facades over the metrics engine. Last updated: April 2026

Design

Both the HTTP API and CLI are thin facades. Every command/endpoint delegates to MetricsEngine. No business logic lives in the API or CLI layer.

CLI (P0)

The CLI provides programmatic access to all metrics. It uses the same MetricsEngine as the HTTP API.

Installation

pip install tidemill
# or
uv pip install tidemill

Commands

# Current MRR
tidemill mrr
# $12,450.00

# MRR as of a specific date
tidemill mrr --at 2026-01-01

# MRR time series
tidemill mrr --start 2025-01-01 --end 2026-01-01 --interval month

# ARR
tidemill arr

# Net new MRR breakdown
tidemill mrr breakdown --start 2026-01-01 --end 2026-03-01

# Churn rate (default: logo churn)
tidemill churn --start 2026-01-01 --end 2026-03-01

# Revenue churn
tidemill churn --type revenue --start 2026-01-01 --end 2026-03-01

# Retention cohorts
tidemill retention --start 2025-01-01 --end 2026-01-01

# All metrics summary
tidemill summary

# Output as JSON (for piping to other tools)
tidemill mrr --format json

# Output as CSV
tidemill mrr --start 2025-01-01 --end 2026-01-01 --interval month --format csv

Configuration

The CLI reads configuration from environment variables or a config file:

# Environment variables
export TIDEMILL_DATABASE_URL=postgresql://localhost/lago
export TIDEMILL_CONNECTOR=lago

# Or config file (~/.tidemill.toml or .tidemill.toml)
[database]
url = "postgresql://localhost/lago"

[connector]
type = "lago"

Programmatic Usage (Python)

import asyncio
from tidemill import MetricsEngine, QuerySpec

# Ingestion mode (Stripe) — engine queries materialized metric_* tables
engine = MetricsEngine(db=async_session)

# Same-database mode (Lago / Kill Bill) is configured per-metric by passing a
# DatabaseConnector into that metric's constructor or init(...) hook. The engine
# itself only owns the shared AsyncSession.

# Dynamic query dispatch — all metric queries are async
mrr = await engine.query("mrr", {"query_type": "current"})
churn = await engine.query("churn", {"start": date(2026, 1, 1), "end": date(2026, 2, 28), "type": "logo"})
cohorts = await engine.query("retention", {"query_type": "cohort_matrix",
                                            "start": date(2025, 1, 1), "end": date(2025, 12, 31)})

# With dimensions and filters via QuerySpec
spec = QuerySpec(
    dimensions=["customer_country"],
    filters={"currency": "USD"},
)
mrr_by_country = await engine.query("mrr", {"query_type": "current"}, spec=spec)

# Synchronous — no I/O
print(engine.available_metrics())
# ['churn', 'ltv', 'mrr', 'retention', 'trials']

HTTP API

FastAPI is a thin HTTP layer. Every endpoint delegates to engine.query(). No business logic lives in the API layer.

from fastapi import FastAPI, Depends, Query
from tidemill import MetricsEngine, QuerySpec
from tidemill.database import get_db

app = FastAPI(title="Tidemill API")

def get_engine() -> MetricsEngine:
    return MetricsEngine(get_db())

def parse_spec(
    dimensions: list[str] = Query(default=[]),
    filter: list[str] = Query(default=[]),    # "country=US", "plan_interval=monthly"
    granularity: str | None = None,
    segment: str | None = Query(default=None),         # universe filter (segment id)
    compare_segments: list[str] = Query(default=[]),    # per-branch slicing (segment ids)
) -> QuerySpec | None:
    filters = {}
    for f in filter:
        key, _, value = f.partition("=")
        filters[key] = value
    if not (dimensions or filters or granularity or segment or compare_segments):
        return None
    spec = QuerySpec(dimensions=dimensions, filters=filters, granularity=granularity)
    # Segment IDs are resolved to SegmentDef objects later, in `query_metric`,
    # so we don't need a DB session here.
    if segment:
        spec.filters["__segment_id__"] = segment
    if compare_segments:
        spec.filters["__compare_segment_ids__"] = list(compare_segments)
    return spec

@app.get("/api/metrics/mrr")
async def get_mrr(
    at: date | None = None,
    start: date | None = None,
    end: date | None = None,
    interval: str = "month",
    spec: QuerySpec | None = Depends(parse_spec),
    engine: MetricsEngine = Depends(get_engine),
):
    if start and end:
        return await engine.query("mrr", {"query_type": "series", "start": start,
                                          "end": end, "interval": interval}, spec=spec)
    return await engine.query("mrr", {"query_type": "current", "at": at}, spec=spec)

Endpoints

Metrics

Method Path Returns
GET /api/metrics List of registered metric names
GET /api/metrics/summary MRR, ARR, churn, retention, LTV, ARPU, trial conversion in one call
GET /api/metrics/mrr MRR (point or series)
GET /api/metrics/arr ARR (point or series — same start/end/interval contract as MRR)
GET /api/metrics/mrr/breakdown Net new MRR breakdown by movement type
GET /api/metrics/mrr/waterfall Monthly MRR waterfall (movements per month)
GET /api/metrics/churn Churn rate (type=logo or type=revenue)
GET /api/metrics/churn/customers Per-customer churn detail (C_start / C_churned)
GET /api/metrics/churn/revenue-events Per-customer revenue-churn events
GET /api/metrics/retention Retention — query_type=nrr / grr / cohort_matrix
GET /api/metrics/ltv Simple LTV
GET /api/metrics/ltv/arpu ARPU (point)
GET /api/metrics/ltv/cohort Per-cohort LTV breakdown
GET /api/metrics/trials Trial conversion rate
GET /api/metrics/trials/funnel Trial funnel (started / converted / expired)
GET /api/metrics/trials/series Monthly trial time-series
POST /api/metrics/{metric} Generic query-by-body for any registered metric — body is {"params": ..., "spec": {...}}

Common query parameters:

Parameter Type Description
at date Point-in-time query (default: today)
start date Series start date
end date Series end date
interval string day, week, month, year

Segment parameters (apply to most metric endpoints):

All metric endpoints share a common QuerySpec contract built from three query-string parameters (see tidemill.metrics.route_helpers.parse_spec):

Parameter Type Description
dimensions string[] GROUP BY dimensions — names declared in the metric's Cube (e.g. customer_country, currency, churn_type, cancel_reason)
filter string[] Repeated key=value filters, e.g. filter=customer_country=US&filter=currency=USD
granularity string Time bucketing for series queries — day, week, month, quarter, or year

Invalid dimension/filter names raise a 400 with the list of available options for that metric's cube. Dimensions that reach through the plan / product joins (plan_interval, plan_name, product_name, pricing_model, collection_method) are declared on the MRR cubes but will return no rows until the Stripe connector ingests plan.* / product.* events (see docs/architecture/connectors.md).

When start and end are provided, the endpoint returns a time series. Otherwise it returns a single value.

Monetary values: All monetary amounts are returned as integer cents (e.g., $12.501250). Divide by 100 in the client to display as dollars. This matches the internal storage convention (*_cents / *_base_cents columns) and avoids floating-point precision issues.

Data

Method Path Returns
GET /api/customers Paginated customer list
GET /api/customers/{id} Customer detail with subscriptions
GET /api/subscriptions Paginated subscription list
GET /api/invoices Paginated invoice list

Segments

Every metric endpoint (/api/metrics/{mrr,churn,retention,ltv,trials} and their sub-paths) also accepts segment=<id> (universe filter) and compare_segments=<id1>&compare_segments=<id2>… (per-branch slicing, up to 10). They compose — segment narrows the universe, compare_segments then splits the narrowed rows by tagging each with every branch it matches. See segments.md for the compilation model.

Method Path Description
GET /api/segments List all segments (workspace-shared)
POST /api/segments Create a segment (JSON SegmentDef body)
GET /api/segments/{id} Fetch one
PUT /api/segments/{id} Update name / description / definition
DELETE /api/segments/{id} Delete
POST /api/segments/validate Lint a definition without persisting
GET /api/metrics/{name}/fields Discovery: dimensions + attributes + time_dimensions for the FE picker

Attributes

Customer attributes are keyed by attribute_definition.key and stored one-row-per-value in customer_attribute. Source may be stripe (fanned out from customer.metadata), csv, api, or computed.

Method Path Description
GET /api/attributes List attribute definitions
POST /api/attributes Create a definition (explicit type pinning)
PUT /api/attributes/{key} Update label / description
GET /api/attributes/{key}/values Distinct observed values (autocomplete)
POST /api/attributes/import Multipart CSV upload (first column is customer id)
POST /api/customers/{id}/attributes Upsert values on one customer
DELETE /api/customers/{id}/attributes/{key} Remove a value

Connectors

Method Path Description
GET /api/sources List connected billing sources
POST /api/sources Add a billing source
POST /api/sources/{id}/backfill Trigger historical backfill
POST /api/webhooks/{source_id} Webhook receiver (translates and publishes to Kafka)

Health

Method Path Description
GET /healthz Liveness check
GET /readyz Readiness check (DB connected)

Authentication

Not included in v1. The API is designed to run behind a reverse proxy or VPN. Authentication can be added later via middleware.

Interactive Documentation

FastAPI auto-generates OpenAPI docs at /docs (Swagger UI) and /redoc.

A runnable Bruno collection lives at bruno/ covering every endpoint listed above — health, auth, sources, webhooks, metrics (with working examples for dimensions=, filter=, segment=, and compare_segments=), segments, attributes, dashboards, and saved charts. Open the folder in the Bruno desktop app or run a request from the CLI:

bru run --env Local "Metrics - MRR/02_series.bru"

See bruno/README.md for the env vars and auth modes.