HTTP API

Blocks and safe writes

Read a document as blocks with ids, write one block back, and make a write fail instead of overwriting someone else's change.

A block is one top-level piece of a markdown document: a heading, a paragraph, a list, a table, a code block. Every post, draft, library document and board card in the /v1/fs tree can be read as a list of blocks, each with an id, and written back one block at a time. This page covers those writes and the two ways sfora keeps you from overwriting a change you haven't seen: block ids and revisions.

Read the blocks

Add ?view=blocks to a document's path to get every block as JSON, or ?view=outline for the headings only:

curl -H "Authorization: Bearer $KEY" "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?view=blocks"

Each block has an id, a type, its lines and offsets, its text, and writable. The full response, the id recipe and the fields are on the /v1/fs tree. Two things matter for writing:

  • An id is derived from the block's content. The same markdown always gives the same ids, so you can compute them yourself. Edit a block and its id changes.
  • writable says whether the id works on a write. The frontmatter fence and the # Title at the top of every read are an envelope around the document's body, and a write can't aim at them. They are writable: false; the body's own blocks are writable: true. Filter on it before you write.

Write one block

PUT the document's path with ?block=<id>. The request body is that block's new markdown, not a file: no frontmatter and no title. A # heading in it is a heading in the body, not a rename.

curl -X PUT -H "Authorization: Bearer $KEY" --data-binary 'The hill chart answers two questions, not one.' "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?block=k7f3a2cx"

sfora replaces exactly that block and copies every other byte of the document through. No other paragraph is re-spelled and no table is re-padded. The other blocks keep their ids, unless the block you edited was one of several identical blocks: those are told apart by an ordinal (k7f3a2cx, then k7f3a2cx.2), and the ordinal renumbers.

The answer is the ordinary write answer, 201:

{
  "filename": "hill-chart-notes.md",
  "path": "/v1/fs/projects/general/library/documents/hill-chart-notes.md",
  "id": "j57d0c8...",
  "url": "https://www.sfora.ai/org/acme/notes/j57d0c8...",
  "created": false,
  "present": true,
  "changed": true,
  "blockIds": { "rebound": 1, "orphaned": 0, "total": 4 }
}

present: true is on document writes only: writing a document puts you in its live roster in the app, on the block you wrote.

?block= works on posts, drafts, library documents (and the docs/ alias) and board cards. Only a draft takes one among posts, because a published post can't be changed.

A block containing a mention has two spellings: the read shows @Ada Lovelace, the stored body holds @[Ada Lovelace](m93b…). writable already accounts for this, so use the ids it marks true.

On a board card

The column in the path is not an instruction on a block write. A whole-file PUT to board/04-done/0001-a-card.md with no column: in its frontmatter moves the card to Done and closes it, because you wrote the file into that folder. A ?block= write to the same path does not: the body is prose with no frontmatter, so the card keeps its column, status, title and labels. If the card moved since you read it, movedTo and path in the answer say where it is now.

What a write did

Every markdown write, whole-file or one block, answers with two fields:

  • changed: whether the stored body moved. sfora compares your bytes with the stored ones block by block and keeps the stored spelling wherever the two parse the same. So a PUT of what you just read, or of a document your formatter re-spelled, stores nothing: changed is false, nobody is notified, and the revision stays where it was. changed is about the body only. A whole-file write that edits only the # Title renames the document and its file and still answers changed: false, so read path and filename for where it lives now.
  • blockIds: what became of the blocks the document had before the write. total is how many there were, rebound how many are the same block under a new id (you edited them), and orphaned how many have no match in the new version (you removed them or rewrote them past recognition). On a create all three are 0.
// one paragraph edited in a four-block document
{ "changed": true,  "blockIds": { "rebound": 1, "orphaned": 0, "total": 4 } }
// the same document written straight back after a read
{ "changed": false, "blockIds": { "rebound": 0, "orphaned": 0, "total": 4 } }

Revisions

A block id protects one block. A revision protects the whole document. Drafts, documents and cards carry a revision number that goes up by one each time their title or body changes (for a document, its frontmatter too). A card's column, status, labels, assignees and priority don't count, so moving a card never conflicts with someone typing in it.

A markdown read sends the revision twice:

ETag: "7"
X-Sfora-Revision: 7

Send it back with the write, either way:

curl -X PUT -H "Authorization: Bearer $KEY" -H 'If-Match: "7"' --data-binary @notes.md "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md"
curl -X PUT -H "Authorization: Bearer $KEY" --data-binary @notes.md "$SITE/v1/fs/projects/general/library/documents/hill-chart-notes.md?expectedRevision=7"

If the document is still at revision 7, the write goes through. If someone changed it in the meantime, nothing is written and you get 409:

{ "error": "conflict", "message": "NOTE_CONFLICT: This document changed in another session" }

Read the document again, merge, and write with the new revision. If-Match takes "7", "7-gzip" (as some proxies rewrite an ETag) or a bare 7. Anything else is 422. Without If-Match or expectedRevision, the write is last-write-wins.

When a write is refused

StatusWhenWhat to do
409 with block and blocksThe ?block= id no longer resolves: someone edited that block since you read it.Pick the block you meant from blocks and write again.
409 without blockThe revision moved, or the target is a published post.Re-read and merge; for a post, write a draft instead.
422The ?block= body is empty, the ?block= id is empty, the path has no stored body (plan.md, map.md, links.md, asks.md), or If-Match is malformed.Fix the request. To remove a block, PUT the whole document without it.

A block conflict carries its recovery, so you can re-aim without a second read:

{
  "error": "conflict",
  "message": "`k7f3a2cx` is not in this document any more — it was edited, replaced or removed since you read it. Its blocks now are listed in `blocks`; re-aim at one of those, or PUT the whole file.",
  "block": "k7f3a2cx",
  "blocks": [
    { "id": "k4h2m9qt", "line": 1, "preview": "## Where the unknowns live" },
    { "id": "k9a12b4d", "line": 3, "preview": "The hill chart answers two." }
  ]
}

blocks is every block the document has now, in order, with the line it starts on (1-based, in the body) and its first line of text. Key off the block field, not the status: a 409 without it is a different refusal, and re-aiming won't help.

A refused write changes nothing: no new revision and no activity.

Troubleshooting

Every write to a block answers 409

You are aiming at an envelope block (writable: false), or at a block that changed. Read ?view=blocks again and use an id marked writable: true.

A write answers changed: false

Your bytes parse the same as the stored ones, so there was nothing to store. If you changed only the title, the document was still renamed; read path.

A whole-file write to a card moved it

A whole-file PUT places the card in the column its column: frontmatter names, or, without that field, in the folder in its path. Moving a card out of Done reopens it. To edit the text only, use ?block=.

Last updated on