Best practices

Choosing the right endpoint, keeping queries fast, and building an integration that stays inside its budget.
View as Markdown

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 429s.

You want to…CallWhy
Iterate on a query you are still writingPreview queryNothing is saved. Capped at 20/min — a development tool, not a production data source
Read one metric repeatedlyRun widgetResults are cached, and the query lives in one place your team can see and edit
Render a page of metrics at onceRun tabOne call instead of N, widgets run concurrently, and 50/min is the most generous budget
Receive numbers without pollingCreate notification rulePush 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 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.

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.

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 429s, 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.

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" } } }'

Next