---
title: "Append row edits to a Sheet's WAL."
method: POST
path: "/v1/sheets/{id}/rows"
tags: ["sheets"]
---

# Append row edits to a Sheet's WAL.

`POST /v1/sheets/{id}/rows`

## Request

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

Appends a batch of row edits (upserts and tombstones) onto the sheet's
write-ahead log (ADR-0031 D4 / DVT-214). Upsert payloads are sealed via
the engine broker before storage — dvt's metadata DB stores only ciphertext
(ADR-0012). Requires `sheet:write` and the sheet must have a writeback
`connectionId` set — auto-bound at create time in the Snowflake Native App
(ADR-0077 r2, DVT-2905), declared explicitly elsewhere. At most 1000 edits per
request.

Each upsert value is checked against its column's declared type before the
edit is accepted (DVT-2476). A value the target warehouse could not store is
rejected here with `400` rather than written to the WAL, where it would
otherwise block every subsequent flush of the sheet.

Values are checked only against the columns declared on the sheet AT THE
TIME OF THIS REQUEST. A value whose key matches no declared column is not
checked here; if that column is still undeclared at flush time the value is
dropped, but if it is declared in the meantime (via
`PATCH /v1/sheets/{sheetId}`) the value is flushed and checked then — and a
value that fails at flush blocks every subsequent flush of the sheet. This
endpoint cannot re-check edits already in the write-ahead log: their
payloads are sealed (ADR-0012) and only the engine can read them. Declare a
sheet's columns before appending rows to it.

Changed 2026-08-09: values that this endpoint previously accepted and that
then failed later at flush are now rejected with `400` at append time. All
of the following were accepted here before and are not any more — existing
integrations sending any of them will start receiving `400`:

- a minute-precision time carrying any zone (`2024-01-15T10:30Z`,
  `2024-01-15 10:30+05:30`, `2024-01-15T10:30-05`) — a zone requires
  SECONDS precision;
- a named zone after a `T`-separated time (`2024-01-15T10:30:00 UTC`) —
  `UTC`/`GMT` are accepted only after a space-separated time;
- named zones other than `UTC`/`GMT` in any position (`… PST`, `… CEST`);
- slash-form (`01/15/2024`) and month-name (`Jan 15 2024`) dates;
- an edit whose `values` is `null` (`{"op":"upsert","values":null}`). This
  was accepted with `200` and sealed an empty edit into the write-ahead log.
  `values` has always been declared as an object and never as `nullable`, so
  this is a conformance fix rather than a narrowing of the contract — but it
  is still an observable `200`→`400` for anything relying on it. An OMITTED
  `values` is likewise rejected; an EMPTY object (`{}`) remains valid and
  writes a row whose declared columns are all `null`.

The enumeration below is the authoritative accepted set; this list names the
previously-working forms that fall outside it.

Changed 2026-08-10: an append to a sheet whose declared columns are not
unique when matched case-insensitively is rejected with `400`. Such a
definition has no defined meaning, and it could previously be created, so an
existing sheet may be in this state. `POST /v1/sheets` describes what these
sheets do at flush time, per warehouse, per declared-type combination, and per
the target table's current columns; it ranges from the flush failing on every
attempt, through the submitted value being silently written into both colliding
columns, to a sheet that flushes until a later value only one of them accepts
jams it — with the rows written before that point already in the warehouse — to
the two silently holding values that differ by the warehouse's own coercion.
The rejection here applies to any
append, including a tombstone-only batch, because the sheet's definition is
what is ambiguous. `PATCH /v1/sheets/{sheetId}` to keep only one of the
colliding columns, then retry.

A `date`/`timestamp` value must be one of the following spellings exactly
(this is the complete list, not a grammar — combinations not listed here,
such as a minute-precision time with a zone, are rejected). The enumeration
is verified against Postgres and Redshift. Snowflake's `AUTO` input format
is documented to accept a superset, but that has NOT been verified against a
live Snowflake — treat this list as the forms dvt validates, not as a
guarantee about Snowflake's own parsing:

- `2024-01-15` — date only
- `20240115` — date only, ISO 8601 basic
- `2024-1-5` — date only, unpadded
- `2024-01-15T10:30` / `2024-01-15 10:30` — minute precision, no zone
- `2024-01-15T10:30:00` / `2024-01-15 10:30:00` — seconds, no zone
- `2024-01-15T10:30:00Z` / `2024-01-15 10:30:00Z` — `Z` (UTC) zone, after
  either separator; and any RFC 3339 value, with or without fractional
  seconds
