---
title: "Create a new board"
method: POST
path: "/api/boards"
tags: ["Boards"]
---

# Create a new board

`POST /api/boards`

## Request

- Base URL: `https://cnvs.app`
- URL: `https://cnvs.app/api/boards`
- Auth: none declared

Allocates a fresh UUID and returns a board immediately reachable at `https://cnvs.app/#{id}`. A bare POST creates a `draw` board. Optional `{mode:"todo", template}` creates a kanban board with `kanban`, `sprint`, or `bugs` starter columns.

**Atomic board import**: optional `content`, `autolayout`, `lock`, and `author` pre-fill and optionally PIN-lock the board in the same transaction; see `BoardImportContent`. The combined content arrays accept at most **2550 entries**, lane titles at most **10**, and the D1 batch at most **320 statements**. Template columns do not count as input. In practice the 5 MB request-body cap (`/quotas.json` → `perRequest.maxBodyBytes`) is the binding limit for image-heavy imports, long before 2550 entries. Each lane index may be named at most once (a duplicate is `400 invalid_item` on the second entry, not a last-write-wins collapse), and `tasks[].done` must be a real boolean. All content is validated first; any invalid item or quota error creates nothing. Draw items require draw mode; columns/tasks/lanes require todo mode. A response containing `imported` confirms support.

**Autolayout** fills missing draw-item coordinates deterministically. **Lock at create** returns the plaintext 8-char `access_key` once. **Author** is 1–80 chars and defaults to `ai:import`; every item may also carry its OWN `author`, which is preserved as the item's creator instead of being overwritten by the importer's tag. **Item ids are always server-minted** and returned in `ids` (per kind, in input order); declare a write-only `sourceId` on texts/images to have `lines[].anchors` endpoints remapped onto the minted ids. Embed readable boards at `https://cnvs.app/?embed=1#{id}`.

**Rate tiers**: standard callers get 5 creates/60s/IP. A valid server-to-server `X-Import-Token` selects 50 creates/60s/infrastructure token and 600 REST/MCP requests/10s/board. WebSockets remain 60/10s/board and lock/unlock/verify-key remain 5/60s/IP. The token never bypasses board access locks, import/field/operation/storage/body limits, validation, or Cloudflare platform limits. Invalid or unconfigured tokens return `403 invalid_import_token`.

## Headers

- `X-Import-Token` string

## Request body

