Potion/Docs

The API

Potion's HTTP API lets your own scripts and services read and write a workspace: capture a note, log a record, pull data back out. You generate a key in Settings, send it with each request, and the key acts as you.

It is not the same thing as the MCP, which connects an AI assistant to your workspace. This is for code you write, and it reaches every object a workspace holds: notes, records and structures, tags, properties, presets, views, graph lenses, Quick Access and files.

This page is also available as plain Markdown at /docs/api.md.

The whole API is described as OpenAPI 3.1 at api.potion.run/openapi.json. Import it into Postman, Insomnia or Bruno to try every endpoint with your key, or generate a client from it.

Every call goes to https://api.potion.run/v1/….

Keys

A key is created in Settings → API keys, and shown once. Potion stores a hash of it, never the key, so a lost key cannot be recovered — make another and revoke the old one.

Two things are fixed when a key is made:

  • One workspace. A key reaches exactly one workspace and can never see another, which keeps a leaked key as contained as the workspace boundary itself.
  • Read or write. A read-only key is refused on every write with a message saying so. Use one for anything that only pulls data.

A key never grants more than you have. It resolves to your account, and the database applies the same rules it applies to the app: your notes, your workspace, trashed notes excluded.

Making a request

Authorization: Bearer potion_sk_xxxxxxxx

Send JSON, receive JSON.

curl https://api.potion.run/v1/me \
  -H "Authorization: Bearer potion_sk_xxxxxxxx"
{
  "user_id": "1037…",
  "workspace_id": "4f91d444-…",
  "key": { "id": "33e5…", "name": "logging script", "scope": "write" }
}

Start here when something is not working: /v1/me tells you which workspace the key opens and whether it may write. GET /v1/workspace names that workspace and counts what is in it.

Notes

CallWhat it does
GET /v1/notesThe workspace's notes, newest edit first. limit (max 200), offset, tag, sort (created_at, updated_at, title) and direction
GET /v1/notes?search=…Ranked full-text search over titles, bodies and tag names
POST /v1/notes/queryThe notes matching a filter tree (the same tree a view saves), without saving a view: filter, optional search, limit, offset
GET /v1/notes/:idOne note
POST /v1/notesCreate one: title (required), content, tags, alias, cover_url, properties
POST /v1/notes/:id/appendAdd to the end: text, optional heading
POST /v1/notes/:id/editReplace one exact passage: old_str, new_str
PATCH /v1/notes/:idChange fields: title, content (the whole body), alias, pinned, pin_label, cover_url (null clears), tags (adds), optional if_unchanged
DELETE /v1/notes/:idMove it to the trash
GET /v1/notes/:id/propertiesIts properties, as { key: value }
PATCH /v1/notes/:id/propertiesSet and clear: { "rating": 9, "status": null }
POST /v1/notes/:id/tagsAdd tags: { "tags": ["reading"] }. A tag that does not exist yet is created
DELETE /v1/notes/:id/tags/:nameTake one tag off (the tag itself stays)
GET /v1/notes/:id/linksWhere it links to (outgoing, unresolved links included) and what links here (backlinks)
GET /v1/notes/:id/filesThe files attached to it

Every list answers with total and has_more, so a count on your side always matches what you actually received.

Three ways to write, and when to use each

This is the part worth reading twice. Most APIs give you one way to change a document, and it is the dangerous one.

  • Append adds to the end without reading the note first. Nothing already there can be lost. Reach for it by default when you are adding.
  • Edit replaces one exact passage. If the passage appears twice, or not at all, the call is refused rather than guessing. Reach for it to change something specific.
  • PATCH replaces the whole body. Use it last. Send if_unchanged with the updated_at you read, and the call is refused if anything changed in between.

Why it matters: a note's body carries [[wiki links]] that hold a note's id, potion:layout directives and share pins. Regenerating a whole body, especially through a language model, is how those quietly break. The narrow shapes exist so you rarely have to.

curl -X POST https://api.potion.run/v1/notes/$ID/append \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"text":"- shipped the importer","heading":"2026-09-22"}'

Deleting is always soft. A note goes to the trash, where it stays recoverable for 30 days. The API has no permanent delete.

Properties are strict: a key the workspace does not define, or a value that does not fit its type, refuses the whole call and nothing is written. On POST /v1/notes the note is still created, and the refusal comes back as properties_error.

