← Back to BrainMop

API reference

App: BrainMop· Developer: DabbleLabs UK· Last updated: 7 August 2026

This is the plain REST API behind BrainMop – the same one the web app, the Android app and BrainMop's own MCP server all call. It is for a script, a bot, or an MCP-capable assistant that isn't one of the ready-made clients. If you just want to connect an assistant like Claude or ChatGPT the easy way, that's Connect an assistant instead – sign in with an emailed link, nothing to copy. This page is the advanced path: a personal token and raw HTTP.

Everything here is read directly from the live server code, not written from memory, so it should match what actually happens. It can still drift as BrainMop changes – if something here disagrees with what the server does, trust the server and let me know.

On this page

Base URL

https://dabblelabs.uk/brainmop/api

Every path below is relative to that base – e.g. GET /notes means GET https://dabblelabs.uk/brainmop/api/notes. Requests and responses are JSON (Content-Type: application/json) unless stated otherwise. CORS is allowed from dabblelabs.uk and brainmop.dabblelabs.uk; other origins aren't reflected, so browser-based clients hosted elsewhere won't be able to call it directly – server-side and native clients are unaffected.

Authentication

Every request needs a personal API token in the Authorization header:

Authorization: Bearer bmp_your_token_here

A missing or malformed header, or a token that's invalid, expired or revoked, gets a 401. There's no separate API key or client ID – the token is the whole credential.

Creating a token

Tokens can only be created (or revoked) by signing in as yourself – a token can never manage other tokens, including itself. That's deliberate: it stops a leaked or compromised token from minting itself a fresh one or silencing its own revocation.

Revoking a token (from either place) cuts off anything using it immediately. Keep a token as you would a password – anyone who has it can act as you, within its scope.

Scopes

A token carries one or both of two scopes:

ScopeGrants
readAny GET request.
writeEverything else – POST, PUT, DELETE.

A token created without picking a scope defaults to read only. Calling an endpoint your token isn't scoped for returns 403:

{ "error": "token lacks write scope" }
If you're hitting an unexplained 402, this is why. Your credentials are fine – this is a billing limit, not a broken token or a bug.

Keeping notes is free, forever. Connecting an assistant – anything authenticating as an API token or over MCP, rather than a signed-in human in the web/Android app – is part of the optional paid plan, after a trial:

The 402 body is meant to be relayed as-is by an assistant that hits it:

HTTP/1.1 402 Payment Required

{
  "error": "payment_required",
  "feature": "ai_access",
  "message": "Your free 30-day trial of connecting an AI assistant to BrainMop has ended. Continued access is part of the paid plan (GBP 24 a year, or GBP 4.50 a month). Your notes are untouched and nothing is broken - this is a billing limit, not an error. You can upgrade at https://dabblelabs.uk/brainmop/."
}

Your notes are never touched by this – it only blocks further token/assistant requests until the plan is active again. See pricing for the full detail.

Rate limits

Limits are per account (shared across every token and the MCP server – not per token), counted in a rolling window. Going over one returns 429 with a Retry-After header:

{ "error": "too many requests, try again later" }
ClassLimitWindowApplies to
Read300 requests60sPlain GET requests
Search240 requests60sGET /notes?q=...
Write180 requests60sPOST / PUT / DELETE
Preview30 requests60sGET /link-preview
Export3 requests1 hourGET /me/export

There's also a blanket flood cap of 1200 requests/minute per IP address across everything, as a coarse abuse backstop. These are today's actual numbers, not a contract – treat them as guidance and expect them to move as the service grows.

Inline text markup

Note titles, bodies and checklist item text aren't plain text or Markdown – they carry BrainMop's own lightweight inline tags. Write these tags literally into the text; the app renders them, and they round-trip exactly as written on read (including via export).

