---
title: "Create a Sheet definition."
method: POST
path: "/v1/sheets"
tags: ["sheets"]
---

# Create a Sheet definition.

`POST /v1/sheets`

## Request

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

Creates a root-level Sheet (ADR-0031). Stores only metadata — no row data.
`kind` must be `standard` (a manual mapping table) — `standard` is the only
creatable kind today. `kind: linked` and any non-empty `seedQuery` are
REJECTED with 400 (DVT-2928): the legacy seed-copy shape (a `linked` sheet
materialized once from `seedQuery`) never had a working execution path and
is retired. `linked` is reserved for a future live-join sheet surface
(DVT-2932) with different semantics — not yet ratified. If `connectionId`
is provided it must reference a
connection with `kind: writeback`; a pushdown connection is rejected with 400
(DVT-904). In the Snowflake Native App, `connectionId` may be omitted — it
auto-binds to the boot-provisioned Host Snowflake Writeback connection
(ADR-0077 r3, DVT-2905), which requires an admin to have provisioned its
in-account `dvt_writeback` database first (Sheets page → Set up writeback).

Changed 2026-08-10: `columns` names must be unique when matched
case-insensitively, and a definition that breaks this is rejected with `400`
(DVT-2476). Declaring both `Title` and `title` was previously accepted and has
no defined meaning: the warehouse's quoted identifiers are case-sensitive, so
the two are distinct names there, while every lookup around them collapses the
pair to a single entry — the server's type check, the engine's projection, and
the warehouse catalog the flush reconciles against.

Depending on the two declared types, the warehouse, and the target table's
current columns, such a sheet either fails every flush that carries an upsert,
or flushes while writing the same submitted value into both columns. Where the
two exist in the table with different types AND both declared types are
accepted against the column dvt matches them to, the outcome is value-dependent:
a flush that succeeded for earlier rows can start failing on a later value that
only one of the two accepts — so rows may already have been written before the
sheet jams — and where both accept it, the two can hold values that differ by
the warehouse's own coercion. Once a flush fails it keeps failing until the
definition is repaired.

A definition that repeats a name exactly, rather than differing only in case,
declares one column twice rather than two columns, and no batch containing an
upsert can ever flush.

