> For clean Markdown of any page, append `.md` to the page URL.
> For a complete documentation index, see https://docs.sarvam.ai/llms.txt.
> For full documentation content in one file, see https://docs.sarvam.ai/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.sarvam.ai/_mcp/server.

# Best practices

## Which endpoint should I call?

The four ways to get numbers out of boards are not interchangeable. Picking the wrong one is the most common cause of avoidable `429`s.

| You want to…                             | Call                                                                       | Why                                                                                         |
| ---------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Iterate on a query you are still writing | [Preview query](/conversations/api/boards/query/preview)                   | Nothing is saved. Capped at **20/min** — a development tool, not a production data source   |
| Read one metric repeatedly               | [Run widget](/conversations/api/boards/widgets/run)                        | Results are cached, and the query lives in one place your team can see and edit             |
| Render a page of metrics at once         | [Run tab](/conversations/api/boards/tabs/run)                              | One call instead of N, widgets run concurrently, and **50/min** is the most generous budget |
| Receive numbers without polling          | [Create notification rule](/conversations/api/boards/notifications/create) | Push instead of pull; costs no request budget at all                                        |

### Prefer a tab run over N widget runs

Rendering six widgets one at a time costs six requests against a 20/min budget. [Run tab](/conversations/api/boards/tabs/run) costs one against a 50/min budget, and runs the widgets concurrently, so it is faster as well as cheaper.

A tab run also degrades gracefully. Each result is tagged `ok` or `error`, and a widget that fails does not fail the request — you still get every other widget.

Always branch on `status` before reading `rows`. Treating the array as uniformly successful will break the first time one query times out.

`filter_values_used` echoes back what was actually applied after merging your `filter_values` over each filter's default — useful for labelling a rendered page, and for confirming a default was what you assumed.

## Caching

Widget runs and tab runs are cached for **60 seconds**, keyed by widget, filter values, and row limit. Editing a widget invalidates its cache immediately.

* Polling the same widget with the same filters more often than once a minute returns identical rows and still consumes request budget. Match your poll interval to the cache, not to your refresh animation.
* Changing any filter value is a different cache key, so a dashboard where users move filters around will miss the cache often. Budget for that.
* Preview is **not** cached — every call executes, so it is the wrong endpoint to poll on a timer.

## Staying inside the query budget

Every run gets 15 seconds and a row cap. Exceeding the time budget returns `408` with code `TIMEOUT`; exceeding the row cap returns `200` with `truncated: true`.

The dialect-level advice for keeping a query fast — filter on `effective_datetime` early, aggregate in the database rather than returning raw rows, name your columns instead of `SELECT *` — is the same whether you write the query here or in the dashboard editor, and lives on [SQL best practices](/conversations/monitor/clickhouse-sql-best-practices).

Two things are specific to calling this over the API:

There is no cursor for stepping through a large result — `row_limit` is a cap, not a page size.

**`truncated: true` means your answer is wrong, not just short.** The cap cut the result off, so any total, average, or max you compute from those rows is derived from a partial set. Raising `row_limit` is rarely the right fix — it just moves the cliff. Aggregate further or narrow the range so the full answer fits.

```python
if result["truncated"]:
    raise RuntimeError("partial result — aggregate in SQL instead of raising row_limit")
```

**`row_limit` and a `LIMIT` in your SQL are not the same control.** `row_limit` is the ceiling; a `LIMIT` inside the query can narrow the result below it but never widen it past it, and on preview and widget runs an in-SQL `LIMIT` is itself clamped to 1000. If you want the top 20, write `ORDER BY ... LIMIT 20` — don't set `row_limit: 20` and hope the ordering holds.

## Handling rate limits

Both limits return `429` with `Retry-After` in seconds; sleep for that long and back off exponentially on repeated `429`s, with jitter if several workers share a key.

The per-organization limits are shared with people using the dashboard, so an integration that consumes the entire preview budget will make boards feel broken for your colleagues.

## Notification rules

For anything on a fixed schedule, a notification rule beats polling: it costs no request budget and survives your service being down.

* The cron is a five-field expression evaluated in **Asia/Kolkata**; the minimum interval is one hour. `next_run_at` in the response is UTC.
* At least one of `email_recipients` or `slack_webhook_url` is required. Both may be set.
* `slack_webhook_url` is write-only — no endpoint ever returns it. Use `slack_configured` to tell whether one is set, and `slack_channel_name` to label it.
* Pause a rule with `{"enabled": false}` rather than recreating it later; disabling clears `next_run_at`.

