Writing content
Creating and updating cards, what validation rejects, editing many at once, and imports you can safely run twice.
Nothing is reserved for the interface. Every write the app performs is a route an API key can call.
Creating a card
POST /workspaces/{workspaceId}/collections/{collectionId}/items{
"title": "Release notes, 2.4",
"blocks": { "blocks": [ /* … */ ] },
"statusId": "<statusId>",
"parentId": null,
"properties": { "<fieldKey>": "value" },
"afterItemId": null
}Only title is required. The collection you post to becomes the card's home
collection, and the API composes its readable key from that list's prefix and
the card's number.
Two ways to name a property, and they are not the same
properties is keyed by a field's key — its stable short name. A
view definition, meanwhile, references a field by
fieldId, its UUID. Both are on the Field object the collection returns;
mixing them up gets you a 400 saying the property is unknown, which is at least
a fast failure.
afterItemId places the card in the manual order; omit it and the card lands at
the end.
Updating a card
PATCH /workspaces/{workspaceId}/items/{itemId}properties is merged, not replaced. Patching one property leaves the others
alone — which is what you want from a PATCH, and what makes it safe for two
clients to edit different properties of the same card.
archived: true takes a card out of every view and out of search, reversibly.
DELETE is permanent and takes the card's subtree with it.
Bodies: never both
Send blocks or body, not both.
blocks sets the document and recomputes the text projection. body sets the
text and puts the document back to being derived from it. See
page bodies.
What validation rejects
Property values are checked against the registry when written. A 400 means the value never landed anywhere.
- a key that is not in the registry — properties are declared before they are used, never invented by a write;
- a value of the wrong type for its property;
- a
selectormulti_selectvalue outside the declaredoptions, when options are declared; - a
personormulti_personthat is not a member of this workspace; - a
filethat is not a file of this workspace; - a
urlthat is not an absolutehttp,httpsormailtoURL; - a block document that breaks the format — an unknown block type, a mark that isn't one of the five, an indent out of range.
Why this cannot be lenient
A badly typed value does not break its own card. It breaks the whole query of every view that casts that property — one bad number makes an entire list fail to load, for everyone who opens it. There is no read-time recovery that would be better than refusing the write.
Moving cards around
POST /workspaces/{workspaceId}/items/{itemId}/move
POST /workspaces/{workspaceId}/items/{itemId}/collections
DELETE /workspaces/{workspaceId}/items/{itemId}/collections/{collectionId}move changes a card's place — its parent, its order, its home list.
The other two attach and detach a card from secondary lists: the same card appearing in a second place, not a copy. Its home collection, and therefore its key, does not change.
Duplicating a card
POST /workspaces/{workspaceId}/items/{itemId}/duplicateNo request body. The copy is returned in full, and it lands immediately after the original in the manual order — not at the end of the list, which on a list of four thousand cards is the same as nowhere.
It takes the card's subtree with it, mirroring DELETE: sub-tasks are recreated
under the copy, each with a number of its own, and re-parented to it. Archived
sub-tasks are left where they are — a copy is born unarchived, so copying them
would put back in a view exactly what someone took out of it.
What travels: title (suffixed (copie) on the root only), icon, status, body
(blocks and text, exactly as stored), properties, and secondary list
memberships. What does not:
- the number, and therefore the key.
SPR-12is never shared between two cards; externalSource/externalId. The copy did not come from the import the original came from, and that pair is a unique idempotency key;archivedAt, which startsnull;createdBy, which is the caller — the same rule as every other creation.
Properties are copied as stored, without being re-validated against the
registry. They passed it once, on the way in; re-checking would refuse to copy a
card the API is perfectly willing to show you, because a select option was
retired in the meantime.
One item.created event per card copied. The root's carries an extra
duplicatedFrom key holding the source id — the only trace of the link, since
the copy itself keeps no reference to its original.
Past 200 cards in the subtree, the route refuses with a 400 rather than holding
a transaction open over hundreds of inserts. Renaming the copy is a PATCH.
Editing many at once
POST …/bulk-preview { definition }
POST …/bulk-update { definition, patch, expectedCount }The selection is a view definition — the same filter the user was looking at, compiled by the same code. Anything else and "what they saw" and "what the update touches" would drift apart.
The shape is deliberately awkward in one place, and it is worth understanding before you wire a UI to it:
- preview first.
bulk-previewreturnscount— what the filter selects right now — plusconfirmAboveandmax; - past
confirmAbove(200 cards),expectedCountis required. Send the number the user actually saw. If reality has moved since, you get a 409 rather than a silent edit of a different set; - past
max(5 000 cards), the API refuses. It does not queue a job and it does not pretend to be atomic. That job does not exist yet, and saying so is better than a request that half-succeeds.
One UPDATE … WHERE, one transaction, one event per card, and a single webhook
delivery for the batch. The response carries the batchId, so you can read back
exactly what it did:
GET /workspaces/{workspaceId}/events?batchId=….
Imports you can run twice
POST /workspaces/{workspaceId}/importAn import is the one write that is designed around failing. Not because it is fragile — because an import always fails at least once, and one you cannot replay is one you cannot repair.
So content that came from somewhere else carries the identity of its origin:
externalSource and externalId, in indexed columns on cards, collections and
files. That pair decides between creating and updating, and anything that hasn't
actually changed is not rewritten — no write, no event, no webhook. Running
a successful import a second time is a no-op you can watch happen: unchanged
in the result is the whole count.
Why external identity is not a property
An idempotency key living in user data is not an idempotency key. Any ordinary write would change it, and deleting a field would take it with it. It has to be a column, out of reach of editing.
Imports also announce themselves as imports. Their events all share one
batchId and carry cause: "import", so an activity feed can ask for
?cause=edit and not bury six months of work under three thousand imported
cards. Webhook delivery is per batch and per type, never one call per card.
A batch carries at most 500 items. Past that, call the route again with the
batchId you were given: three thousand cards remain one action in the log.