- `2024-01-15 10:30:00+05:30` — space-separated, `±HH:MM` zone
- `2024-01-15T10:30:00+05` / `2024-01-15 10:30:00+05` — `±HH` zone
- `2024-01-15T10:30:00+0530` / `2024-01-15 10:30:00+0530` — `±HHMM` zone
- `2024-01-15 10:30:00 UTC` — seconds, named zone (`UTC` or `GMT` only)

Slash-separated and month-name forms (`01/15/2024`, `Jan 15, 2024`) are
**not** accepted even though the warehouse would take them: their meaning
depends on the warehouse's date-order setting, which would make
`01/02/2024` mean different dates on different deployments. Send ISO-8601.

Any spelling that carries seconds may add a fractional part
(`2024-01-15 10:30:00.5`, `2024-01-15T10:30:00.123+05:30`).

Further restrictions, each because the warehouse rejects the value: named
zones other than `UTC`/`GMT` (send a numeric offset instead — other
abbreviations resolve to different instants depending on the warehouse's
abbreviation set); the comma decimal mark for fractional seconds
(`10:30:00,5` — use `10:30:00.5`); a zone displacement beyond `±15:59`, or
whose minutes field is `60` or greater (e.g. `+00:60`, `+0060`); year
zero; and non-ASCII whitespace padding such as a non-breaking space.

A `number` must fit `NUMBER(38,9)` (at most 29 integer digits, and at most
16383 digits after the point) and an `integer` must fit a signed 64-bit
integer.

Precision: a value sent as a bare JSON **number** is carried as an
IEEE-754 double and is therefore exact only to about 15 significant
digits, with no error reported — on Postgres
`1234567890123456.789` is stored as `1234567890123460` (the exact stored
value differs by warehouse; Snowflake rounds the same input to
`1234567890123456.8`). Send a high-precision `number` as a
JSON **string** (`"1234567890123456.789"`), which is carried verbatim, so
all 29 integer digits survive.

Note that the column is `NUMBER(38,9)` either way: digits past the **ninth
decimal place are silently rounded on every carrier, strings included**
(`"0.1234567895"` stores as `0.123456790`). The string carrier removes the
~15-significant-digit ceiling, not the scale limit.

The range limits above are applied to the value as it will actually be
stored, so a JSON number near a bound may be rejected where the same digits
sent as a string are accepted.

Other per-type rules enforced here:

- `uuid` — at most 36 characters, counted in characters, not bytes.
- `boolean` — a JSON boolean, or one of the strings `true`/`false`/`t`/`f`/
  `yes`/`no`/`y`/`n`/`on`/`off`/`1`/`0` (case-insensitive).
- Every type — a JSON array or object is rejected; values must be scalars
  or `null`.
- `integer` — a bare JSON number with a fractional part is accepted rather
  than rejected on every warehouse, and is ROUNDED when the sheet flushes.
  **On Postgres** the rounding is HALVES TO EVEN:
  `42.5` stores as `42`, and `43.5` stores as `44`.
  That is because the value is bound as an IEEE-754 double and the
  `float8`→`bigint` cast uses `rint()`. It is not the same as a
  `numeric`→`bigint` cast, which rounds halves away from zero (`42.5` would
  give `43`) — `numeric` is not the carrier. The rounding MODE on other
  warehouses is not specified here; only the fact that a fractional value is
  rounded rather than refused holds on every warehouse that flushes. (A
  Redshift sheet never flushes — see `POST /v1/sheets` — so no rounding
  behaviour is observable there, even though the value is accepted.) The
  same value sent as a string (`"42.5"`) is rejected on every warehouse,
  because a string is bound as text and text conversion does not round.
- Keys within one `values` object must not collide case-insensitively
  (`"Title"` and `"title"` together are rejected): columns are matched
  case-insensitively, so the intended value would be ambiguous.

Two rules depend on the sheet's warehouse, so the same request body can be
accepted on one connection and rejected on another:

- a JSON number for a `boolean` column is accepted only on Snowflake;
- the string `"NaN"` (case-insensitive) for a `number` column is accepted
  only on Postgres and Redshift.

## Request body

