---
title: "Create a dashboard from a spec."
method: POST
path: "/v1/dashboards"
tags: ["dashboards"]
---

# Create a dashboard from a spec.

`POST /v1/dashboards`

## Request

- Base URL: `http://localhost:8080`
- URL: `http://localhost:8080/v1/dashboards`
- Auth: one of:
  - API key in cookie `dvt_session`
  - HTTP bearer

Validates the spec against the dvt dashboard JSON Schema, decomposes its panels and
`lg`-breakpoint layout into elements, and persists a new dashboard. If the spec omits
`id` (or sets it to the all-zeros UUID placeholder `00000000-0000-0000-0000-000000000000`),
the server generates one and injects it into the stored spec. If `id` is set to an
existing dashboard's id, the request fails with 409 — unless the existing dashboard's
stored spec is semantically identical to the one submitted, in which case the request is
treated as an idempotent replay and returns 200 with the existing dashboard (see
`PATCH /v1/dashboards/{id}` to make an actual update instead).

## Request body

- CreateDashboardRequest
  - `visibility` 'private' | 'shared' | 'public'
  - `spec` object, required: The full dvt dashboard spec (meta, theme, layout, panels).
  - `changeSummary` string: Optional human-readable note describing the dashboard being created (DVT-1426) — e.g. "Swapped revenue bar for cohort heatmap per Maria's request". Human-authored or agent-authored only: unlike PATCH's changeSummary below, dvt web never client-generates a diff-based summary here — there is no prior revision to diff against at create time, and the Save-confirmation modal that composes the client-generated summary (DVT-2686) is PATCH-only. A blank value here is simply recorded as blank. Recorded verbatim on the initial revision and surfaced verbatim in the org activity feed; never summarized or transformed. Over 280 characters is rejected with 400.
  - `queryRole` string: Snowflake mode only (DVT-1738): pin the Snowflake role this dashboard's queries run under for every viewer, at create time — the same validation as PATCH's queryRole (you can only pin a role your own Snowflake session holds, validated live against CURRENT_AVAILABLE_ROLES(); unquoted role names only, canonicalized to upper case; reserved pseudo-roles (PUBLIC) are rejected 400; a non-held role is rejected 422 and the dashboard is NOT created). Omit (or pass an empty string) to create unpinned — each viewer's queries then run as their own default role. Outside snowflake mode the field is rejected with 400.

## Response `200`

Idempotent replay: the id already exists in this org with an identical spec; returns the existing dashboard unchanged (no new revision is created).

