Files
joplin-cli-gateway/api-mvp.md
T
2026-08-19 17:18:45 +01:00

19 KiB

MVP API Draft

Status: implemented MVP contract draft. REST remains normative; contract hardening and generated OpenAPI documentation may continue before a 1.0 compatibility freeze.

This API is informed by the official Joplin Terminal documentation, Joplin Data API, and Joplin synchronisation specification.

Boundary

The public API is a permission-filtered gateway over one dedicated Joplin CLI profile. It is not the Joplin Server API and it does not expose arbitrary shell or CLI command execution.

Joplin CLI includes an experimental local server command that exposes the Joplin Data API. That facility may be useful inside the gateway, but it is not the public contract. The adapter mechanism may change without changing this API.

The gateway exposes:

  • REST under /api/v1;
  • GraphQL at /graphql;
  • OAuth-style bearer-token authentication.

REST and GraphQL expose the same authorised Joplin capabilities. GraphQL subscriptions are not part of the MVP.

Common Behaviour

Authentication

Every endpoint except liveness and token issuance requires:

Authorization: Bearer <access-token>

Access tokens represent stable application clients, not people or sessions. Joplin Server credentials and Joplin E2EE keys are never exposed to gateway clients.

The MVP token flow is OAuth 2-style client credentials:

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=...&client_secret=...
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600
}

An operator creates client IDs, login secrets, enabled state, and permissions in administrator-owned JSON configuration, then restarts the gateway container to apply changes. This configuration is read-only to the gateway. Gateway-generated durable state is stored in a separate gateway-managed JSON file. Human gateway login, self-registration, authorisation-code grants, refresh tokens, and an administration API are outside the MVP.

The implementation issues HMAC-signed JWT bearer tokens with a configurable lifetime (one hour by default). The signing key is generated at process start, so restarting invalidates every outstanding token. There is no self-service revocation endpoint in the MVP. Disabling or removing a client and restarting the container therefore causes its next request to be rejected even if its previous token had not expired.

Synchronisation transaction

Every operation that reads or changes Joplin data runs inside the single profile queue:

  1. synchronise the managed CLI profile with Joplin Server;
  2. re-evaluate authorisation against the newly synchronised notebook hierarchy;
  3. execute the read or mutation against the local Joplin profile;
  4. after a local mutation, synchronise again;
  5. return the response.

A GraphQL document receives one pre-operation sync. If it contains mutations, they execute serially and the gateway completes a post-operation sync before returning.

Liveness checks and token issuance do not read Joplin data and do not trigger a sync.

If the pre-operation sync fails, the gateway returns 503 SYNC_UNAVAILABLE and does not execute the operation. It never silently serves stale Joplin data in the MVP.

If a local mutation succeeds but the post-operation sync fails, the gateway returns a degraded success. For REST the status is 202 Accepted; GraphQL returns mutation data without a transport error. REST adds sync beside the returned object's fields, and GraphQL mutation result types expose a sync field. Both use the following status shape:

{
  "sync": {
    "status": "pending",
    "local_change_applied": true,
    "operation_id": "...",
    "warning": "The change is local and has not been confirmed on Joplin Server"
  }
}

The gateway retains the local change and retries sync periodically. Other gateway clients share that local profile, but human Joplin clients cannot see the change until it reaches Joplin Server. Humans may meanwhile change the same content, potentially causing normal Joplin conflicts when sync resumes.

Create requests should carry an Idempotency-Key so ambiguous operations can be retried safely. The exact operation-recovery record remains to be specified, but partial success must never be hidden.

Joplin representation

The API preserves native Joplin concepts and identifiers:

  • a notebook is called notebook publicly, although Joplin calls it folder internally;
  • Joplin 32-character IDs are exposed directly;
  • note bodies contain normal Joplin Markdown;
  • timestamps are Unix time in milliseconds;
  • Joplin property names use snake_case in REST and GraphQL;
  • successful mutations return the resulting Joplin object after the post-operation sync.

Internal and security-sensitive fields such as encrypted payloads, master keys, sync internals, and gateway configuration are never exposed.

