Browse docs
API ReferenceUpdated March 27, 2026

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 /dashboard accepts read, write, or read-and-write
  • POST /dashboard, PATCH /dashboard, and DELETE /dashboard require write or read-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:

  • PATCH does not support appending or editing a single widget in place yet.
  • When widgets is 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:

  • dashboard contains the saved dashboard metadata and url
  • widgets contains the full persisted widget list in API shape
  • if widgets was 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 Request if the JSON body is invalid or the request body is not a JSON object
  • 400 Bad Request if page or pageSize is not a positive integer on GET /dashboard
  • 400 Bad Request if dashboardId or dashboardSlug is missing for PATCH or DELETE
  • 400 Bad Request if PATCH omits all editable fields
  • 400 Bad Request if name, slug, description, or widgets fails validation
  • 400 Bad Request if a query widget contains non-read-only SQL
  • 403 Forbidden if the API key does not have permission for the requested operation
  • 401 Unauthorized if the API key is missing or invalid
  • 404 Not Found if the dashboard does not exist for the API key's team
  • 409 Conflict if 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 = 0
  • w = 12
  • h = 4
  • y is 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-table charts, xAxis and yAxis must match actual columns returned by querySql.
  • For Bar Chart, xAxis can be text/categorical, numeric, or time-like.
  • For Scatter Plot, Line Chart, and Stacked Area Chart, xAxis must be numeric or time-like.
  • For all non-table charts, yAxis must be numeric.
  • For categorical Bar Chart x-axes, the widget preserves query result order. Use ORDER BY in querySql to control bar order.
  • If sourceUrl is omitted, the dashboard UI derives a fallback draft-query URL when it can. The derived link preserves defaultQuery, tab, chartType, xAxis, yAxis, and groupBy.

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:

  • endpoint is a text x-axis column
  • requests is the numeric y-axis column
  • ORDER BY requests DESC controls 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_name are 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:

  • city
  • price
  • wait_time_minutes
  • status
  • user_id
  • timestamp_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 City is an Explore table widget
  • selectedColumns: ["city", "price"] plus aggregation: "sum" yields one aggregated price column per city
  • orderBy: "price" sorts by that aggregated revenue column
  • Average Wait Time by City is an Explore line widget
  • splitBy: ["city"] creates one line per city
  • orderDirection: "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_ride is the x-axis
  • retention_pct is the y-axis
  • groupBy: "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_by is null for API-created dashboards because an API key is not a user
  • created_by_api_key_id is the internal team_api_keys.id row for the creating key
  • dashboards created through the UI still use created_by with a user id and typically leave created_by_api_key_id as null

If the normalized slug already exists for your team, the API returns 409 Conflict.

Other common errors:

  • 400 Bad Request if required fields are missing or invalid
  • 400 Bad Request if a query widget includes non-read-only SQL
  • 404 Not Found if the target dashboard does not exist for your team
  • 401 Unauthorized if the API key is missing or invalid
  • 400 Bad Request if the API key only has read scope