TagEffect
[b]text[/b]Bold
[i]text[/i]Italic
[u]text[/u]Underline
~~text~~Strikethrough
[sm]text[/sm]Small text
[lg]text[/lg]Large text
[hd]text[/hd]Heading-sized text
[url=https://example.com]link text[/url]Link. Accepts http(s) or mailto; an empty body ([url=https://example.com][/url]) shows the URL itself.

Bold/italic/underline/strikethrough and the three sizes stack freely (e.g. [b][i]both[/i][/b]). Untagged text renders at normal size and weight. There's no list/table/image markup, and no nesting of [url] inside itself. If you write a literal [ or ] as normal text rather than markup, it's stored and returned as regular text – but any text that happens to match one of the tags above will be treated as markup, so avoid writing e.g. [b] unless you mean it.

Notes

A note is either type: "text" (a body string) or type: "list" (an items checklist) – picked at creation and not changeable after except via the explicit /convert endpoint, which rewrites the content across the type change rather than flipping a flag.

The note object

{
  "id": 4213,
  "type": "text",
  "title": "Launch checklist",
  "body": "Ship it.",
  "position": 7,
  "pinned": 0,
  "done_at": null,
  "archived_at": null,
  "deleted_at": null,
  "created_at": "2026-08-01 09:14:02",
  "updated_at": "2026-08-05 18:02:41",
  "client_uuid": "b1e2c3d4-...",
  "sync_meta": null,
  "labels": [ { "id": 3, "name": "work", "color": "#f0a830", "demoted": 0 } ],
  "is_demoted": 0
}

items is present (an array, possibly empty) only when type is "list" – see Checklist items. Endpoints that return a note as a side effect of a mutation (create, item/label/status actions, convert, triage, duplicate) return a lighter version without deleted_at / client_uuid / sync_meta; those three fields are the ones to treat as optional. GET /notes and GET /notes/{id} always return the full object. Checklist items follow the same pattern: an item embedded in the bulk GET /notes list omits done_at / created_at / updated_at, while GET /notes/{id} and any item mutation return the item's full fields.

GET/notes

List/search your notes. Query parameters (all optional):

qFull-text search across title, body, item text and label names.
idLook up one note by id (a bare number or #123 in q also works).
statusactive (default) / archived / trashed / all.
label_idOnly notes carrying this label.
done1 to only show notes marked done (active status only).
include_demotedDefault on; 0 hides notes whose only labels are "demoted" ones.
fieldsComma-separated field allowlist to slim the response, e.g. fields=id,title.

Returns a bare JSON array by default. There's no pagination – every matching note comes back in one response every time, so for a genuinely large notebook prefer filtering with q/label_id, or use the bulk export instead of iterating this endpoint.

POST/notes

Create a note.

POST /notes
{
  "type": "list",
  "title": "Groceries",
  "items": [ { "text": "Milk" }, { "text": "Eggs" } ]
}

201
{ "id": 4301, "type": "list", "title": "Groceries", "body": "",
  "items": [
    { "id": 9001, "position": 0, "text": "Milk", "checked": false, "done_at": null, ... },
    { "id": 9002, "position": 1, "text": "Eggs", "checked": false, "done_at": null, ... }
  ],
  "labels": [], "is_demoted": 0, ... }

For a text note, send "type": "text", "body": "..." instead of items. title is optional and capped at 255 characters; body/item text is capped at 1 MiB each. Pass a client_uuid to make creation idempotent – retrying the same request with the same client_uuid updates the existing note instead of creating a duplicate.

GET/notes/{id}

Fetch one note (any status – active, archived or trashed). 404 if it doesn't exist or isn't yours.

PUT/notes/{id}

Edit title and/or body (text notes only – list notes are edited via the items endpoints). Only the fields you send are touched; omitting a field leaves it as-is. Returns the full updated note.

DELETE/notes/{id}

Soft-delete (move to trash). 204 on success. See trash below for permanent purge.

Status & organisation actions

All POST, all take no body except where noted, all return the full updated note:

POST /notes/{id}/archive / /unarchiveFile out of / back into the active list.
POST /notes/{id}/pin / /unpinPin to the top.
POST /notes/{id}/restoreRestore a trashed note.
POST /notes/{id}/duplicateDeep-copy title, body/items and labels into a new note. 201.
POST /notes/{id}/convertBody {"to": "text"} or {"to": "list"}. Text→list splits the body on newlines into items; list→text joins item text with newlines and discards the checklist structure. One-way in practice – converting back doesn't restore the original.
POST /notes/{id}/triageBody {"action": "do"|"push_middle"|"push_bottom"} – mark done, or reposition without a full reorder.

Labels on a note

POST /notes/{id}/labels          { "label_id": 3 }     -> 200, full note
DELETE /notes/{id}/labels/{label_id}                    -> 204

Version history

Every save keeps the version before it.

GET /notes/{id}/revisions                    -> array of revisions, newest first
GET /notes/{id}/revisions?limit=20&offset=0   -> { revisions, limit, offset, total, has_more }
POST /notes/{id}/revisions/{rev_id}/restore   -> 200, full note reverted to that revision
POST /notes/{id}/undo                          -> 200, full note reverted to before its last change

How far back you can read is capped by plan: 30 days on the free plan (or a lapsed paid plan), unlimited on the paid plan. The underlying data is kept longer than that (90 days, or the most recent 50 revisions, whichever is more) – a lapse never destroys history, it just hides the older part from reads until the plan is active again.

Checklist items

Only on type: "list" notes; 409 on a text note.

POST /notes/{id}/items                { "text": "Milk", "prepend": false }   -> 201, full note
PUT /notes/{id}/items/{item_id}       { "text"?: "...", "checked"?: true }   -> 200, full note
DELETE /notes/{id}/items/{item_id}                                           -> 200, full note
PUT /notes/{id}/items/reorder         { "item_ids": [9002, 9001] }           -> 200, full note

Every item mutation returns the whole parent note (not just the item) so a client always has the current list in hand. reorder takes the complete, exact set of the note's current item ids in the new order – a partial or mismatched list is rejected with 400. Pass a client_uuid on create the same way as notes, for idempotent retries.

Labels

GET /labels                                            -> [ { id, name, color, demoted, note_count }, ... ]
POST /labels          { "name": "work", "color"?: "#f0a830", "demoted"?: false }   -> 201
PUT /labels/{id}       { "name"?, "color"?, "demoted"? }                            -> 200
DELETE /labels/{id}                                                                 -> 204
POST /labels/{id}/merge   { "into": 7 }                                             -> 200, moves every note from this label onto the target and deletes this one

name is capped at 100 characters and must be unique per account (409 on a clash); color at 32 characters. A "demoted" label is a convention for keeping assistant-authored housekeeping notes out of the way – notes carrying only demoted labels are hidden from default listing unless you ask for them with include_demoted.

Bulk export

GET/me/export

If you want everything – every note, its items, its labels and its version history – this is the endpoint, not a loop over GET /notes. It streams a single JSON document:

{
  "format_version": 1,
  "exported_at": "...",
  "account": { "id": ..., "email": ..., "plan": ..., ... },
  "labels": [ ... ],
  "notes": [ { ...note fields..., "items": [...], "revisions": [...] } ]
}

It never includes session tokens, password hashes, or any other account's data. It's capped at 3 requests/hour (see rate limits) regardless of plan – a full export isn't a routine polling action, so build any sync logic against GET /changes instead and reserve this for an actual "get my data out" request.

Errors

Most errors are a flat JSON object with a single error string describing what went wrong:

{ "error": "title too long (max 255)" }
{ "error": "body too long (max 1048576 bytes, got 1048577)" }
StatusMeaning
400Malformed request – bad JSON, a missing/invalid field.
401Missing, malformed, invalid, expired or revoked token.
402Paid plan required – see the paid gate.
403Token doesn't have the scope for this request.
404Doesn't exist, or isn't yours – the API never distinguishes the two.
409Conflict with the resource's current state (e.g. a duplicate label name, or a checklist action on a text note).
413A text field is over its size limit.
429Rate limited – see rate limits.
500Something broke server-side. Rare; if you can reproduce it, it's a bug – say so.

The one exception is GET /me/export, which streams its body directly and doesn't use this envelope for a mid-stream failure.

Other endpoints

These exist and are stable, but are lower-traffic or more specialised than the above:

GET /trash / DELETE /trash / DELETE /trash/{id}List trashed notes, empty the trash, or permanently purge one note. Purging is irreversible.
POST /undo-group/{groupId}Revert every note change made in one batch (this is what lets a whole AI session be undone in one step).
GET /changes?since={cursor}Incremental sync feed – what BrainMop's own clients poll instead of re-fetching everything.
GET /me / PATCH /me / DELETE /meYour account: profile, email change, self-serve deletion. DELETE /me requires a signed-in session, not a token (403 otherwise), and a body of { "export_acknowledged": true, "confirm_email": "you@example.com", "password": "..." } – the export flag and the email echo are mandatory (400 if missing or if confirm_email doesn't match), and password re-proves the credential for a password-auth account (a Google-linked account sends id_token instead). Deletion is a soft delete with a cooling-off window before permanent purge, not instant.
GET / PATCH /me/preferencesAn opaque per-account settings blob for client use.
GET /link-preview?url=...Fetches title/image/site metadata for a URL, for building rich link previews.

What this API can't do

Stated plainly, so you find out here rather than by trial and error:

This is a small, actively-developed API. Endpoints, limits and response shapes here can change; this page is regenerated from the server code rather than hand-maintained, but it can still lag a change briefly. If in doubt, the response you actually get back is the ground truth.

Building something with this? Connect an assistant covers the easy path if you haven't already; otherwise the app itself is a normal notes app you can use alongside whatever you build.