Skip to navigation

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. Each value is a string, or null for a filter that had neither a value nor a default. Values you send for tokens the board has no filter for are ignored, so a typo in a token name fails silently rather than with an error.

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; reaching 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 may be wrong, not just short. It is set whenever the number of rows returned equals the effective limit, so rows beyond the cap may have been dropped, and any total, average, or max you compute from those rows may be 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.

The flag cannot tell a cut-off result from one that hit your own LIMIT exactly: a top-20 query that returns 20 rows also reports truncated: true. Treat it as a warning only for queries that are not meant to stop at a fixed count.

if result["truncated"] and not query_has_own_limit:
raise RuntimeError("possibly 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 an in-SQL LIMIT is itself clamped to 1000 — so a row_limit above 1000 only helps when the SQL has no LIMIT. 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

Every limit — the per-key limit and the three per-organization run limits — returns 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 IST (Asia/Kolkata), and it may not fire more than once an hour — * * * * * is rejected. next_run_at in the response is UTC.
  • At least one of email_recipients or slack_webhook_url is required. Both may be set. A webhook URL must start with https://hooks.slack.com/.
  • 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-01T00:00:00+05:30" } },
"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-01T00:00:00+05:30" }
]
}'
# 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-01T00:00:00+05:30" } } }'

Next