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
| Call | What it does |
|---|---|
GET /v1/notes | The 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/query | The notes matching a filter tree (the same tree a view saves), without saving a view: filter, optional search, limit, offset |
GET /v1/notes/:id | One note |
POST /v1/notes | Create one: title (required), content, tags, alias, cover_url, properties |
POST /v1/notes/:id/append | Add to the end: text, optional heading |
POST /v1/notes/:id/edit | Replace one exact passage: old_str, new_str |
PATCH /v1/notes/:id | Change fields: title, content (the whole body), alias, pinned, pin_label, cover_url (null clears), tags (adds), optional if_unchanged |
DELETE /v1/notes/:id | Move it to the trash |
GET /v1/notes/:id/properties | Its properties, as { key: value } |
PATCH /v1/notes/:id/properties | Set and clear: { "rating": 9, "status": null } |
POST /v1/notes/:id/tags | Add tags: { "tags": ["reading"] }. A tag that does not exist yet is created |
DELETE /v1/notes/:id/tags/:name | Take one tag off (the tag itself stays) |
GET /v1/notes/:id/links | Where it links to (outgoing, unresolved links included) and what links here (backlinks) |
GET /v1/notes/:id/files | The 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_unchangedwith theupdated_atyou 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
| Call | What it does |
|---|---|
GET /v1/notes/:id/revisions | The title and body each edit replaced, newest first, kept 7 days |
POST /v1/notes/:id/revisions/:revisionId/restore | Put one back. The current version becomes a revision, so this is undoable |
GET /v1/notes/trash | Notes in the trash, most recently deleted first |
POST /v1/notes/:id/restore | Take 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.
| Call | What it does |
|---|---|
GET /v1/records/structures | Every structure in the workspace, with its fields |
GET /v1/records/structures/:nameOrId | One structure |
POST /v1/records/structures | Declare one: name, fields, optional description, label_field, unique_together, max_rows, immutable |
PATCH /v1/records/structures/:nameOrId | Change it: add fields, relabel or hide them, change the description |
GET /v1/records?structure=weight | Its rows. filter, sort, direction, limit (max 500), offset |
GET /v1/records/watch?structure=weight | The same rows, live: answered now, then again on every change (see Watching live) |
GET /v1/records/summary?structure=weight&field=kg | Count, sum, average, min and max, computed in the database |
POST /v1/records | Write 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/:id | Merge fields into a row; null removes one |
DELETE /v1/records/:id | Soft 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:
| Field | Operators |
|---|---|
| number, date, time | eq, neq, gt, gte, lt, lte, in, not_in, is_empty, not_empty |
| text | eq, neq, contains, in, not_in, is_empty, not_empty |
| select, note, file, record | eq, neq, in, not_in, is_empty, not_empty |
| yes/no | eq, neq, is_empty, not_empty |
created_at, updated_at | eq, neq, gt, gte, lt, lte |
id | eq, 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: answercarries the whole page each time; withchanges=true, each answer after the first also names the rowscreated,updatedorremovedsince the previous one, so you need not compare pages yourself. "Removed" means the row left the page: deleted, filtered out, or pushed pastlimitby newer rows.: alivearrives every 25 seconds so a proxy does not close a quiet connection.event: problemreports one answer that failed; the stream stays open.event: closedmeans 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.
| Call | What it does |
|---|---|
GET /v1/webhooks | Every subscription. ?structure= for one structure |
POST /v1/webhooks | Subscribe: structure, url (https), optional events, filter, fields, ignore_own_key, description. The answer holds the signing secret, once |
GET /v1/webhooks/:id | One subscription |
PATCH /v1/webhooks/:id | Change it, turn it off (enabled: false), or resume: true after a pause |
DELETE /v1/webhooks/:id | Unsubscribe |
POST /v1/webhooks/:id/rotate-secret | A new secret, in this answer only. The old one stops at once |
POST /v1/webhooks/:id/test | Send a webhook.test event, to check a receiver without writing a row |
GET /v1/webhooks/:id/deliveries | What was sent, newest first: status, attempts, your HTTP status. ?status=failed |
POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver | Send one past event again now |
Tags
| Call | What it does |
|---|---|
GET /v1/tags | Every tag, with its colour, note_count and linked_tags |
GET /v1/tags/:id | One tag, and used_by: the views and graph lenses that name it |
PATCH /v1/tags/:id | Rename, recolour, pin: name, color, is_pinned |
DELETE /v1/tags/:id | Remove it from every note, then delete it |
PUT /v1/tags/:id/links | The 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.
| Call | What it does |
|---|---|
GET /v1/properties | The workspace's properties, each with notes_with_value |
GET /v1/properties/:idOrKey | One, and used_by: views (by role), presets and graph lenses |
POST /v1/properties | Define one: key, type, optional label, options, position, is_pinned |
PATCH /v1/properties/:idOrKey | Rename the key, relabel, reorder, pin, change a select's options |
DELETE /v1/properties/:idOrKey | Delete 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.
| Call | What it does |
|---|---|
GET /v1/presets | Every preset: tags, properties (what it sets), asks (what it asks for) |
GET /v1/presets/:idOrName | One |
POST /v1/presets | Create: name, tags, properties, ask, optional icon, color, is_pinned |
PATCH /v1/presets/:idOrName | Change it. tags replaces the tag list; properties and ask together replace the property list |
DELETE /v1/presets/:idOrName | Delete it. Notes it was applied to keep their values |
POST /v1/presets/:idOrName/apply | Apply 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.
| Call | What it does |
|---|---|
GET /v1/views | Every view, with its filter, filter_summary, sort and display |
GET /v1/views/:id | One |
GET /v1/views/:id/notes | The notes it matches now, in its order. limit, offset |
POST /v1/views | Create: name, filter, sort, display_properties (at most 3), show_tags, is_pinned, is_default, icon, color, cover_url |
PATCH /v1/views/:id | Change any of those. filter replaces the whole tree; null clears it |
DELETE /v1/views/:id | Delete 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,anyornone, and nest up to 4 deep, with at most 50 conditions. - Tags take
has_all,has_anyorhas_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
- text and select:
- Fields are
title,created_atandupdated_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).
| Call | What it does |
|---|---|
GET /v1/lenses | Every lens |
GET /v1/lenses/:id | One |
POST /v1/lenses | Create: name, scope, settings, is_default |
PATCH /v1/lenses/:id | Change it. settings is merged into the current ones; scope replaces |
DELETE /v1/lenses/:id | Delete 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.
| Call | What it does |
|---|---|
GET /v1/quick-access | The pinned notes, in order |
POST /v1/quick-access | Pin one: note_id, optional icon, color (#RRGGBB), icon_filled, position |
PATCH /v1/quick-access/:id | Restyle or move it. :id is the item's id or the note's id |
DELETE /v1/quick-access/:id | Unpin 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.
| Call | What it does |
|---|---|
GET /v1/files | Every file in the workspace, attached to a note or not; or one note's with note_id |
GET /v1/files/:id | One, with its outline (the sections a long document was split into) |
GET /v1/files/:id/text | Its 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/:id | Rename 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
| Status | Means |
|---|---|
| 400 | The request, or a rule: the message is the sentence to act on |
| 401 | No key, a bad key, or a revoked one |
| 403 | A read-only key tried to write |
| 404 | Not in this workspace, or already trashed |
| 409 | Something depends on it (a view uses the tag), or the name is already taken |
| 500 | Our 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