The MVP neither accepts attachment/image uploads nor exposes HTML-specific note creation fields. Joplin internal note links such as [Label](:/note-id) remain unchanged inside Markdown bodies. Retrieving the linked target is a separate authorised operation and returns not found when the target is inaccessible.

Pagination

REST collection endpoints accept:

  • page, starting at 1;
  • limit, default 50, maximum 100;
  • order_by, restricted to fields supported by that resource;
  • order_dir, either ASC or DESC.

They return:

{
  "items": [],
  "page": 1,
  "limit": 50,
  "has_more": false
}

The changes feed uses its own cursor instead of page numbers. GraphQL collections use connection objects with equivalent items and page_info fields; Relay compatibility is not required for the MVP.

Errors and non-disclosure

REST errors use a stable machine-readable envelope:

{
  "error": {
    "code": "NOTE_NOT_FOUND",
    "message": "Note not found",
    "request_id": "...",
    "details": {}
  }
}

GraphQL reports the same code, request_id, and optional details under errors[].extensions.

Expected HTTP meanings are:

Status Meaning
400 Invalid input, filter, or pagination request
401 Missing, invalid, expired, or disabled-client token
403 The object is visible, but the client lacks the required capability
404 The object is absent or must be hidden from this client
409 A request conflicts with current Joplin state
422 Semantically invalid Joplin content or relationship
503 The pre-operation synchronisation failed, so no content operation ran

An inaccessible object returns the same 404 shape as a nonexistent object. Search, counts, pagination metadata, tag relationships, and changes must not reveal inaccessible notes.

REST Endpoints

Service and identity

Method Path Purpose Synchronises
GET /health/live Process liveness only; returns no Joplin or client data No
POST /oauth/token Exchange configured client credentials for an access token No
GET /api/v1/me Return the authenticated stable client ID and its effective gateway permissions No

GET /api/v1/me never returns secrets, Server credentials, or inaccessible notebook metadata.

Notebooks

Method Path Capability
GET /api/v1/notebooks Return the visible notebook hierarchy
GET /api/v1/notebooks/{notebook_id} Get a visible notebook or an authorised path-only ancestor
GET /api/v1/notebooks/{notebook_id}/notes List visible notes directly inside a notebook
POST /api/v1/notebooks Create a top-level or descendant notebook

GET /notebooks returns accessible notebooks and the minimum ancestor stubs needed to represent their paths. A path-only ancestor contains id, title, parent_id, children, and access: "path_only"; it contains no note counts or content-derived metadata.

Notebook creation body:

{
  "title": "Project notes",
  "parent_id": "optional-parent-joplin-id"
}

Creating any notebook requires the global create_notebooks capability. If parent_id is present, the parent must also be visible to the client. Creating a child beneath a read-only hierarchy is permitted by the current product specification, but the inherited child remains read-only to that client. Creating a top-level notebook writes a durable automatic read/write grant to the gateway-managed state file.

Notebook update, rename, move, trash, and permanent deletion endpoints do not exist in the MVP.

Notes

Method Path Capability
GET /api/v1/notes List notes visible to the client
POST /api/v1/notes Create a note in a writable notebook
GET /api/v1/notes/{note_id} Get one visible note
PATCH /api/v1/notes/{note_id} Edit a note or move it between writable notebook trees
DELETE /api/v1/notes/{note_id} Move a note to Joplin trash
GET /api/v1/notes/{note_id}/tags List the note's tags
PUT /api/v1/notes/{note_id}/tags/{tag_id} Apply an existing tag to a writable note
DELETE /api/v1/notes/{note_id}/tags/{tag_id} Remove a tag from a writable note

GET /notes optionally accepts notebook_id. When omitted it searches all notebook trees visible to the client. GET /notebooks/{id}/notes lists direct members only; descendant notebooks are navigated explicitly.

Create note body:

{
  "parent_id": "required-writable-notebook-id",
  "title": "Meeting notes",
  "body": "Markdown content"
}