History and the trash

CallWhat it does
GET /v1/notes/:id/revisionsThe title and body each edit replaced, newest first, kept 7 days
POST /v1/notes/:id/revisions/:revisionId/restorePut one back. The current version becomes a revision, so this is undoable
GET /v1/notes/trashNotes in the trash, most recently deleted first
POST /v1/notes/:id/restoreTake one out of the trash

A restored note gets its links back. Its Quick Access pin does not come back, and it keeps its alias only if no other note claimed it in the meantime.

Records

Records are rows of a structure, not notes: a weight on a date, an expense, a logged reading. A record cannot exist before its structure, so declare the structure first.

CallWhat it does
GET /v1/records/structuresEvery structure in the workspace, with its fields
GET /v1/records/structures/:nameOrIdOne structure
POST /v1/records/structuresDeclare one: name, fields, optional description, label_field, unique_together, max_rows, immutable
PATCH /v1/records/structures/:nameOrIdChange it: add fields, relabel or hide them, change the description
GET /v1/records?structure=weightIts rows. filter, sort, direction, limit (max 500), offset
GET /v1/records/watch?structure=weightThe same rows, live: answered now, then again on every change (see Watching live)
GET /v1/records/summary?structure=weight&field=kgCount, sum, average, min and max, computed in the database
POST /v1/recordsWrite one row (data) or up to 500 (rows), in one transaction. A unique collision is refused unless on_conflict: "update", which updates the row it hits
PATCH /v1/records/:idMerge fields into a row; null removes one
DELETE /v1/records/:idSoft delete

A batch is one transaction. If any row is refused, nothing is written and the error names the row, so a retry is safe.

curl -X POST https://api.potion.run/v1/records \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"structure":"weight","data":{"kg":81.6,"when":"2026-09-22"}}'

Name a structure by its id in anything long-lived. A name stops matching the day you rename the structure; an id never changes.

Every row carries created_at and updated_at, set by the database and never by a caller. When the day something happened matters, give the structure a date field and fill it.

max_rows: 1 makes a settings object: a write replaces the one row (the previous one is kept, deleted). max_rows: N caps a list, refusing the row past N. Lowering it below the rows held is refused.

immutable: true makes rows facts: they can be written and deleted, never edited — a PATCH to one, or on_conflict: "update", is refused. Turning it on or off is always allowed.

Field changes are additive once rows exist: add fields, relabel them, hide them, but never remove, retype or rename a key. fields replaces the list, yet a field you resend without unique, unit, label, hidden, min or max keeps what it had. Deleting a structure is done in the app.

Filters

filter is a JSON list of conditions, all of which must hold:

filter=[{"field":"kg","op":"gte","value":80}]

Each kind of field answers its own operators, and anything else is refused rather than ignored:

FieldOperators
number, date, timeeq, neq, gt, gte, lt, lte, in, not_in, is_empty, not_empty
texteq, neq, contains, in, not_in, is_empty, not_empty
select, note, file, recordeq, neq, in, not_in, is_empty, not_empty
yes/noeq, neq, is_empty, not_empty
created_at, updated_ateq, neq, gt, gte, lt, lte
ideq, neq, in, not_in
* (a search)contains: any value of the row holds the text, case ignored, 1 to 200 characters

A refused filter is a feature: a dropped condition would quietly widen your answer, and every total built on it would be wrong. neq and not_in also match rows that have no value at all. On created_at and updated_at — set only by the database, never by a caller — a plain date means that whole day.

Watching live

GET /v1/records/watch asks the same question as GET /v1/records and keeps the connection open: it answers now, then again every time the structure changes, from any surface. It is Server-Sent Events, not a WebSocket, so curl -N reads it line by line, and a read-only key is enough.

curl -N -H "Authorization: Bearer $POTION_API_KEY" \
  "https://api.potion.run/v1/records/watch?structure=inbox&sort=created_at&direction=desc&limit=5&changes=true"
event: answer
data: {"structure":{…},"records":[…],"total":12,"has_more":true}

: alive

