# 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](/docs/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](/docs/api.md).

The whole API is described as OpenAPI 3.1 at [api.potion.run/openapi.json](https://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/…`.

::toc

## 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

```text
Authorization: Bearer potion_sk_xxxxxxxx
```

Send JSON, receive JSON.

```text
curl https://api.potion.run/v1/me \
  -H "Authorization: Bearer potion_sk_xxxxxxxx"
```

```json
{
  "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_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.

```text
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](#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.

```text
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:

```text
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.

```text
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"
```

```text
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](/docs/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`.

```json
{
  "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](/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 |

```json
{ "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