The gateway never relies on Joplin CLI's mutable “current notebook”; parent_id is required for deterministic multi-client behaviour.

Writable note fields confirmed for the MVP are:

  • parent_id;
  • title;
  • body, containing Markdown/plain text.

The implemented writable note fields are exactly parent_id, title, and body. To-do fields, attachment/image fields, HTML-specific input, encrypted fields, sharing fields, sync internals, and other Joplin metadata are not writable in the MVP.

To-dos and Joplin conflict notes are entirely hidden from MVP retrieval, listing, search, trash, history, and change-feed endpoints. Managing either remains a human operation in Joplin clients until its gateway capability is introduced post-MVP.

Moving a note by changing parent_id requires write access to both its current and destination notebook hierarchies.

PATCH /notes/{id} may include expected_updated_time. After the mandatory pre-operation sync, a mismatch returns 409 NOTE_CHANGED without applying the update. Omitting it permits normal unguarded Joplin write/conflict behaviour.

DELETE /notes/{id} uses normal reversible Joplin trash behaviour. Permanent deletion and deletion of note revisions are not available in the MVP.

Trash

Method Path Capability
GET /api/v1/trash/notes List trashed notes from readable notebook trees
GET /api/v1/trash/notes/{note_id} Read one authorised trashed note
POST /api/v1/trash/notes/{note_id}/restore Restore a trashed note to its original notebook

Joplin normally restores a trashed note to its original notebook. Reading a trashed note requires read access to that notebook; restoration requires write access. If the original notebook no longer exists, the gateway returns 409 RESTORE_DESTINATION_MISSING without restoring it; a human must resolve the destination in a standard Joplin client.

Note history

Method Path Capability
GET /api/v1/notes/{note_id}/revisions List the note's available historical versions
GET /api/v1/notes/{note_id}/revisions/{revision_id} Return a reconstructed historical note snapshot
POST /api/v1/notes/{note_id}/revisions/{revision_id}/restore Restore that version as a new note in an explicitly selected notebook

Revision access follows current read permission on the note's notebook. Restoration requires a body containing { "parent_id": "existing-writable-notebook-id" } and write permission on that destination. The gateway returns reconstructed note versions, not Joplin's internal diff records, and clients cannot create, edit, or delete revisions directly.

Revision restoration is non-destructive: it creates a new restored note in the selected notebook rather than replacing the current one. History availability and retention depend on Joplin's revision service; the API does not promise that every edit has a retained revision.

Tags

Method Path Capability
GET /api/v1/tags List global tags
GET /api/v1/tags/{tag_id} Get a global tag
GET /api/v1/tags/{tag_id}/notes List only visible notes carrying the tag
POST /api/v1/tags Create a global tag

Any authenticated client may list and view tags. POST /tags requires create_tags and accepts { "title": "tag-name" }. Applying or removing a tag uses the note endpoints and requires write access to that note.

Tag rename and deletion are not part of the MVP. Tag note counts, if returned at all, count only notes visible to the requesting client.

Method Path Capability
GET /api/v1/search?query=... Search visible notes using Joplin search syntax

The endpoint accepts the standard collection pagination and sorting parameters. It searches notes only in the MVP. Results are permission-filtered before they are counted or returned.

The gateway must ensure inaccessible Joplin search hits cannot affect exposed totals, snippets, ranking details, or pagination metadata.

Recent changes

Method Path Capability
GET /api/v1/changes Establish a cursor at the current visible change position
GET /api/v1/changes?cursor=...&limit=... Poll visible note changes after a cursor

The MVP feed reflects the Joplin Data API's current event capability: note changes only. Each item contains:

{
  "id": 123,
  "item_type": "note",
  "item_id": "joplin-note-id",
  "type": "created",
  "created_time": 1760000000000
}

The response contains items, cursor, and has_more. A request without a cursor establishes a baseline and does not return historical events. Cursors are opaque to clients.

The upstream Joplin Data API retains events for a limited period (currently documented as up to 90 days). An expired or invalid cursor returns 409 CHANGE_CURSOR_INVALID, requiring the client to re-list the visible state and establish a new cursor.