## End-to-end

Discover the schema, develop a query, save it, and run it.

**`curl`**

```bash title="curl"
BASE="https://apps.sarvam.ai/api/analytics/v1/$ORG_ID/$WORKSPACE_ID"

# 1. What can I query?
curl -s "$BASE/query-schema" -H "X-API-Key: $SARVAM_API_KEY"

# 2. Try the SQL without saving anything.
curl -s -X POST "$BASE/query-preview" \
  -H "X-API-Key: $SARVAM_API_KEY" -H "Content-Type: application/json" \
  -d '{
        "sql": "SELECT language_name, count() AS calls FROM EngagementFactsBoardsView WHERE effective_datetime >= {{start_date}} GROUP BY language_name ORDER BY calls DESC",
        "filter_values": { "start_date": { "filter_type": "date", "value": "2026-09-01" } },
        "row_limit": 100
      }'

# 3. Create a board (returns board_id).
curl -s -X POST "$BASE/boards" \
  -H "X-API-Key: $SARVAM_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Campaign Overview", "tag": "Weekly reporting" }'

# 4. Save the query as a widget (returns widget_id).
curl -s -X POST "$BASE/boards/$BOARD_ID/widgets" \
  -H "X-API-Key: $SARVAM_API_KEY" -H "Content-Type: application/json" \
  -d '{
        "name": "Calls by language",
        "sql": "SELECT language_name, count() AS calls FROM EngagementFactsBoardsView WHERE effective_datetime >= {{start_date}} GROUP BY language_name ORDER BY calls DESC",
        "viz_type": "bar",
        "viz_config": { "x_axis": "language_name", "y_axis": ["calls"] },
        "filters": [
          { "internal_name": "start_date", "display_label": "Start date", "filter_type": "date", "default_value": "2026-09-01" }
        ]
      }'

# 5. Run it.
curl -s -X POST "$BASE/boards/$BOARD_ID/widgets/$WIDGET_ID/run" \
  -H "X-API-Key: $SARVAM_API_KEY" -H "Content-Type: application/json" \
  -d '{ "filter_values": { "start_date": { "filter_type": "date", "value": "2026-09-01" } } }'
```

**`Python`**

```python title="Python"
import os
import httpx

BASE = f"https://apps.sarvam.ai/api/analytics/v1/{os.environ['ORG_ID']}/{os.environ['WORKSPACE_ID']}"
client = httpx.Client(headers={"X-API-Key": os.environ["SARVAM_API_KEY"]}, timeout=30)

SQL = """
SELECT language_name, count() AS calls
FROM EngagementFactsBoardsView
WHERE effective_datetime >= {{start_date}}
GROUP BY language_name
ORDER BY calls DESC
"""

# 1. What can I query?
schema = client.get(f"{BASE}/query-schema").json()
print([t["name"] for t in schema["tables"]])

# 2. Try the SQL without saving anything.
preview = client.post(
    f"{BASE}/query-preview",
    json={
        "sql": SQL,
        "filter_values": {"start_date": {"filter_type": "date", "value": "2026-09-01"}},
        "row_limit": 100,
    },
).json()
print(preview["columns"], preview["rows"])

# 3. Create a board.
board = client.post(f"{BASE}/boards", json={"name": "Campaign Overview", "tag": "Weekly reporting"}).json()

# 4. Save the query as a widget.
widget = client.post(
    f"{BASE}/boards/{board['id']}/widgets",
    json={
        "name": "Calls by language",
        "sql": SQL,
        "viz_type": "bar",
        "viz_config": {"x_axis": "language_name", "y_axis": ["calls"]},
        "filters": [
            {
                "internal_name": "start_date",
                "display_label": "Start date",
                "filter_type": "date",
                "default_value": "2026-09-01",
            }
        ],
    },
).json()

# 5. Run it.
result = client.post(
    f"{BASE}/boards/{board['id']}/widgets/{widget['id']}/run",
    json={"filter_values": {"start_date": {"filter_type": "date", "value": "2026-09-01"}}},
).json()

if result["truncated"]:
    print("warning: result was cut short by row_limit")
for row in result["rows"]:
    print(dict(zip([c["name"] for c in result["columns"]], row)))
```

## Next

#### [Writing board SQL](/conversations/api/boards/sql)

Allowed tables, filter tokens, and every error code.

#### [Boards API overview](/conversations/api/boards/overview)

Base URL, rate limits, and the error envelope.