REST API for managing notes, notebooks and attachments on a self-hosted Joplin Server
460
A small FastAPI service that puts a plain REST API in front of a self-hosted Joplin Server instance, so third-party apps can create, read, update and delete notes, notebooks and attachments without speaking Joplin's internal sync protocol.
Joplin Server (the sync target you self-host) has no friendly HTTP API of its own - it exposes the same low-level item API the desktop/mobile apps use to sync, where every note or notebook is a single flat text blob in Joplin's own serialization format, addressed by a 32-character id. (Joplin's actual Data API is a normal REST API, but it only runs inside the desktop app, not the server.)
notes-api logs into Joplin Server with its own account, serializes and
parses that text format, and exposes ordinary JSON over HTTP instead -
protected by a single API key, containerized, and deployable next to your
Joplin Server stack.
Joppy is a Python library that wraps both the Joplin desktop and Joplin Server APIs, including its own reverse-engineered handling of the server's item format - if you're working in Python and don't need a standalone HTTP service, it's worth a look instead of (or alongside) this.
/notes, /notebooks)/search)POST /notes/{id}/attachments uploads a file as a
Joplin resource and links it into the note bodyX-API-Key header for auth/stats - request volume, error rate, latency,
top endpoints and recent request logdocker compose up and it's runningA running Joplin Server instance and a dedicated Joplin account for this service to log in as (don't reuse your personal or admin account - see below).
Pull the published image:
cp .env.example .env # fill in real values
docker compose up -d # pulls ajvcorreia/notes-api:latest
Or build it yourself - comment out image: and uncomment build: . in
compose.yaml, then docker compose up -d --build.
JOPLIN_BASE_URL must exactly match your Joplin Server's configured
APP_BASE_URL - Joplin Server rejects requests whose Origin doesn't match
it, so pointing this at an internal docker hostname generally won't work
even if it's network-reachable; use the same base URL your other Joplin
clients connect to.
Don't reuse an admin account. Log in as an existing admin to create a
dedicated user, then confirm its email directly in Postgres (there's no
mail flow for made-up addresses like [email protected]):
# get an admin session
curl -X POST http://<joplin-host>:22300/api/sessions \
-H 'Content-Type: application/json' \
-d '{"email":"admin@localhost","password":"admin"}'
# create the service account (use the session id from above)
curl -X POST http://<joplin-host>:22300/api/users \
-H "X-API-AUTH: <session id>" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"<pick one>","full_name":"Notes API Service"}'
# confirm the email and clear must_set_password so it can log in
docker exec <joplin-postgres-container> psql -U <pg user> -d joplin -c \
"UPDATE users SET email_confirmed=1, must_set_password=0 WHERE email='[email protected]';"
Any Joplin client (desktop, mobile) that syncs using this same account will share the exact same notes the API manages.
All routes except /health require X-API-Key: <API_KEY>.
| Method | Path | Description |
|---|---|---|
| GET | /notes | List notes, without body (?parent_id=, ?limit=, ?offset=) |
| GET | /search | Case-insensitive substring search over title/body, without body (?q=, ?limit=, ?offset=) |
| GET | /notes/{id} | Get one note, including body |
| POST | /notes | Create a note |
| PUT | /notes/{id} | Update a note (partial) |
| DELETE | /notes/{id} | Delete a note |
| POST | /notes/{id}/attachments | Upload a file, attach it to the note (multipart) |
| GET | /notebooks | List notebooks |
| POST | /notebooks | Create a notebook |
| DELETE | /notebooks/{id} | Delete a notebook |
| GET | /stats | Usage dashboard (HTML page, no API key needed to load - it prompts for one in the browser) |
| GET | /stats/data | Usage stats as JSON (requires X-API-Key) |
curl -X POST http://localhost:8000/notes \
-H "X-API-Key: $API_KEY" -H 'Content-Type: application/json' \
-d '{"title": "Hello", "body": "World", "parent_id": "<notebook id>"}'
curl -X POST http://localhost:8000/notes/<note id>/attachments \
-H "X-API-Key: $API_KEY" -F "[email protected];type=image/png"
Interactive docs at /docs once the service is running.
Visit http://localhost:8000/stats for a live view of API traffic: total
requests, error rate, average response time, requests per day, top
endpoints, response status breakdown and a recent-request log. The page
itself needs no auth to load, but it calls /stats/data with the same
X-API-Key as everything else - you'll be prompted for it once, and it's
kept in the browser's local storage from then on.
GET /health calls are recorded like any other request, but the dashboard's
"Ignore health checks" toggle (on by default) excludes them so they don't
drown out real traffic. It's passed through to the API as /stats/data?exclude_health=true;
turn it off in the dashboard, or pass exclude_health=false yourself, to see
everything.
The dashboard auto-refreshes every 30 seconds by default; use the interval dropdown next to the refresh button to change it (10s/30s/1m/5m) or turn auto-refresh off. The choice is kept in the browser's local storage.
Request history is stored in a SQLite file (STATS_DB_PATH, default
data/stats.db). compose.yaml mounts a notes-api-data volume over
/app/data so this survives container restarts/upgrades; without a volume
it resets whenever the container is recreated.
.github/workflows/docker-publish.yml builds and pushes
ajvcorreia/notes-api
(linux/amd64 + linux/arm64) automatically:
main updates the latest tagv1.2.3 also publishes 1.2.3 and 1.2 tagsIt needs two repository secrets under Settings > Secrets and variables > Actions:
DOCKERHUB_USERNAME - your Docker Hub usernameDOCKERHUB_TOKEN - a Docker Hub access token
(Account Settings > Security > New Access Token), not your passwordGET /notes is O(n) in your total note count without limit. Joplin
Server has no metadata-only listing endpoint - every note's raw content
still has to be downloaded and parsed to know its title/type/parent_id.
Passing limit stops fetching further pages once enough matches are
found, which does cut latency for small limits; without it (or with a
restrictive parent_id matching few notes late in the list) it still
walks the whole library.GET /search and GET /notebooks are backed by an in-memory cache
(app/item_cache.py), since Joplin Server has no search endpoint at all
(that's only in the desktop app's local Data API) and no metadata-only
listing either - both need every item's content. The cache fetches all
items concurrently and parses them across a process pool (so parsing
isn't serialized on one core), then reuses that snapshot for
ITEM_CACHE_TTL_SECONDS (default 30s). Writes made through this API
invalidate it immediately; edits from other Joplin clients (or another
instance of this API) are only picked up once the TTL expires.notes-api cannot read or write without the encryption
master key.Content type
Image
Digest
sha256:cd7acfe9f…
Size
56.6 MB
Last updated
about 2 months ago
docker pull ajvcorreia/notes-api