(Only Postgres and Snowflake reach these outcomes. A Redshift sheet reaches none
of them: the flusher skips Redshift connections before the table is created, so
nothing is written and nothing fails. Every other warehouse type is rejected
outright by the engine's writeback gate.) Existing sheets are not migrated —
appending to one that is already in this state returns `400` until it is
corrected here or via `PATCH /v1/sheets/{sheetId}`.

## Query parameters

- `reason` string

## Request body

- CreateSheetRequest: `targetSchema`/`targetTable` are read-only and rejected with 400 if sent — see Sheet.targetSchema (DVT-1895).
  - `name` string, required
  - `kind` 'standard' | 'linked', required: 'standard' = manual writeback table, the default. 'linked' (v2, DVT-2932, ADR-0078 D1) creates a live-join sheet bound to a base warehouse relation — requires the admin or owner role (sheet:linked:create; a viewer/editor gets 403), defaults to visibility 'private' and rejects 'public' (org-public linked access is reserved for a future caller's-rights base, DVT-2935), and requires baseConnectionId/baseRelation/primaryKey (see below). The legacy alias 'blank' (pre-DVT-1870) is still accepted and silently normalized to 'standard'. **Edition note (pr-review round-1 fix, H1; wording corrected round-2, A-HIGH-1):** creating kind='linked' additionally requires the `sheetLinkedCreate` edition capability cell (`GET /v1/me` → `editionCapabilities.sheetLinkedCreate`). Gallery ✅; self-host frozen (unverified — DVT-2025); ✅ in the Snowflake Native App edition since DVT-3220 (ADR-0078 D5/R1: app-identity appends, caller's-rights base reads). In snowflake mode the operative gates are per-org and per-request, not the edition cell: the org's `linkedSheetsEnabled` flag (else 400 `linked-base-org-disabled`) and the Snowflake ingress caller token (else 400 `linked-base-caller-token-missing`), ahead of the baseConnectionId validation errors below.
  - `connectionId` string, uuid, nullable
  - `targetDatabase` string: The install's writeback database (DVT_WRITEBACK by default on the native app; `dvt_writeback` elsewhere); any other value is rejected with writeback-database-unsupported (400, `X-Dvt-Error-Code`). Defaults to the install's writeback database when omitted.
  - `columns` object[]: kind='standard' only. For kind='linked', 'columns' is not client-suppliable — it is composed server-side from 'primaryKey' + 'extensionColumns' (primary key columns first, then extensions; the extension set is always derived as columns minus primaryKey).
  - `primaryKey` string[]: Required, non-empty for kind='linked' (400 if empty or omitted) — the base relation's key column names. Each must exist in the base relation, be declared NOT NULL, and have an engine-mappable type (integer, text, uuid, or date); validated server-side at create time against the base connection's live catalog (closes DVT-2543). Immutable for kind='linked' after create — any PATCH attempting to change it is rejected with 400, regardless of whether the sheet has flushed yet (pr-review round-1 fix, H2/H3). Also accepted (optional) for kind='standard' — a rare composite-PK declaration; standard-kind primaryKey is NOT validated against a live catalog and stays mutable via PATCH pre-flush (see PatchSheetRequest.primaryKey). For both kinds, no entry may use the reserved '_dvt_' prefix (dvt-managed columns) — rejected with 400, same rule as 'extensionColumns' below.
  - `seedQuery` string, nullable: RETIRED (DVT-2928). A non-empty value is rejected with 400 — seed-copy linked sheets are no longer supported. Omit it.
  - `visibility` 'private' | 'shared' | 'public': Defaults to 'private'. For kind='linked', 'public' is rejected with 400 — only 'private' (default) or 'shared' are allowed (ADR-0078 D1/sec-M1).
  - `description` string, nullable
  - `baseConnectionId` string, uuid: kind='linked' only; required. The pushdown-kind connection to read the base relation through (GET /v1/connections). Must not be an oauth_passthrough connection, must have the same warehouse account/host as this request's 'connectionId' (the writeback connection), and must have been opted in for linked-sheet base reads by an admin (PATCH /v1/connections/{id} {"linkedBaseOptIn":true}) — each is a distinct typed 400 otherwise. Ignored for kind='standard'.
  - `baseRelation` object: kind='linked' only; required. The base relation's TYPED identifier parts — never a dotted FQN string (e.g. never "ANALYTICS.PUBLIC.ORDERS"). Each part must be a simple identifier (letters, digits, `_` and `$`; 1–255 chars). Ignored for kind='standard'.
    - `database` string: Optional — resolves to the base connection's default database when omitted.
    - `schema` string, required
    - `table` string, required
  - `extensionColumns` object[]: kind='linked' only; optional (zero extension columns is legal). Additional columns this sheet may write via the row-write API, layered onto the base relation's primaryKey — {name,type} pairs in the same vocabulary as 'columns'. Names must be identifier-safe, non-empty type, no duplicates, no overlap with 'primaryKey', and may not use the reserved '_dvt_' prefix (dvt-managed columns). Ignored for kind='standard'.
    - `name` string, required
    - `type` string, required

## Response `201`

Sheet created.

- SheetEnvelope
  - `data` Sheet, required: A dvt Sheet definition (ADR-0031): a root-level, org-scoped, versioned warehouse- writeback object. Metadata only — the row data lives in the customer's warehouse (the dvt_writeback namespace), never in dvt, so this object has no row-data field.
    - `id` string, uuid, required
    - `folderId` string, uuid, nullable, required: The containing library folder, or null when unfoldered (DVT-1563). Set via PUT /v1/sheets/{id}/folder. Only the sheet's owner (or an org admin/owner) may move it between folders.
    - `ownerId` string, uuid, required: The user who created the sheet.
    - `name` string, required
    - `kind` 'standard' | 'linked', required: 'standard' = manual mapping table, freely editable via the row-write API. 'linked' (v2, DVT-2932, ADR-0078 D1) = a live-join sheet bound at create time to a base warehouse relation (baseConnectionId/baseRelation): row-write is restricted to the declared extensionColumns — every edit must still SEND a value for every primaryKey column (required, non-null; they identify which base row the edit applies to), but primaryKey values are not editable content, only the declared extensionColumns are (pr-review round-1 fix, L4: earlier wording said PK values come "never from the client", which described what's writable, not what's sent) — and creation requires the admin or owner role (sheet:linked:create). The legacy seed-copy shape ('linked' materialized once from seedQuery) was retired (DVT-2928/0091) and never shared this behavior — existing pre-DVT-2932 'linked' rows are unaffected.
    - `connectionId` string, uuid, nullable, required: The writeback connection (a connection id). Null until wired. DVT-904 enforces at bind time (POST/PATCH) that this must reference a connection with kind 'writeback'; a pushdown connection is rejected with 400. The append path re-asserts this as defense in depth (409) before sealing WAL entries.
    - `targetDatabase` string, required: The dvt-managed writeback namespace: the install's writeback database (DVT_WRITEBACK by default on the native app; `dvt_writeback` elsewhere). dvt never writes outside it. Any other value is rejected on create/update with writeback-database-unsupported.
    - `targetSchema` string, nullable, required: Read-only, server-managed (DVT-1895): derived from the sheet's folder path and pinned write-once at first flush. Null until the sheet has flushed. Never settable via POST/PATCH — a client-supplied value is rejected with 400.
    - `targetTable` string, nullable, required: Read-only, server-managed (DVT-1895): derived from the sheet's name (and folder subpath) and pinned write-once at first flush. Null until the sheet has flushed. Never settable via POST/PATCH — a client-supplied value is rejected with 400.
    - `columns` object[], required: Declared column definitions. Reconciled against the live catalog at write time (ADR-0031 D1a).
    - `primaryKey` string[], required: Declared primary key / composite PK (column names) for linked sheets. Empty for standard.
    - `seedQuery` string, nullable, required: RETIRED (DVT-2928): the seed SQL a legacy seed-copy 'linked' sheet was materialized from. Never executed at write time — nothing ever ran this query. A non-empty seedQuery is rejected with 400 on POST/PATCH; new sheets can never have one. Only present (non-null) on a 'linked' sheet row created before this retirement.
    - `visibility` 'private' | 'shared' | 'public', required
    - `version` integer, required: Optimistic-concurrency version. Pass this back on PATCH.
    - `schemaVersion` integer, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `access` object: The CALLER's access to this sheet (ADR-0091 D3.1). Present only on GET /v1/sheets/{id}; omitted on list/create/patch. UX-only: the server enforces on every request and never reads this from a client. `read` is always true on a 200 (a non-reader gets 404). `write` is true when the caller passes the same checks as a write (sheet:write capability, per- resource ACL, owner, admin bypass, Snowflake live-tier viewer cap).
      - `read` boolean, required
      - `write` boolean, required
    - `rowCount` integer, nullable: Best-effort COUNT(*) observed against the warehouse target at the sheet's most recent successful WAL flush (DVT-1598). Null until the first flush, or when the observation attempt failed — never fails the flush itself. An observation, not stored row data (ADR-0011).
    - `description` string, nullable: Freeform markdown authoring notes about this sheet (DVT-1865 Description tab) — author-written text, never customer row data (ADR-0011). Null until an editor sets one via PATCH.
    - `baseConnectionId` string, uuid, nullable, required: kind='linked' v2 only (DVT-2932, ADR-0078 D1). The pushdown-kind connection this linked sheet reads its base relation through. Null for kind='standard'. Set once at create (POST /v1/sheets) and immutable thereafter — a PATCH that includes this field is rejected with 400; changing a linked sheet's base connection is a delete-and-recreate operation, not an update.
    - `baseRelation` object, nullable: kind='linked' v2 only (DVT-2932, ADR-0078 D1/D3). The base relation's TYPED identifier parts — never a dotted FQN string. Absent for kind='standard'. Set once at create and immutable thereafter (same PATCH-rejection rule as baseConnectionId).
      - `database` string: Optional — resolves to the base connection's default database when omitted.
      - `schema` string
      - `table` string
    - `baseSchemaFingerprint` object, nullable: kind='linked' v2 only (DVT-2932). A create-time snapshot of the base relation's column shape, captured from the engine's describe result and sorted by column name — the input to the DVT-2931 reconcile-on-read drift check (not yet implemented; this slice only captures the snapshot). Absent for kind='standard'.
      - `cols` object[]
        - `name` string
        - `dataType` string: Warehouse-native type string as reported by the engine describe call, e.g. 'NUMBER(38,0)' or 'character varying'.
        - `nullable` boolean
  - `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

- `400`: Malformed request.
- `401`: Missing, invalid, or expired session.
- `403`: The caller's role does not permit this action.
- `409`: A sheet with this name already exists in the organization.
- `429`: Two causes, distinguished by the Problem `type` (DVT-3464 split this from a single shared `errors/rate-limited`, which made an engine-capacity shed indistinguishable from an actual rate limit): - `https://docs.dvt.dev/errors/rate-limited` — a rate or concurrency limiter for this operation rejected the request before it reached the engine. See `components/responses/RateLimited` for the general per-actor SEC-2 tier; some operations stack an additional operation-specific limiter (the query submit rate limit, the per-org running-job cap, or the trial query ceiling). - `https://docs.dvt.dev/errors/engine-at-capacity` — the engine's own admission control (ADR-0053) shed the request before it reached the warehouse — a dvt engine limit, not a warehouse or rate problem (DVT-765). `suggestion` names the actual wait in seconds and, when the engine reports a scope, whether the per-org or global engine concurrency cap was hit. Both carry `Retry-After` (seconds); back off for at least that long before retrying.

---

[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)
