> 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. > Choosing the right endpoint, keeping queries fast, and building an integration that stays inside its budget.