- AppendSheetRowsRequest: POST /v1/sheets/{id}/rows request body.
  - `version` integer, required: The current sheet definition version (optimistic concurrency). Obtain from the sheet's `version` field; a stale value returns 409.
  - `edits` SheetRowEdit[], required: The batch of row edits to append. 1–1000 entries per request. For a kind='linked' sheet, this 1000 caps INPUT edits, not the resulting write volume: each edit explodes server-side into one cell write per declared extension column it touches (a row upsert setting k extension columns produces k cell writes), and that exploded cell count is independently bounded at 1000 as well — so a linked-sheet batch may need to stay well under 1000 input edits if it sets many extension columns per row.
    - `rowUid` string, uuid, nullable: dvt surrogate row key. Required for tombstone. Optional for upsert — omit to have the server generate a new UUID. If provided for upsert, must be a valid UUID.
    - `op` 'upsert' | 'tombstone', required: 'upsert' writes or replaces the row; 'tombstone' deletes it. For a kind='linked' sheet (DVT-2932), 'tombstone' is not a legal op — a linked sheet's rows are keyed to the base relation, so its lifecycle is owned by the base data, not by client delete; every edit on a linked sheet is rejected with 400 unless op='upsert'.
    - `values` object: The row image for an upsert. Must be a JSON object. Sealed by the engine broker before storage — dvt stores only ciphertext (ADR-0012). Absent for tombstone.

## Response `200`

Edits appended. Returns the assigned seq for each edit and the updated pending stats.

- object
  - `data` AppendSheetRowsResponse, required: Successful result of POST /v1/sheets/{id}/rows.
    - `applied` object[], required: Assigned seq and rowUid for each applied write. For a kind='standard' sheet, parallel to the request's 'edits' array (one entry per input edit). For a kind='linked' sheet, parallel to the EXPLODED per-cell writes instead: a single row upsert touching k declared extension columns produces k entries here, all sharing the same rowUid, with k consecutive seq values — never one entry per input edit in that case.
      - `rowUid` string, uuid, required
      - `seq` integer, required: The WAL sequence number assigned to this edit.
      - `op` 'upsert' | 'tombstone', required
    - `nextSeq` integer, required: The sheet's next_seq value after this batch was appended.
    - `pending` object, required: Unflushed WAL stats after this batch.
      - `entries` integer, required: Number of unflushed WAL entries.
      - `bytes` integer, required: Total payload bytes of unflushed entries.
  - `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.
- `404`: Resource does not exist.
- `409`: Several shapes, distinguished by the Problem `type` (DVT-2905, ADR-0077 r2 D6r): - `https://docs.dvt.dev/errors/version-conflict` — your `version` is stale; re-fetch the sheet and retry. - `https://docs.dvt.dev/errors/writeback-not-provisioned` — snowflake-mode only (`editionCapabilities.spcsWritebackConnection`): the sheet has no bound connection because the in-account `dvt_writeback` database isn't provisioned for this account yet. Ask an admin to provision the dvt_writeback database (Sheets page → Set up writeback), then retry. - `https://docs.dvt.dev/errors/no-writeback-connection` — every other edition's equivalent: the sheet has no writeback connection set (set `connectionId` via PATCH before editing rows). - `https://docs.dvt.dev/errors/not-writeback-connection` — the bound connection is not a writeback-kind connection, or no longer exists (PATCH the sheet with a valid writeback `connectionId` — DVT-904). - `https://docs.dvt.dev/errors/linked-base-org-disabled` — kind='linked' sheets, snowflake mode only: the org-wide `linkedSheetsEnabled` admin kill switch is off (ADR-0078 R1/round-3 H2, DVT-3220; also echoed in the `X-Dvt-Error-Code` response header). Ask an org admin to re-enable it via `PATCH /v1/orgs/{orgId}/settings {"linkedSheetsEnabled":true}`.
- `429`: Too many unflushed edits in this sheet's WAL — retry after the next flush.
- `503`: Sheet row writeback is not configured on this deployment, distinguished by the Problem `type`: - `https://docs.dvt.dev/errors/sealing-unavailable` — snowflake-mode only (`editionCapabilities.spcsWritebackConnection`): the engine's row-payload broker/`CONNECTION_KEK` secret is not configured. - `https://docs.dvt.dev/errors/unavailable` — every other edition's equivalent (`CONNECTION_KEK` not set).

---

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