event: answer
data: {"structure":{…},"records":[…],"total":13,"has_more":true,"changes":{"created":[{…}],"updated":[],"removed":[]}}
  • event: answer carries the whole page each time; with changes=true, each answer after the first also names the rows created, updated or removed since the previous one, so you need not compare pages yourself. "Removed" means the row left the page: deleted, filtered out, or pushed past limit by newer rows.
  • : alive arrives every 25 seconds so a proxy does not close a quiet connection.
  • event: problem reports one answer that failed; the stream stays open. event: closed means the key was revoked or expired, and the stream ends.
  • Changes a few hundred milliseconds apart arrive as one answer. Reconnect when the connection drops; the first answer tells you where things stand.

Use a watch when your program runs somewhere a webhook cannot reach, such as a laptop or a terminal. Use a webhook when you have a public URL and want every event delivered even while you are away.

Webhooks

A structure can call a URL of yours whenever a matching row is written — created, updated or deleted, from any surface. Subscriptions are managed here like any other resource; receiving them (what arrives, the signature, retries) is its own page: Webhooks.

CallWhat it does
GET /v1/webhooksEvery subscription. ?structure= for one structure
POST /v1/webhooksSubscribe: structure, url (https), optional events, filter, fields, ignore_own_key, description. The answer holds the signing secret, once
GET /v1/webhooks/:idOne subscription
PATCH /v1/webhooks/:idChange it, turn it off (enabled: false), or resume: true after a pause
DELETE /v1/webhooks/:idUnsubscribe
POST /v1/webhooks/:id/rotate-secretA new secret, in this answer only. The old one stops at once
POST /v1/webhooks/:id/testSend a webhook.test event, to check a receiver without writing a row
GET /v1/webhooks/:id/deliveriesWhat was sent, newest first: status, attempts, your HTTP status. ?status=failed
POST /v1/webhooks/:id/deliveries/:deliveryId/redeliverSend one past event again now

Tags

CallWhat it does
GET /v1/tagsEvery tag, with its colour, note_count and linked_tags
GET /v1/tags/:idOne tag, and used_by: the views and graph lenses that name it
PATCH /v1/tags/:idRename, recolour, pin: name, color, is_pinned
DELETE /v1/tags/:idRemove it from every note, then delete it
PUT /v1/tags/:id/linksThe tags it brings along onto every note that carries it: { "linked_tag_ids": [12] }

Renaming is always safe: views and lenses hold a tag's id, not its name. Deleting is refused (409) while a view filters on the tag or a graph lens is scoped by it, and the message names them. Otherwise that view would quietly start matching more notes.

Properties

A property is a typed value a note can carry: text, number, date, bool, select (with options), or json.

CallWhat it does
GET /v1/propertiesThe workspace's properties, each with notes_with_value
GET /v1/properties/:idOrKeyOne, and used_by: views (by role), presets and graph lenses
POST /v1/propertiesDefine one: key, type, optional label, options, position, is_pinned
PATCH /v1/properties/:idOrKeyRename the key, relabel, reorder, pin, change a select's options
DELETE /v1/properties/:idOrKeyDelete it and its value on every note

A type can change only while no note holds a value. Narrowing a select's options leaves values already on notes in place and lists them back as stranded_values. A delete is refused (409) while a view, preset or graph lens uses the property.

Presets

A preset is a named set of tags and property values written onto a note. A value can be fixed, or asked for (ask), with or without a pre-filled answer.

CallWhat it does
GET /v1/presetsEvery preset: tags, properties (what it sets), asks (what it asks for)
GET /v1/presets/:idOrNameOne
POST /v1/presetsCreate: name, tags, properties, ask, optional icon, color, is_pinned
PATCH /v1/presets/:idOrNameChange it. tags replaces the tag list; properties and ask together replace the property list
DELETE /v1/presets/:idOrNameDelete it. Notes it was applied to keep their values
POST /v1/presets/:idOrName/applyApply to a note: note_id, optional answers, overwrite

Applying adds and never takes away. A value the note already holds is kept unless you send overwrite: true. A question with no answer and no pre-filled value is left unset and listed in properties_unanswered. An empty value is never written.

Views

A view is a saved filter, a sort and what its cards show, re-evaluated whenever it is read.