- object
  - `mode` 'draw' | 'todo': Initial board mode. Defaults to `draw`.
  - `template` 'kanban' | 'sprint' | 'bugs': Starter column set when `mode` is `todo` and `content.columns` is empty/absent. Defaults to `kanban`.
  - `content` BoardImportContent: Initial board content applied atomically at create time (`POST /api/boards` `content` field / MCP `create_board`). The combined input arrays may contain at most 2550 entries (counted exactly as supplied — entries are never merged); template-seeded columns do not count as input. The D1 batch is capped at 320 statements (multi-row INSERTs sized against D1's 100-bound-parameters-per-statement limit). In practice the 5 MB request-body cap bites first on image-heavy imports, well before 2550 entries. **Per-item `author`.** Every item in `texts` / `lines` / `images` / `columns` / `tasks` / `lanes` accepts an optional `author` (1–80 chars of `[A-Za-z0-9:_\-.]`, trimmed — the same rule as the REST/WS paths). Absent or null inherits the body-level `author` (default `ai:import`); an invalid value is `400 {error:"invalid_item", kind, index, reason}`. This exists because an item's `author` is its CREATOR and is never rewritten, so restoring someone else's board must be able to carry the original authorship instead of stamping everything as the importer. **`sourceId` (texts and images).** Optional string, 1–128 chars, unique across the whole import. WRITE-ONLY: it is never stored and never becomes the row id — item ids are always server-minted and returned in the response's `ids`. Its only effect is anchor resolution: a `lines[i].anchors.start.id` / `.end.id` matching a declared `sourceId` is rewritten to that item's minted id. An anchor id matching nothing is left verbatim and renders as a free stroke end. **Strict value checks.** `tasks[].done` must be a real boolean (or null/omitted); a truthy stand-in like `"false"` is `400 {error:"invalid_item", kind:"tasks", index, reason}` rather than being coerced into a COMPLETED card. `lanes` may name each lane index at most once; a second entry for the same index is `400 {error:"invalid_item", kind:"lanes", index, reason}` on that second entry, since collapsing it would overwrite the first entry's `author`. Draw kinds (texts/lines/images) require draw mode; kanban kinds (columns/tasks/lanes/colWidth) require `mode: "todo"` — a mismatch is `400 content_mode_mismatch`. All standard per-board quotas also apply (live values in /quotas.json).
    - `texts` object[]
      - `x` number: Optional with `autolayout: true`, required otherwise.
      - `y` number: Optional with `autolayout: true`, required otherwise.
      - `content` string, required: Required, non-empty. cnvs markup + Mermaid supported.
      - `color` string: Named ink only (red/blue/green/…); anything else clamps to auto.
      - `width` number: Explicit width in px (160–4096).
      - `postit` boolean
      - `diagram` boolean
      - `kind` 'text' | 'link': `link` renders the content as a URL capsule. Defaults to `text`.
      - `author` string: Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\-.]`). Omit to inherit the body-level `author`.
      - `sourceId` string: Optional caller-side handle, unique across the import. Write-only: never stored, never the row id — it only lets `lines[].anchors` endpoints be remapped to this text's server-minted id.
    - `lines` object[]: Freehand strokes. `strokes` is accepted as an alias key (when both are present, `lines` wins).
      - `points` unknown, required
      - `color` string
      - `anchors` unknown
      - `author` string: Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\-.]`). Omit to inherit the body-level `author`.
    - `images` object[]
      - `x` number: Optional with `autolayout: true`, required otherwise.
      - `y` number: Optional with `autolayout: true`, required otherwise.
      - `dataUrl` string, required: `data:image/(png|jpeg|gif|webp|svg+xml);base64,...`, ≤ ~900 kB. Required — hosted URLs are not accepted.
      - `width` number, required
      - `height` number, required
      - `thumbDataUrl` string: Optional ≤8 kB raster thumbnail for SVG previews.
      - `author` string: Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\-.]`). Omit to inherit the body-level `author`.
      - `sourceId` string: Optional caller-side handle, unique across the import. Write-only: never stored, never the row id — it only lets `lines[].anchors` endpoints be remapped to this image's server-minted id.
    - `columns` object[]: Todo mode. Array order = sort order. When empty/absent on a todo board, the `template` columns are seeded instead (and `tasks[].columnIndex` indexes into those).
      - `title` string, required
      - `lane` integer: Row index (≥ 0). Defaults to 0.
      - `color` 'red' | 'blue' | 'green': Optional title color; anything else → default.
      - `author` string: Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\-.]`). Omit to inherit the body-level `author`.
    - `tasks` object[]
      - `columnIndex` integer, required: Index into `content.columns` (or the template-seeded columns when `content.columns` is empty/absent).
      - `name` string, required
      - `description` string: Stored inside the task's opaque `content` JSON, same as the UI.
      - `due_date` string: ISO 8601 date.
      - `priority` 'H' | 'M' | 'L'
      - `assignee` string
      - `done` boolean: Completion flag. Must be a REAL boolean — a truthy stand-in such as the string `"false"` or `0` is rejected `400 {error:"invalid_item", kind:"tasks", index, reason:"done must be true or false (or omitted)"}` and nothing is created (coercing `"false"` would persist the card as COMPLETED). `null` or omitted means not done.
      - `color` string: Card color, stored inside the task's opaque `content` JSON alongside `description`.
      - `author` string: Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\-.]`). Omit to inherit the body-level `author`.
    - `lanes` object[]: Row (lane) titles for multi-row kanban layouts. At most 10 entries; each `lane` must be used by a column created in this request. Each lane index may appear AT MOST ONCE — a second entry for the same `lane` is rejected `400 {error:"invalid_item", kind:"lanes", index, reason}` (the `index` is that of the SECOND entry) rather than collapsing last-write-wins, because collapsing would let the later entry overwrite the first namer's `author`, and an item's author is its creator and is never rewritten. Every entry still counts separately toward both the 10-lane-title and 2550-total-input-entry limits, and the 10-entry quota error is reported before the duplicate check. Lanes get no id — they are keyed by `(board_id, lane)`, so they have no entry in the response's `ids`.
      - `lane` integer, required: Row index (≥ 0).
      - `title` string, required
      - `author` string: Optional per-item creator tag (1–80 chars of `[A-Za-z0-9:_\-.]`). Omit to inherit the body-level `author`.
    - `colWidth` number: Shared kanban column width in px, clamped to [200, 480].
  - `autolayout` boolean: Draw mode only: deterministically place texts/images that omit x/y (masonry grid; explicit coordinates are never touched). Without it, items missing coordinates are rejected `400 invalid_item`. No-op in todo mode.
  - `lock` 'write' | 'all': PIN-lock the board atomically at create time. `write` = public reads, key-holders write; `all` = key required for everything. The plaintext key is returned ONCE as `access_key` — there is no recovery.
  - `author` string: Board-level author tag, inherited by every created item that does not carry its own `author` (e.g. `ai:plai`). 1-80 chars of `[A-Za-z0-9:_\-.]`. Defaults to `ai:import`. Per-item `author` wins where present — see `BoardImportContent`.

## Response `200`

New board created.

- CreateBoardResponse
  - `id` string, required: Freshly allocated board UUID. Open at `https://cnvs.app/#{id}` — or `https://cnvs.app/?embed=1#{id}` for the minimal-chrome iframe embed view.
  - `mode` 'draw' | 'todo', required: The created board's mode.
  - `columns` Column[]: Todo boards only: the created columns (from `content.columns`, or the template seed).
    - `id` string, required
    - `title` string, required
    - `sort` number: Left-to-right order of the column.
    - `lane` integer: Row index for multi-lane layout. Defaults to 0 (top row).
    - `color` 'red' | 'blue' | 'green' | 'null', nullable: Column title color — one of `red` / `blue` / `green`, or null for the default.
    - `author` string, nullable
    - `last_updated` string: UTC timestamp.
  - `imported` object: Present whenever `content` was provided (even all-zero) — the feature-detection signal for board import. Per-kind counts of rows created FROM `content`; template-seeded columns are NOT counted.
    - `texts` integer
    - `lines` integer
    - `images` integer
    - `columns` integer
    - `tasks` integer
    - `lanes` integer
  - `ids` object: Present whenever `content` was provided, alongside `imported`. The server-minted ids for the imported rows: index `i` of each array is the id created for `content.<kind>[i]`, in the order the caller supplied them. Item ids are ALWAYS minted server-side — a caller may not choose them (ids are globally unique and the item write paths upsert on id, so an honoured caller id could overwrite rows on another board). `columns` is `[]` when the columns came from the `template` seed (those are already returned in full in `columns`). There is no `lanes` array — lane rows have no synthetic id, they are keyed by `(board_id, lane)`.
    - `texts` string[]
    - `lines` string[]
    - `images` string[]
    - `columns` string[]
    - `tasks` string[]
  - `access_key` string: Only when `lock` was requested: the plaintext board key (8 chars a-z0-9), returned ONCE. There is no recovery.