Every poll applies the client's current permissions. Events for inaccessible, newly inaccessible, or otherwise non-disclosable notes are omitted without revealing their existence.

The event envelope and cursor are gateway contracts rather than WebSocket-specific concepts. This allows a post-MVP WebSocket or subscription service to reuse the same stream and let disconnected clients resume without making the persistent connection itself a delivery guarantee.

GraphQL Surface

The MVP GraphQL endpoint is POST /graphql. It supports queries and mutations, but not subscriptions or schema mutation by clients.

Proposed root queries:

type Query {
  me: Client!
  notebooks: [Notebook!]!
  notebook(id: ID!): Notebook
  notes(filter: NoteFilter, page: Int = 1, limit: Int = 50,
        order_by: NoteOrderField, order_dir: OrderDirection): NotePage!
  note(id: ID!): Note
  tags(page: Int = 1, limit: Int = 50): TagPage!
  tag(id: ID!): Tag
  search(query: String!, page: Int = 1, limit: Int = 50,
         order_by: NoteOrderField, order_dir: OrderDirection): NotePage!
  changes(cursor: String, limit: Int = 50): ChangePage!
  trashed_notes(page: Int = 1, limit: Int = 50): NotePage!
  trashed_note(id: ID!): Note
  note_revisions(note_id: ID!, page: Int = 1, limit: Int = 50): RevisionPage!
  note_revision(note_id: ID!, revision_id: ID!): NoteRevision
}

Proposed root mutations:

type Mutation {
  create_notebook(input: CreateNotebookInput!): Notebook!
  create_note(input: CreateNoteInput!): Note!
  update_note(id: ID!, input: UpdateNoteInput!): Note!
  delete_note(id: ID!): DeletedNote!
  create_tag(input: CreateTagInput!): Tag!
  add_tag_to_note(note_id: ID!, tag_id: ID!): Note!
  remove_tag_from_note(note_id: ID!, tag_id: ID!): Note!
  restore_trashed_note(id: ID!): Note!
  restore_note_revision(note_id: ID!, revision_id: ID!, parent_id: ID!): Note!
}

Relationships such as Notebook.notes, Note.tags, and Tag.notes use the same permission filtering and pagination rules as REST. A nullable lookup returns null for both absent and inaccessible objects, accompanied by no metadata that distinguishes the cases.

Permission Matrix

Operation Required permission
View a notebook path Direct/inherited read or write, or path-only ancestor status
Read/list/search a note Direct/inherited read or write on its notebook
Create a note Direct/inherited write on destination notebook
Edit/delete a note Direct/inherited write on current notebook
Move a note Direct/inherited write on source and destination notebooks
Read a trashed note or its history Direct/inherited read or write on its notebook
Restore a trashed note or revision Direct/inherited write on the destination notebook
List/view tags Any authenticated client
Apply/remove a tag Direct/inherited write on the note's notebook
Create a tag Global create_tags
Create a notebook Global create_notebooks; visible parent if creating a child
Read recent changes Current read or write access to the affected note
Any MVP operation full_access bypasses the individual MVP checks

All access is default deny.

Explicitly Absent From MVP

There are no public endpoints for:

  • arbitrary Joplin CLI or shell command execution;
  • Joplin CLI configuration or profile access;
  • explicit sync control, sync-target upgrades, or Joplin Server administration;
  • resources, attachments, or resource file download;
  • to-do creation, completion, or other to-do-specific operations;
  • import or export;
  • E2EE configuration, passwords, or master keys;
  • notebook rename, update, move, trash, or deletion;
  • tag rename or deletion;
  • permission/client administration;
  • GraphQL subscriptions;
  • permanent note deletion.

Remaining Decisions Before Contract Freeze

The mutation recovery and idempotency contract remains to be finalised. The implementation records pending post-sync failures and returns an operation ID, but it does not yet deduplicate retries by Idempotency-Key or expose an operation-status endpoint.

Historical revision restoration currently preserves the reconstructed title unchanged when creating the new note.