Dashboard
The Dashboard API lets you list, create, edit, and delete dashboards programmatically with the same API keys you already use for ingest and query operations.
Supported operations:
- List:
GET https://api.telemetry.sh/dashboard - Create:
POST https://api.telemetry.sh/dashboard - Edit:
PATCH https://api.telemetry.sh/dashboard - Delete:
DELETE https://api.telemetry.sh/dashboard
Headers
| Name | Type | Description |
|---|---|---|
Content-Type |
String | Must be application/json. |
Authorization |
String | Your API key, either as the raw key or as Bearer <key>. |
Scope rules:
GET /dashboardacceptsread,write, orread-and-writePOST /dashboard,PATCH /dashboard, andDELETE /dashboardrequirewriteorread-and-write
List response
GET /dashboard returns every dashboard for the API key's team, ordered by updated_at DESC.
Supported query parameters:
| Field | Type | Required | Exact contract | Default |
|---|---|---|---|---|
page |
Integer | No | Positive integer page number. Values less than 1 are rejected. |
1 |
pageSize |
Integer | No | Positive integer page size. Values greater than 100 are clamped to 100. The API also accepts the legacy alias page_size. |
50 |
Each item includes the dashboard metadata, a url, a widget_count, and the full widget list in API shape.
Top-level response:
| Field | Type | Exact contract |
|---|---|---|
status |
String | Always "success" on success. |
pagination |
Object | Pagination metadata for the current page. See the table below. |
dashboards |
Array | Array of dashboard objects for the API key's team. Empty array when the team has no dashboards. |
pagination includes:
| Field | Type | Exact contract | Example |
|---|---|---|---|
page |
Integer | Current 1-based page number. | 2 |
pageSize |
Integer | Effective page size after defaulting and max-size clamping. | 25 |
total |
Integer | Total number of dashboards for the team before pagination. | 63 |
totalPages |
Integer | Total number of pages for the current pageSize. 0 when total is 0. |
3 |
hasNextPage |
Boolean | true when another page exists after the current page. |
true |
hasPreviousPage |
Boolean | true when another page exists before the current page. |
true |
Each item in dashboards includes:
| Field | Type | Exact contract | Example |
|---|---|---|---|
id |
String | Dashboard id. | "550e8400-e29b-41d4-a716-446655440000" |
team_id |
String | Owning team id. | "a7d4..." |
name |
String | Dashboard name. | "Operations Overview" |
slug |
String | Dashboard slug. | "operations-overview" |
description |
String or null |
Saved dashboard description. | "Core service health and latency" |
created_by |
String or null |
User id for UI-created dashboards. null for API-created dashboards. |
null |
created_by_api_key_id |
String or null |
Team API key id for API-created dashboards. null for UI-created dashboards. |
"key_123" |
created_at |
String | Timestamp string from the dashboards table. | "2026-03-27T00:09:03.797Z" |
updated_at |
String | Timestamp string from the dashboards table. | "2026-03-27T00:09:03.797Z" |
url |
String | Relative dashboard URL in Telemetry UI. | "/team/acme/dashboard/operations-overview" |
widget_count |
Integer | Number of widgets attached to the dashboard. | 2 |
widgets |
Array | Widget array in the same shape returned by create and edit responses. | [] |
Create body
| Field | Type | Required | Exact contract | Example |
|---|---|---|---|---|
name |
String | Yes | Trimmed dashboard name. Must be non-empty after trimming. | "HTTP Monitoring" |
slug |
String | No | Optional custom slug. Telemetry normalizes it by trimming, lowercasing, removing all characters except a-z, 0-9, spaces, and -, converting whitespace runs to -, collapsing repeated -, and trimming leading/trailing -. If omitted, Telemetry derives it from name. The normalized result must still contain at least one alphanumeric character. |
"HTTP Monitoring!!!" becomes "http-monitoring" |
description |
String or null |
No | Optional description. Empty strings are normalized to null. |
"Core service health and latency" |
widgets |
Array | No | Optional array of widgets to create immediately. If omitted, the dashboard is created empty. If any widget is invalid, the whole request is rejected with 400. |
[] |
Create response
POST /dashboard returns 201 Created with the saved dashboard plus the normalized widget list.
{
"status": "success",
"dashboard": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"team_id": "a7d4...",
"name": "HTTP Monitoring",
"slug": "http-monitoring",
"description": "Core service health and latency",
"created_by": null,
"created_by_api_key_id": "key_123",
"created_at": "2026-03-27T00:09:03.797Z",
"updated_at": "2026-03-27T00:09:03.797Z",
"url": "/team/acme/dashboard/http-monitoring"
},
"widgets": [
{
"id": "0b3cb3f5-1147-4f2f-bf64-5044147c3e9a",
"title": "Errors Per Hour",
"widget_type": "query",
"config": {
"querySql": "SELECT hour, errors FROM error_rollups",
"chartType": "Line Chart",
"xAxis": "hour",
"yAxis": "errors",
"groupBy": null,
"queryId": null,
"sourceUrl": null
},
"layout": {
"x": 0,
"y": 0,
"w": 12,
"h": 4
},
"sort_order": 1
}
]
}
Edit body
PATCH /dashboard is a partial update endpoint. Omitted fields stay unchanged.
You must identify the target dashboard with dashboardId or dashboardSlug.
| Field | Type | Required | Exact contract | Example |
|---|---|---|---|---|
dashboardId |
String | One of dashboardId or dashboardSlug |
Existing dashboard id to edit. If both identifiers are sent, they must refer to the same dashboard. | "550e8400-e29b-41d4-a716-446655440000" |
dashboardSlug |
String | One of dashboardId or dashboardSlug |
Existing dashboard slug to edit. This is the current slug, not the new slug. | "http-monitoring" |
name |
String | No | New trimmed dashboard name. Must be non-empty when provided. Updating name does not automatically change the slug. |
"HTTP Monitoring v2" |
slug |
String | No | New slug value. Telemetry normalizes it with the same rules as create. If omitted, the existing slug stays unchanged. | "http-monitoring-v2" |
description |
String or null |
No | New dashboard description. null or "" clears the description. If omitted, the existing description stays unchanged. |
"Updated on-call dashboard" |
widgets |
Array | No | Full replacement widget list. If omitted, existing widgets stay unchanged. If provided, Telemetry deletes the current widget set and replaces it with exactly the widgets in this array. [] clears all widgets. |
[] |
Notes:
PATCHdoes not support appending or editing a single widget in place yet.- When
widgetsis provided, widget ids are regenerated because the API replaces the full widget set.
Edit response
PATCH /dashboard returns 200 OK with the same response shape as create:
dashboardcontains the saved dashboard metadata andurlwidgetscontains the full persisted widget list in API shape- if
widgetswas replaced, the returned widget ids reflect the newly inserted rows
Delete body
DELETE /dashboard deletes a single dashboard and all of its widgets.
You must identify the target dashboard with dashboardId or dashboardSlug.
| Field | Type | Required | Exact contract | Example |
|---|---|---|---|---|
dashboardId |
String | One of dashboardId or dashboardSlug |
Existing dashboard id to delete. If both identifiers are sent, they must refer to the same dashboard. | "550e8400-e29b-41d4-a716-446655440000" |
dashboardSlug |
String | One of dashboardId or dashboardSlug |
Existing dashboard slug to delete. | "http-monitoring" |
Notes:
- Deletion is permanent.
- Deleting a dashboard also deletes all widgets currently attached to it.
Delete response
DELETE /dashboard returns 200 OK:
{
"status": "success",
"deleted_dashboard": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "HTTP Monitoring",
"slug": "http-monitoring",
"description": "Core service health and latency",
"widget_count": 2,
"url": "/team/acme/dashboard/http-monitoring"
}
}
Common errors
400 Bad Requestif the JSON body is invalid or the request body is not a JSON object400 Bad RequestifpageorpageSizeis not a positive integer onGET /dashboard400 Bad RequestifdashboardIdordashboardSlugis missing forPATCHorDELETE400 Bad RequestifPATCHomits all editable fields400 Bad Requestifname,slug,description, orwidgetsfails validation400 Bad Requestif a query widget contains non-read-only SQL403 Forbiddenif the API key does not have permission for the requested operation401 Unauthorizedif the API key is missing or invalid404 Not Foundif the dashboard does not exist for the API key's team409 Conflictif another dashboard in the same team already uses the requested slug
Widget fields
Each item in widgets supports:
| Field | Type | Required | Exact contract | Example |
|---|---|---|---|---|
title |
String | Yes | Trimmed widget title. Must be non-empty after trimming. | "Errors Per Hour" |
widget_type |
String | Yes | Exact enum: query or explorer. |
"query" |
config |
Object | Yes | Widget configuration object. Its required fields depend on widget_type. |
{ "querySql": "...", "chartType": "Line Chart" } |
layout |
Object | No | Optional grid placement. If omitted, Telemetry stacks widgets vertically using defaults. | { "x": 0, "y": 0, "w": 12, "h": 4 } |
Understanding layout
Telemetry dashboards use a 12-column grid.
The layout object uses grid units, not pixels:
| Field | Type | Default | Exact contract | Example |
|---|---|---|---|---|
x |
Integer | 0 |
Non-negative integer. 0 is the far left. Invalid values fall back to 0. |
0 |
y |
Integer | next open row | Non-negative integer. 0 is the top of the dashboard. If omitted, Telemetry places the widget after the previous widget’s y + h. Invalid values fall back to that computed row. |
0 |
w |
Integer | 12 |
Positive integer width in grid columns. 12 means full width and 6 means half width. Invalid values fall back to 12. |
12 |
h |
Integer | 4 |
Positive integer height in grid rows. Invalid values fall back to 4. |
4 |
So this:
"layout": { "x": 0, "y": 0, "w": 12, "h": 4 }
means:
- place the widget at the top-left corner of the dashboard
- make it span all 12 columns
- make it 4 dashboard grid rows tall
This:
"layout": { "x": 6, "y": 0, "w": 6, "h": 4 }
means:
- place the widget on the top row
- start halfway across the dashboard
- make it take the right half of the row
For example, these two widgets render side-by-side:
[
{ "layout": { "x": 0, "y": 0, "w": 6, "h": 4 } },
{ "layout": { "x": 6, "y": 0, "w": 6, "h": 4 } }
]
If you omit layout, Telemetry stacks widgets vertically using these defaults:
x = 0w = 12h = 4yis placed on the next open row
Telemetry does not clamp x and w to the 12-column grid and does not resolve overlapping positions for you. Keep x + w <= 12 and avoid overlapping x/y ranges if you want predictable rendering.
Query widget config
| Field | Type | Required | Exact values or format | Example |
|---|---|---|---|---|
querySql |
String | Yes | Trimmed read-only SQL string. Must be non-empty after trimming. Dashboard query widgets reject write statements such as INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, GRANT, and REVOKE. |
"SELECT endpoint, COUNT(*) AS errors FROM http_logs GROUP BY endpoint" |
chartType |
String | Yes | Exact enum validated by the API: table, Scatter Plot, Bar Chart, Line Chart, or Stacked Area Chart. |
"Line Chart" |
xAxis |
String | Required for non-table charts |
Trimmed result column name to use for the x-axis. For Bar Chart, this can be a text/categorical, numeric, or time-like column. For Scatter Plot, Line Chart, and Stacked Area Chart, use a numeric or time-like column. May be an empty string only when chartType is table. |
"hour" |
yAxis |
String | Required for non-table charts |
Trimmed numeric result column name to use for the y-axis. May be an empty string only when chartType is table. |
"errors" |
groupBy |
String or null |
No | Optional trimmed result column name used to split a chart into multiple series. Use null or omit it for no grouping. |
"service" |
queryId |
String or null |
No | Optional trimmed saved-query id. The API stores this as a string and does not validate its shape. | "550e8400-e29b-41d4-a716-446655440000" |
sourceUrl |
String or null |
No | Optional trimmed UI URL to open when the widget title is clicked. Relative app URLs such as /team/acme/ops/draft/0?... are recommended. |
"/team/acme/ops/draft/0?tab=chart&chartType=Line+Chart&xAxis=hour&yAxis=errors" |
Notes:
- Use
chartType: "table"if you want a plain result table instead of a chart. - Dashboard query widgets auto-run when the dashboard loads, so only read-only SQL is accepted.
- For all non-
tablecharts,xAxisandyAxismust match actual columns returned byquerySql. - For
Bar Chart,xAxiscan be text/categorical, numeric, or time-like. - For
Scatter Plot,Line Chart, andStacked Area Chart,xAxismust be numeric or time-like. - For all non-
tablecharts,yAxismust be numeric. - For categorical
Bar Chartx-axes, the widget preserves query result order. UseORDER BYinquerySqlto control bar order. - If
sourceUrlis omitted, the dashboard UI derives a fallback draft-query URL when it can. The derived link preservesdefaultQuery,tab,chartType,xAxis,yAxis, andgroupBy.
Example: query widget with a categorical bar-chart x-axis
{
"title": "Requests by Endpoint",
"widget_type": "query",
"config": {
"querySql": "SELECT endpoint, COUNT(*) AS requests FROM http_logs GROUP BY endpoint ORDER BY requests DESC",
"chartType": "Bar Chart",
"xAxis": "endpoint",
"yAxis": "requests",
"groupBy": null
}
}
In that example:
endpointis a text x-axis columnrequestsis the numeric y-axis columnORDER BY requests DESCcontrols the left-to-right bar order
Explorer widget config
| Field | Type | Required | Exact values or format | Example |
|---|---|---|---|---|
tableName |
String | Yes | Trimmed source table name. Must be non-empty after trimming. | "queue_metrics" |
explorerState |
Object | Yes | Full Explore configuration used to render the widget. See the detailed field table below. | { "graphType": "line", "aggregation": "p95", ... } |
sourceUrl |
String or null |
No | Optional trimmed UI URL to open when the widget title is clicked. If omitted, Telemetry derives a fallback Explore URL from tableName and explorerState. |
"/team/acme/table/queue_metrics?tab=explore&graphType=line" |
explorerState fields
| Field | Type | Required | Exact values or format | Default | Example |
|---|---|---|---|---|---|
graphType |
String | Yes | Exact enum validated by the API: samples, table, line, bar, stacked-area. |
"samples" |
"line" |
aggregation |
String | Yes | Exact enum validated by the API: count, sum, avg, min, max, p50, p90, p95, p99. |
"count" |
"p95" |
metric |
String or null |
No | Trimmed column or field path to aggregate. Use null for count. For non-count aggregations, this is the primary measure and takes precedence over auto-detected numeric selectedColumns when provided. |
null |
"latency_ms" |
timeZone |
String or null |
No | Use UTC or an IANA time zone identifier such as America/Los_Angeles or Europe/Berlin. The API currently trims and stores any non-empty string, but invalid time zone names can fail later when the widget query runs. If omitted, the saved config keeps null and the UI falls back to the client time zone or UTC depending on context. |
null |
"America/Los_Angeles" |
timePreset |
String | Yes | Supported values are 1h, 6h, 24h, 7d, 30d, 90d, custom. The API currently accepts any non-empty string, but only those values are supported by the UI and SQL generator. |
"7d" |
"7d" |
customStart |
String | No | Used when timePreset is custom. Recommended format: YYYY-MM-DDTHH:mm, YYYY-MM-DD HH:mm, YYYY-MM-DDTHH:mm:ss, or YYYY-MM-DDTHH:mm:ss.sss, optionally followed by Z or a numeric offset like -07:00. If no timezone suffix is present, the value is interpreted in timeZone. |
unset | "2026-03-20T00:00:00Z" |
customEnd |
String | No | Same format as customStart. If omitted for a custom range, the query has no upper time bound. |
unset | "2026-03-26T00:00:00-07:00" |
granularity |
String | Yes | Exact enum validated by the API: auto, minute, hour, day, week, month. |
"auto" |
"hour" |
splitBy |
Array of strings | Yes | Array of non-empty trimmed field names. Dotted paths such as attributes.queue_name are allowed for nested JSON fields. |
[] |
["queue_name"] |
seriesLimit |
Integer or null |
No | Positive integer or null. null means no explicit series cap. Most useful for split charts. |
100 |
10 |
filters |
Array | Yes | Array of filter groups. Each group is ANDed internally; multiple groups are ORed together. Empty or invalid groups are dropped during normalization. | [] |
[] |
selectedColumns |
Array of strings | Yes | Array of non-empty trimmed field names to show in samples or table views. Dotted JSON paths are allowed. Use [] to let Telemetry choose defaults. |
[] |
["timestamp_utc", "queue_name", "latency_ms"] |
orderBy |
String or null |
No | Optional field name used to order tabular results. Dotted JSON paths are allowed. | null |
"timestamp_utc" |
limit |
Integer | Yes | Positive integer row or group limit. Invalid values fall back to the default. | 200 |
200 |
orderDirection |
String | Yes | Exact enum validated by the API: ASC or DESC. |
"DESC" |
"DESC" |
timePreset values
| Value | Meaning |
|---|---|
1h |
Last 1 hour |
6h |
Last 6 hours |
24h |
Last 24 hours |
7d |
Last 7 days |
30d |
Last 30 days |
90d |
Last 90 days |
custom |
Use customStart and customEnd instead of a relative range |
If you send an undocumented timePreset, the current SQL generator falls back to 7-day behavior. Treat the values above as the supported contract.
granularity: "auto" behavior
When granularity is auto, Telemetry resolves it like this:
timePreset |
Effective granularity |
|---|---|
1h, 6h, 24h |
minute |
7d |
hour |
30d, 90d, custom |
day |
Filter groups and conditions
filters should be shaped like this:
[
{
"logic": "AND",
"conditions": [
{ "field": "queue_name", "operator": "=", "value": "email" },
{ "field": "latency_ms", "operator": ">", "value": "1000" }
]
}
]
Each item in filters is a filter group:
| Field | Type | Required | Exact values or format | Example |
|---|---|---|---|---|
logic |
String | Yes | Must be AND. The dashboard creation API normalizes every group to AND; there is no per-group OR. To express OR, use multiple groups because groups are ORed together. |
"AND" |
conditions |
Array | Yes | Array of filter conditions in the group. A group with zero valid conditions is dropped. | [{ "field": "queue_name", "operator": "=", "value": "email" }] |
Each item in conditions supports:
| Field | Type | Required | Exact values or format | Example |
|---|---|---|---|---|
field |
String | Yes | Non-empty trimmed field path. Dotted nested JSON paths such as attributes.queue_name are supported. Empty path segments are not allowed. |
"latency_ms" |
operator |
String | Yes | Exact enum validated by the API: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IS NULL, IS NOT NULL. |
">" |
value |
String, Number, Boolean, or null |
Usually | Stored as a string during normalization. For IS NULL and IS NOT NULL, use "" or omit the semantic value. null becomes "". |
"1000" |
When the generated SQL runs:
- conditions inside a group are joined with
AND - groups are joined with
OR - dotted field paths such as
attributes.queue_nameare translated into nested field access
Example: create an empty dashboard
Example: list dashboards
curl "https://api.telemetry.sh/dashboard?page=1&pageSize=25" \
-H "Authorization: $API_KEY"
Example response:
{
"status": "success",
"pagination": {
"page": 1,
"pageSize": 25,
"total": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false
},
"dashboards": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"team_id": "team_123",
"name": "Operations Overview",
"slug": "operations-overview",
"description": "Core service health and latency",
"created_by": null,
"created_by_api_key_id": "key_123",
"created_at": "2026-03-27T00:09:03.797Z",
"updated_at": "2026-03-27T00:09:03.797Z",
"url": "/team/acme/dashboard/operations-overview",
"widget_count": 1,
"widgets": [
{
"id": "widget_123",
"title": "Requests Per Hour",
"widget_type": "query",
"config": {
"querySql": "SELECT DATE_TRUNC('hour', timestamp_utc) AS hour, COUNT(*) AS requests FROM http_logs GROUP BY 1 ORDER BY 1",
"chartType": "Line Chart",
"xAxis": "hour",
"yAxis": "requests",
"groupBy": null,
"sourceUrl": "/team/acme/ops/draft/0?tab=chart&chartType=Line+Chart&xAxis=hour&yAxis=requests"
},
"layout": { "x": 0, "y": 0, "w": 12, "h": 4 },
"sort_order": 0
}
]
}
]
}
Example: create an empty dashboard
curl -X POST https://api.telemetry.sh/dashboard \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d '{
"name": "Operations Overview",
"description": "Core service health and latency"
}'
Example: rename a dashboard and update its description
curl -X PATCH https://api.telemetry.sh/dashboard \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d '{
"dashboardSlug": "operations-overview",
"name": "Operations Overview v2",
"description": "Updated to focus on API latency and error rates"
}'
Example: replace all widgets on an existing dashboard
This request keeps the same dashboard but replaces the full widget set with the new widgets array.
curl -X PATCH https://api.telemetry.sh/dashboard \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d '{
"dashboardSlug": "operations-overview",
"widgets": [
{
"title": "Revenue by City",
"widget_type": "explorer",
"config": {
"tableName": "uber_rides",
"explorerState": {
"graphType": "table",
"aggregation": "sum",
"metric": "price",
"timePreset": "30d",
"granularity": "auto",
"splitBy": ["city"],
"filters": [],
"selectedColumns": [],
"orderBy": "price",
"limit": 10,
"orderDirection": "DESC"
}
},
"layout": { "x": 0, "y": 0, "w": 12, "h": 4 }
}
]
}'
Example: delete a dashboard
curl -X DELETE https://api.telemetry.sh/dashboard \
-H "Content-Type: application/json" \
-H "Authorization: $API_KEY" \
-d '{
"dashboardSlug": "operations-overview"
}'
Common widget examples
The next examples show only the JSON request body you send to POST /dashboard.
These examples assume an uber_rides table with fields such as:
citypricewait_time_minutesstatususer_idtimestamp_utc
Example: create a dashboard with two common Explore widgets
This example includes:
- a table widget that sums ride revenue by city and sorts descending by revenue
- a time series widget that charts average wait time by city as grouped lines
{
"name": "Uber Marketplace Overview",
"widgets": [
{
"title": "Revenue by City",
"widget_type": "explorer",
"config": {
"tableName": "uber_rides",
"explorerState": {
"graphType": "table",
"aggregation": "sum",
"metric": "price",
"timePreset": "30d",
"granularity": "auto",
"splitBy": ["city"],
"filters": [
{
"logic": "AND",
"conditions": [
{ "field": "status", "operator": "=", "value": "completed" }
]
}
],
"selectedColumns": ["city", "price"],
"orderBy": "price",
"limit": 20,
"orderDirection": "DESC"
}
},
"layout": { "x": 0, "y": 0, "w": 6, "h": 4 }
},
{
"title": "Average Wait Time by City",
"widget_type": "explorer",
"config": {
"tableName": "uber_rides",
"explorerState": {
"graphType": "line",
"aggregation": "avg",
"metric": "wait_time_minutes",
"timePreset": "7d",
"granularity": "hour",
"splitBy": ["city"],
"seriesLimit": null,
"filters": [
{
"logic": "AND",
"conditions": [
{ "field": "status", "operator": "=", "value": "completed" }
]
}
],
"selectedColumns": ["city", "wait_time_minutes"],
"limit": 200,
"orderDirection": "ASC"
}
},
"layout": { "x": 6, "y": 0, "w": 6, "h": 4 }
}
]
}
In that example:
Revenue by Cityis an Explore table widgetselectedColumns: ["city", "price"]plusaggregation: "sum"yields one aggregatedpricecolumn percityorderBy: "price"sorts by that aggregated revenue columnAverage Wait Time by Cityis an Explore line widgetsplitBy: ["city"]creates one line per cityorderDirection: "ASC"keeps the time axis in chronological order
Example: create a query widget for cohort retention smile curves
This example uses a Query widget because cohort retention is much harder to express in Explore. It uses:
- a first-ride cohort definition
- a distinct weekly activity table
- a join between cohort sizes and retained riders
Readable querySql:
WITH first_rides AS (
SELECT
user_id,
date_trunc('week', MIN(timestamp_utc)) AS cohort_week
FROM
uber_rides
WHERE
status = 'completed'
GROUP BY
user_id
),
weekly_activity AS (
SELECT DISTINCT
user_id,
date_trunc('week', timestamp_utc) AS activity_week
FROM
uber_rides
WHERE
status = 'completed'
),
cohort_activity AS (
SELECT
f.cohort_week,
a.activity_week,
date_diff('week', f.cohort_week, a.activity_week) AS weeks_since_first_ride,
COUNT(DISTINCT a.user_id) AS retained_riders
FROM
first_rides f
JOIN weekly_activity a ON a.user_id = f.user_id
WHERE
a.activity_week >= f.cohort_week
GROUP BY
f.cohort_week,
a.activity_week,
date_diff('week', f.cohort_week, a.activity_week)
),
cohort_sizes AS (
SELECT
cohort_week,
COUNT(*) AS cohort_size
FROM
first_rides
GROUP BY
cohort_week
)
SELECT
weeks_since_first_ride,
100.0 * retained_riders / cohort_size AS retention_pct,
CAST(ca.cohort_week AS VARCHAR) AS cohort_week
FROM
cohort_activity ca
JOIN cohort_sizes cs ON ca.cohort_week = cs.cohort_week
WHERE
weeks_since_first_ride BETWEEN 0 AND 12
ORDER BY
weeks_since_first_ride ASC,
cohort_week ASC
{
"name": "Uber Rider Retention",
"widgets": [
{
"title": "Weekly Rider Retention Smile Curves",
"widget_type": "query",
"config": {
"querySql": "WITH first_rides AS ( SELECT user_id, date_trunc('week', MIN(timestamp_utc)) AS cohort_week FROM uber_rides WHERE status = 'completed' GROUP BY user_id ), weekly_activity AS ( SELECT DISTINCT user_id, date_trunc('week', timestamp_utc) AS activity_week FROM uber_rides WHERE status = 'completed' ), cohort_activity AS ( SELECT f.cohort_week, a.activity_week, date_diff('week', f.cohort_week, a.activity_week) AS weeks_since_first_ride, COUNT(DISTINCT a.user_id) AS retained_riders FROM first_rides f JOIN weekly_activity a ON a.user_id = f.user_id WHERE a.activity_week >= f.cohort_week GROUP BY f.cohort_week, a.activity_week, date_diff('week', f.cohort_week, a.activity_week) ), cohort_sizes AS ( SELECT cohort_week, COUNT(*) AS cohort_size FROM first_rides GROUP BY cohort_week ) SELECT weeks_since_first_ride, 100.0 * retained_riders / cohort_size AS retention_pct, CAST(ca.cohort_week AS VARCHAR) AS cohort_week FROM cohort_activity ca JOIN cohort_sizes cs ON ca.cohort_week = cs.cohort_week WHERE weeks_since_first_ride BETWEEN 0 AND 12 ORDER BY weeks_since_first_ride ASC, cohort_week ASC",
"chartType": "Line Chart",
"xAxis": "weeks_since_first_ride",
"yAxis": "retention_pct",
"groupBy": "cohort_week"
},
"layout": { "x": 0, "y": 0, "w": 12, "h": 5 }
}
]
}
In that example:
weeks_since_first_rideis the x-axisretention_pctis the y-axisgroupBy: "cohort_week"creates one line per signup cohort, which produces the smile-curve style comparison- this is a Query widget instead of an Explore widget because the cohort logic depends on multiple CTEs and joins
Response
Successful POST requests return 201 Created.
Successful PATCH requests return 200 OK.
Both return this shape:
{
"status": "success",
"dashboard": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"team_id": "team_123",
"name": "HTTP Monitoring",
"slug": "http-monitoring",
"description": null,
"created_by": null,
"created_by_api_key_id": "8dff0c1a-8b36-49c3-9e6b-dc0d3ff51d74",
"created_at": "2026-03-26T00:00:00.000Z",
"updated_at": "2026-03-26T00:00:00.000Z",
"url": "/team/acme/dashboard/http-monitoring"
},
"widgets": [
{
"id": "9a6e4f27-d3bf-4a70-bf3a-38fe63211f93",
"title": "Errors Per Hour",
"widget_type": "query",
"config": {
"queryId": null,
"querySql": "SELECT ...",
"chartType": "Line Chart",
"xAxis": "hour",
"yAxis": "errors",
"groupBy": null,
"sourceUrl": null
},
"layout": {
"x": 0,
"y": 0,
"w": 12,
"h": 4
},
"sort_order": 1
}
]
}
Successful DELETE requests return 200 OK with a deletion summary:
{
"status": "success",
"deleted_dashboard": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "HTTP Monitoring",
"slug": "http-monitoring",
"description": null,
"widget_count": 6,
"url": "/team/acme/dashboard/http-monitoring"
}
}
Dashboards created through this API are team-scoped and attributed to the API key that created them:
created_byisnullfor API-created dashboards because an API key is not a usercreated_by_api_key_idis the internalteam_api_keys.idrow for the creating key- dashboards created through the UI still use
created_bywith a user id and typically leavecreated_by_api_key_idasnull
If the normalized slug already exists for your team, the API returns 409 Conflict.
Other common errors:
400 Bad Requestif required fields are missing or invalid400 Bad Requestif a query widget includes non-read-only SQL404 Not Foundif the target dashboard does not exist for your team401 Unauthorizedif the API key is missing or invalid400 Bad Requestif the API key only hasreadscope