- DashboardEnvelope
  - `data` Dashboard, required
    - `id` string, uuid, required
    - `folderId` string, uuid, nullable, required: The containing folder, or null when unfoldered. Set via PUT /v1/dashboards/{id}/folder.
    - `ownerId` string, uuid, required: The user who created the dashboard. Only the owner (or an org admin/owner) may move it between folders.
    - `title` string, required
    - `description` string: Optional human/agent-authored summary of the dashboard (from the dashboards.description column, falling back to spec meta.brief on list). Omitted when neither source is populated.
    - `visibility` 'private' | 'shared' | 'public', required
    - `queryRole` string: Snowflake mode only (DVT-1388): the optional pinned Snowflake role this dashboard's queries run under for every viewer (canonicalized to Snowflake's case fold). A viewer who does not hold the role gets a clean no-access query error — Snowflake enforces the pin, it can never elevate. Omitted when unpinned: each viewer's own default (primary) role is used, or, for a viewer reading via a role share, the shared role.
    - `version` integer, required: Optimistic-concurrency version. Pass this back on PATCH.
    - `schemaVersion` integer, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `spec` object, nullable, required: The full dvt dashboard spec from the current revision snapshot. Always `null` on collection reads (`GET /v1/dashboards`, `GET /v1/me/favorites`), which omit the spec to stay cheap — fetch `GET /v1/dashboards/{id}` for it. Populated on single-dashboard reads and writes.
    - `governance` DashboardGovernance: Secret-free governance-disclosure summary for the connections this dashboard references (ADR-0012 §"Governance disclosure rule"). Present on GET responses when the auth-method kind can be resolved; carries ONLY the auth-method enum, never any credential material. Drives the shared-role notice in the UI.
      - `sharedRole` boolean, required: True when at least one referenced connection uses a service_* auth method (one shared service identity — all viewers see the same role's data). False for per-user/OAuth-backed dashboards, which display no notice.
      - `roleMapped` boolean, required: True when every shared-role source is role-mapped (the 2026-06-14 ADR-0012 amendment): the notice then reads "data shown as your mapped role" rather than "all viewers see the same data".
      - `connections` ConnectionGovernance[], required: Per-connection auth-method kind for each source the spec references.
        - `sourceId` string, required: The panel's sourceId exactly as the spec spells it — a connection id (matched case-insensitively) or name. A connection referenced both ways appears once per distinct reference.
        - `authMethod` 'service_secret' | 'service_keypair' | 'oauth_passthrough' | 'cloud_iam' | 'spcs_token', required: The connection's auth-method kind. Never any credential material.
        - `type` 'snowflake' | 'bigquery' | 'databricks_sql' | 'redshift' | 'postgres' | 'mysql' | 'sqlite' | 'parquet_s3' | 'csv' | 'google_sheets': The connection's warehouse type (e.g. snowflake, postgres, bigquery) — the same value Connection.type carries. A non-secret enum; lets a viewer-tier client name the warehouse without the editor-only connections list.
        - `roleMapped` boolean: A non_secret_config.roleMapping policy is in force (service_* only).
        - `resolvedRoles` string[]: Roles the acting viewer's role-mapping resolution yields for this connection: the primary role first, then any secondary roles sorted ascending. Omitted when the connection is not role-mapped or the resolution is unavailable (best-effort disclosure). Role names are non-secret metadata (ADR-0012/DVT-11 contract).
    - `url` string, uri: User-facing deep link into the dvt web app for this dashboard (DVT-5113), `<app URL>/o/<org>/dashboards/<id>`, built from APP_URL, else APP_BASE_URL (default https://app.dvt.dev). Present on single-dashboard reads (`GET /v1/dashboards/{id}`) and on create/update responses; omitted on collection reads and when the host is a placeholder (`.invalid`).
    - `conformance` 'core' | 'full': The spec's portability layer (ADR-0014), computed from the spec: `core` when it uses only the renderer-neutral dvt Core profile (portable, the "industry standard" layer), `full` when it uses any escape hatch (ECharts `option` passthrough, raw `html`, `media`, `$dvtRef`, `chart:custom`, or an arbitrary-CSS canvas background). A derived, read-only property — never authored. Optional for back-compat.
    - `provenance` ProvenanceSuggestion[]: Advisory provenance suggestions (DVT-249, ADR-0004 Amendment 1) computed from the persisted spec, present on create/update responses so an agent applying without a preview sees the same `meta.brief`/`purpose`/`audience`/ `keyQuestions` guidance the apply preview returns. NON-BLOCKING — persistence already succeeded. Omitted when there is nothing to suggest.
      - `field` string, required: Dotted spec path the suggestion concerns (e.g. `meta.brief`, `meta.panels.p1.serves_question`).
      - `severity` 'gate' | 'warn' | 'info', required: `gate` — the Tier-1 authoring gate (`meta.brief` on a 3+ panel dashboard); advisory at the server, treated as blocking by the authoring boundary. `warn` — a Tier-2 gap (missing `purpose`/`audience`/`keyQuestions` at 3+ panels, or a `serves_question` index past `keyQuestions`). `info` — a Tier-3 nudge (no `dataAsOf` on a data-bound dashboard, no per-panel provenance, an agent-asserted-but-unvalidated claim, or an orphan `meta.panels` key).
      - `message` string, required: Human/agent-readable guidance for resolving the suggestion.
    - `provenanceSummary` DashboardProvenanceSummary: Bounded discovery summary projected from the spec's `meta` provenance (DVT-250), present on dashboard LIST results so an agent can find the right dashboard by purpose/audience/question without fetching each full spec. Distinct from the apply-time `provenance` suggestions (DVT-249): this is recorded *content*, not advisory lint. Omitted when the dashboard carries no provenance. `findings` is a COUNT (not the array) to keep the summary cheap.
      - `brief` string: meta.brief — the one-line thesis.
      - `findingsCount` integer: Number of meta.findings entries.
      - `audience` 'executive' | 'analyst' | 'operator': meta.audience.
      - `keyQuestions` string[]: meta.keyQuestions — the questions this dashboard answers.
      - `tags` string[]: meta.tags.
      - `documentedPanelCount` integer: Number of panels that have at least one documentation field populated (`meta.panels[id].purpose` / `intent` / `assumptions` / `notes`). Computed in SQL from the manifest JSONB; non-zero means the dashboard has agent-facing panel docs. Absent (treated as 0) when the dashboard carries no panel provenance at all (ADR-0045 §7).
      - `hasPageDocs` boolean: True when at least one page carries a `doc` object with at least one populated field (`description` / `intent` / `assumptions` / `notes`). Absent when no page has docs. Use `dvt_dashboard_get(view="docs")` to read the full per-page doc bodies (ADR-0045 §7).
    - `certification` Certification: A dashboard's derived certification standing (DVT-5223), computed at read time — dashboards carry no tier column. `tier` is the EFFECTIVE tier (highest-ranked live grant, else draft). `state` and the certified* fields describe the highest-ranked ACTIVE grant, which may be stale or expired; the effective* fields describe the grant that determined `tier`. For headless render actors only `tier` and `state` are returned.
      - `tier` 'draft' | 'published' | 'certified' | 'canonical', required
      - `state` 'live' | 'stale' | 'expired': State of the highest-ranked active grant; absent when there is none.
      - `certifiedTier` 'published' | 'certified' | 'canonical'
      - `domain` string
      - `certifiedBy` string
      - `certifiedAt` string, date-time
      - `certifiedRevisionId` string, uuid
      - `certifiedVersion` integer
      - `editsSince` integer: Revisions written since the certified one.
      - `grantedVia` string
      - `expiresAt` string, date-time
      - `queryRole` string
      - `effectiveDomain` string
      - `effectiveGrantedBy` string
      - `effectiveGrantedAt` string, date-time
      - `effectiveExpiresAt` string, date-time
    - `permissions` DashboardPermissions: Server-computed, per-actor permission hint (DVT-676) on a dashboard: which mutating actions the requesting actor may take, derived from the same authz resolution the enforcing handlers use (capability floor + per-resource ownership/visibility/ACL). An advisory UI hint so clients can show/hide affordances (e.g. a Delete menu row) without re-deriving per-resource ACL grants they cannot see — the server still enforces on every mutating call, so a client MUST NOT treat a true value as authorization. Present on dashboard LIST and GET responses. `canDelete` mirrors `canWrite` today (delete is gated on dashboard:write). `canShare` follows owner-can-share (DVT-665): the owner, org admins, and ACL-admin grantees may share — except in snowflake mode, where a caller whose LIVE tier is viewer gets `canShare: false` even as the owner or an ACL-admin grantee (DVT-3302/DVT-4721: the live tier caps them at viewer rank). Exception: on a Claude connector viewer-tier call the flags are projected to editor-tier enforcement (the tier writes run at) and `requiresConnectorTier` is set; see that field.
      - `canWrite` boolean, required
      - `canDelete` boolean, required
      - `canShare` boolean, required
      - `requiresConnectorTier` 'editor': Present (as "editor") only on a Claude connector viewer-tier call (DVT-4942): the flags above were projected to what an editor-tier connector call would be allowed, since writes run at that tier. The write is additionally refused (tier_insufficient) if the caller lacks the dvt_editor app role, which dvt-api cannot observe from a viewer-tier call.
    - `lastViewedAt` string, date-time: The calling user's own most recent view of this dashboard (DVT-1407), sourced from the dashboard_views telemetry table. Absent when the caller has never viewed this dashboard, or when there is no authenticated caller to scope by.
    - `favoritedAt` string, date-time: DVT-5306. When the caller starred this dashboard. Set only on GET /v1/me/favorites results; absent everywhere else.
    - `viewCount` integer: DVT-5306. The caller's own human views of this dashboard in the org over the last 30 days (`lastViewedAt` is all-time). Set only on GET /v1/me/favorites results; omitted when 0 and everywhere else.
    - `accessViaFolderId` string, uuid: Set on LIST results (DVT-1421) when the caller's access to this dashboard resolves via a folder-inherited ACL grant rather than ownership, an org-admin bypass, or a direct/group grant of equal or higher privilege. An advisory "via folder" hint for the UI — never authoritative, and never present for the owner or an org admin/owner (neither needs the badge). Omitted otherwise.
    - `creatorName` string: Display label for ownerId (DVT-1418): the owning user's display_name, else email, else "system". Absent only if the owner row is somehow unresolvable.
    - `lastEditedBy` string: Display label for the most recent create/update edit against this dashboard (DVT-1418), sourced from the latest matching audit_logs row for this org. For an agent-attributed edit, the API-key name or MCP client id (DVT-1196 "via {key}" convention) when present, else the editor's own display_name/email/ "system" fallback. Absent when the dashboard has no create/update audit history (e.g. pre-audit legacy rows).
    - `lastEditedByType` 'user' | 'agent': The actor_type of the audit_logs row behind lastEditedBy (DVT-1418), clamped to `user` | `agent` (audit_logs also allows `system`/`render`, folded into `user` here). Absent when lastEditedBy is absent.
    - `lastViewedBy` string: Display label (display_name/email/"system") of whoever — any actor, any type, not just the calling viewer — most recently viewed this dashboard org-wide (DVT-1418), sourced from the dashboard_views telemetry table. Distinct from `lastViewedAt` above (DVT-1407's caller-scoped read receipt). Absent when the dashboard has never been viewed by anyone in this org.
    - `lastViewedByAt` string, date-time: Timestamp paired with lastViewedBy above (DVT-1418). Named distinctly from `lastViewedAt` (DVT-1407, caller-scoped) to avoid colliding with that already-shipped, differently-scoped field. Absent when lastViewedBy is absent.
  - `meta` Meta, required: Response metadata.
    - `requestId` string, required: Correlates with the X-Request-Id header.
    - `total` integer: Item count, present on collection responses.
    - `nextCursor` string, nullable: Opaque keyset-pagination cursor, present on paginated responses (e.g. GET /v1/dashboards) when more rows exist beyond this page. Pass it back as the `cursor` query param to fetch the next page; absent or null on the last page.
    - `roleResolveDegraded` boolean: Snowflake mode only (DVT-2260), on GET /v1/dashboards: present and true when the caller's live Snowflake role resolution failed or was unavailable — role-shared dashboards may be missing from this page. Omitted (not merely false) whenever resolution succeeded or the role-share arm does not apply (off snowflake mode, an org admin/owner, or off the ingress listener).

## Other responses

- `201`: Dashboard created.
- `400`: Malformed request, or a `python` panel this write seam refused (ADR-0084 D2/I7). Distinguished by the Problem `type`; every python refusal also echoes its slug in the `X-Dvt-Error-Code` response header: - `https://docs.dvt.dev/errors/bad-request` (the default) — a malformed or otherwise invalid request body. - `https://docs.dvt.dev/errors/python-source-unknown` — a python panel's `data.sourceId` names no live connection in this organization. A connection in ANOTHER organization reads identically, by design. List the ones you can use with `GET /v1/connections`. - `https://docs.dvt.dev/errors/python-source-not-snowflake` — the panel's source resolves but is not a Snowflake connection. A python panel executes as an anonymous procedure inside Snowflake and has nowhere else to run; express the panel as SQL against the current source instead. - `https://docs.dvt.dev/errors/python-source-not-pushdown` — the source is Snowflake but a `writeback`-kind connection (a Sheets flush target, not a queryable source). Point `data.sourceId` at a pushdown connection. Only a write that ADDS or CHANGES a python panel is checked: a title-, layout-, or geometry-only edit of one already stored is an ordinary dashboard write, and deleting one is never an authoring act.
- `401`: Missing, invalid, or expired session.
- `403`: The caller's role does not permit this action, or a `python` panel this write seam refused on identity grounds (ADR-0084 D2/I7). Distinguished by the Problem `type`: - `https://docs.dvt.dev/errors/forbidden` — the caller's role does not permit this action; for a python panel, it lacks the `python:author` capability (the editor floor), or an API key's scopes exclude it. - `https://docs.dvt.dev/errors/not-in-edition` — python panels are not available on this deployment at all (`editionCapabilities.pythonPanels` is false). **The 1.0.20 Snowflake native app ships `PYTHON_PANELS_ENABLED="true"` as of DVT-4453** (risk accepted 2026-09-19; ADR-0084 H5 remains a hard gate for the next cut, not a closed item), so this response is no longer expected there by default. On the native app the literal is baked into the package at build time, so it would fire again only after a provider-side package patch from dvt turns it back off — a consumer install cannot flip it itself; the in-account lever is the `python:author` editor floor via the app roles. On Gallery and Enterprise it fires whenever the operator's own env literal is off. Same shape as the linked-Sheet cell gate's refusal. Use a SQL-backed panel, or ask your dvt operator. - `https://docs.dvt.dev/errors/python-author-requires-admin` — the panel's source is a SHARED-credential connection (any `service_*` auth method, the internal `platform_managed` one, or `cloud_iam`, which is the server's own ambient workload identity), so the authored code would run under a credential no viewer holds: authoring it requires the admin/owner rank, over and above `python:author`. Also echoed in `X-Dvt-Error-Code`. Only the two caller's-rights methods keep the editor floor — `oauth_passthrough` and `spcs_token`, each of which opens the warehouse session as the acting user.
- `409`: Dashboard id already exists. Returned only when the existing spec differs from the one submitted (or the existing dashboard is not visible in this org) — an identical replay of the same id and spec returns 200 instead (see `POST /v1/dashboards`).
- `422`: The spec failed JSON Schema validation. `fields` lists each violation.

---

[API](https://skmtc.dev/dvt/apis/dvt-rest-api.md) · [All operations](https://skmtc.dev/dvt/apis/dvt-rest-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dvt/apis/dvt-rest-api/revisions/00016f504165?raw)