CallWhat it does
GET /v1/viewsEvery view, with its filter, filter_summary, sort and display
GET /v1/views/:idOne
GET /v1/views/:id/notesThe notes it matches now, in its order. limit, offset
POST /v1/viewsCreate: name, filter, sort, display_properties (at most 3), show_tags, is_pinned, is_default, icon, color, cover_url
PATCH /v1/views/:idChange any of those. filter replaces the whole tree; null clears it
DELETE /v1/views/:idDelete it. Notes are untouched: a view is a filter, never a folder

sort is { "field": "created_at" | "updated_at" | "title" | "property", "direction": "asc" | "desc", "property": "rating" }, with property only for a property sort.

The filter tree

A filter is one tree of groups and conditions. It names tags by id and properties by definition id, as the app stores it, so take the ids from GET /v1/tags and GET /v1/properties.

{
  "type": "group", "match": "all",
  "children": [
    { "type": "tags", "op": "has_any", "tag_ids": [12, 40] },
    { "type": "property", "def_id": "9b1c…", "op": "gte", "value": 8 },
    { "type": "group", "match": "none", "children": [
      { "type": "field", "field": "title", "op": "contains", "value": "draft" }
    ] }
  ]
}
  • Groups match all, any or none, and nest up to 4 deep, with at most 50 conditions.
  • Tags take has_all, has_any or has_none.
  • Properties take the operators of their type:
    • text and select: eq, neq, contains, not_contains, starts_with, ends_with, in, not_in, empty, not_empty
    • number: eq, neq, gt, gte, lt, lte, between
    • date: on, before, after, on_or_before, on_or_after, between, within
    • yes/no: eq
    • json: has_key, contains
    • every type: empty, not_empty
  • Fields are title, created_at and updated_at.
  • Dates take YYYY-MM-DD, or { "relative": { "n": 7, "unit": "day" } }.

The database checks every tree before saving it, and a tree it cannot answer is refused with the reason. The same tree drives POST /v1/notes/query and a graph lens's scope. Filters are described in full at /docs/filters.

Graph

The graph page opens on a lens, a saved way of looking at it: which notes (scope, a filter tree, or null for the whole workspace) and how to draw them (settings).

CallWhat it does
GET /v1/lensesEvery lens
GET /v1/lenses/:idOne
POST /v1/lensesCreate: name, scope, settings, is_default
PATCH /v1/lenses/:idChange it. settings is merged into the current ones; scope replaces
DELETE /v1/lenses/:idDelete it. If it was the default, the next lens becomes the default

settings is { "layers": { "links", "tags" }, "nodes": { "orphans", "dangling" }, "look": { "arrows", "titles": "zoom" | "always" | "never" } }, all yes/no except titles.

Quick Access

The notes pinned to the sidebar, in order.

CallWhat it does
GET /v1/quick-accessThe pinned notes, in order
POST /v1/quick-accessPin one: note_id, optional icon, color (#RRGGBB), icon_filled, position
PATCH /v1/quick-access/:idRestyle or move it. :id is the item's id or the note's id
DELETE /v1/quick-access/:idUnpin it (the note stays)

Files

Files, as readable text: attached to a note, or kept in the workspace alone. Uploading and deleting are done in the app.

CallWhat it does
GET /v1/filesEvery file in the workspace, attached to a note or not; or one note's with note_id
GET /v1/files/:idOne, with its outline (the sections a long document was split into)
GET /v1/files/:id/textIts extracted text, marked by page. pages (7 or 7-9), max_chars (default 20,000)
GET /v1/files/search?q=…Full-text search inside files. file_id narrows it to one
PATCH /v1/files/:idRename it: file_name. Its [[file:…]] references in the note are rewritten too

A file's text is ready once its status is ready. A long text comes back with truncated: true and next_chunk, so ask for the next page range.

When something is refused

StatusMeans
400The request, or a rule: the message is the sentence to act on
401No key, a bad key, or a revoked one
403A read-only key tried to write
404Not in this workspace, or already trashed
409Something depends on it (a view uses the tag), or the name is already taken
500Our side
{ "error": "filter on \"created_at\" takes eq, neq, gt, gte, lt or lte, not \"in\"", "code": "refused" }

Refusals quote the rule that stopped the write, in the same words the app uses.

What this version does not do

These are deliberately left out, because each is either one wide, irreversible cascade or machinery that lives in the app:

  • renaming or deleting a workspace
  • deleting a structure
  • deleting a note for good
  • uploading or deleting a file
  • layouts