## Other responses

- `400`: Import validation failed — NOTHING was created (not even the board row). `{error: "invalid_item", kind, index, reason}` names the first offending item; `{error: "quota_exceeded", kind, reason}` a quota the content would exceed; `{error: "content_mode_mismatch"}` a content/mode disagreement; `{error: "invalid_content", field, reason}` a malformed top-level field (content / lock / author / colWidth). Two `invalid_item` cases are easy to hit when replaying a hand-edited or third-party file: a `tasks[].done` that is not a real boolean (a coerced `"false"` would be stored as COMPLETED), and a `lanes` array that names the same lane index twice (collapsing it would overwrite the first entry's `author`).
- `401`: Board is access-locked and the request did not carry a valid key. `lockMode` tells the client whether to prompt for a code or just disable write UI.
- `403`: An `X-Import-Token` header was presented but does not match the `IMPORT_TOKEN` secret (or the secret is unset). Distinct from the missing-header path, which is simply rate-limited.
- `429`: Rate limit exceeded. Standard REST/MCP: 60 requests/10s/board; valid X-Import-Token infrastructure tier: 600/10s/board; browser WebSocket always 60/10s/board. REST/MCP enforcement is soft per-isolate; WebSocket enforcement is strong per Durable Object.

---

[API](https://skmtc.dev/cnvs/apis/cnvs-app-http-api.md) · [All operations](https://skmtc.dev/cnvs/apis/cnvs-app-http-api/llms.txt) · [OpenAPI document](https://skmtc.dev/cnvs/apis/cnvs-app-http-api/revisions/c8f2c3b7ff90?raw)
