docs-mintlify/docs/explore-analyze/dashboards/dashboards-as-code.mdx
Dashboards as code lets you manage the reporting assets in a deployment —
workbooks, their dashboards, and the
reports the dashboard widgets render — from source control instead
of only through the UI. You keep each asset's definition in Git and apply it to a
deployment with the Cube Cloud REST API, the same way you might manage
Superset assets with preset-cli or infrastructure with Terraform.
Two idempotent upsert endpoints make this possible. Instead of tracking the
per-deployment numeric id that a POST returns, you address each asset by a
portable identifier you choose and re-apply its definition as often as you
like:
| Endpoint | Keyed by | Upserts |
|---|---|---|
PUT /deployments/{deploymentId}/workbooks/by-slug/{slug} | a deployment-scoped slug | a workbook (and its dashboard draft) |
PUT /deployments/{deploymentId}/reports/by-public-id/{publicId} | an account-unique publicId | a report |
Because the identifier is stable and lives in your repository, applying the same definition twice is a no-op, and applying it to a second deployment (staging → production) reproduces the same assets there.
<Note>This page covers the REST primitives available today. They are the building blocks for an as-code workflow you assemble in your own pipeline — Cube does not yet ship a single bundle export/apply command that wraps them.
</Note>Three assets are involved, each with its own identity:
publicId: a 12-character alphanumeric ([0-9A-Za-z]) id that is unique
across your account. You mint it when you author the report and keep it fixed
for the report's lifetime.links: [{ dashboard: <slug> }] for drill-in).meta.dashboardDraft and is made visible by publishing the
workbook. Each chart widget references a report.The identifiers you control (publicId, slug) are what make a definition
portable. The numeric ids that POST responses return are per-deployment and are
resolved at apply time — you never store them in Git.
These are public REST endpoints. Authenticate with a deployment API key exactly as for the rest of the REST API — see Authentication for how to create a key and pass it. The examples below assume:
export CUBE_API_URL="https://<your-cube-cloud-host>"
export CUBE_API_TOKEN="<your-api-key>"
export DEPLOYMENT_ID="<your-deployment-id>"
An as-code pipeline applies a dashboard bottom-up: reports first, then the workbook that lays them out, then publish.
The report and dashboard-draft definitions are large and are not meant to be hand-written. Build the reports and dashboard once in the UI, then read them back over the API and commit the results:
GET /deployments/{deploymentId}/reports/{reportId} returns a
report's definition.GET /deployments/{deploymentId}/workbooks/{workbookId}
returns the workbook, including its dashboardDraft.Assign each report a publicId and the workbook a slug of your choosing, store
those alongside the exported definitions in your repository, and treat that as the
source of truth.
For every report, upsert it by publicId. If a report with
that publicId already exists in the deployment it is updated with the fields you
send (same semantics as PUT /reports/{reportId});
otherwise it is created with that publicId.
curl -X PUT \
"$CUBE_API_URL/api/v1/deployments/$DEPLOYMENT_ID/reports/by-public-id/revqZ1x8Kp0a" \
-H "Authorization: $CUBE_API_TOKEN" \
-H "Content-Type: application/json" \
-d @report-revenue-by-month.json
The path publicId is the report's identity; the request body is the report
definition you exported (its query in sqlQuery / jsonQuery, pivot in
pivotItems, and visualization config in meta). Keep track of the numeric
id each response returns — the dashboard draft references reports by that
per-deployment id.
Upsert the workbook by slug, carrying the dashboard
layout in meta.dashboardDraft. Only the fields you send are changed, and meta
is merged into the existing metadata rather than replacing it. The
dashboardDraft is validated the same way the builder validates it.
curl -X PUT \
"$CUBE_API_URL/api/v1/deployments/$DEPLOYMENT_ID/workbooks/by-slug/revenue-overview" \
-H "Authorization: $CUBE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Revenue Overview",
"meta": { "dashboardDraft": { "...": "the exported dashboard config" } }
}'
Because each chart widget inside dashboardDraft points at a report by its
per-deployment numeric id, rewrite those references to the ids returned in
step 2 before applying the workbook to a new deployment. Re-applying to
the same deployment needs no rewriting — the ids are stable there.
Upserting the workbook writes the dashboard draft. Publish it to make it
visible to viewers with POST /workbooks/{workbookId}/publish,
using the workbook id returned in step 3. Publishing is itself idempotent per
workbook, so it is safe to run on every apply.
Re-applying an unchanged definition is a no-op — that is the property that makes
these endpoints safe to run on every pipeline execution. When something does go
wrong, both upserts fail with a 409 rather than guessing, and the report upsert
distinguishes three cases by a code field in the response body so your pipeline
can react correctly:
| Endpoint | code | Meaning | What to do |
|---|---|---|---|
| workbook & report | upsert_branch_changed | A concurrent writer created or deleted the asset between the access check and the write, so the request would have applied under the wrong permission check. | Retry. Transient; happens only under concurrent applies of the same key. |
| report | (none) | The publicId already belongs to a report in a different deployment. publicId is unique across the account. | Permanent. Use a different publicId. |
| report | ambiguous_legacy_id | The id matches more than one legacy report (see below), so it can't identify one. | Permanent. Give the intended report a publicId of your own (see below), then key on that. |
The upserts serialize per key (per slug, per publicId), so two pipeline runs
applying the same bundle at once can't create a duplicate — the loser gets a
retryable upsert_branch_changed instead.
publicIdsA report's publicId is write-once: you can assign one to a report that
doesn't have one yet, but a report's existing publicId can never be changed,
because clients may already have stored it. You can supply a publicId:
POST /reports, or
just call the upsert endpoint with the id in the path.PUT /reports/{reportId}. This is how you bring a report
that was authored in the UI under as-code management.Pick any distinct 12-character [0-9A-Za-z] id. The auto-generated placeholder
ids shown for reports that don't have a stable id yet are a reserved, non-unique
shape and are rejected with 400 — you must choose your own.
Reports created before publicId existed don't store one; the API synthesizes
one from the report's internal id so every report has an id on the wire. These
synthesized ids are not unique — several reports can share one. The upsert
endpoint resolves a synthesized id only when it is unambiguous, adopting it as the
report's real publicId at that point; if it matches more than one report it
returns the ambiguous_legacy_id conflict above. For anything you manage as code,
don't rely on a synthesized id — assign a publicId you chose and key on that.