API reference
Notes API
Notes are Harbor's core resource: syncable records with a sanitized HTML body you can write in Markdown or HTML.
The notes endpoints cover the full lifecycle of a note: create, list with
filters, fetch, partial update, append, PDF export, trash/delete, and the
attachment-hash swap that powers client-side encryption. Every endpoint on this
page requires a bearer token with the notes scope.
Notes are syncable records: every mutation allocates an update sequence
number (USN) in the same transaction that writes the row, deletes are
tombstones, and writes are last-write-wins (no locking — cross-device
divergence is resolved by sync’s conflict copies). Mutations return
{"note": {...}, "usn": n} so clients can advance their sync cursor; reads
return the bare note object.
The note object
| Field | Type | Description |
|---|---|---|
id | string | Client-supplied UUID (server-generated when omitted at create). |
title | string | Up to 255 characters (runes). Opaque ciphertext when is_encrypted. |
content | string | Sanitized HTML fragment — an allowlist subset of standard HTML, not ENML. Opaque ciphertext when is_encrypted. |
content_hash | string | Hash over the final stored bytes (the sanitized HTML, or the ciphertext). |
content_length | int | Length of the final stored bytes. Capped at 5 MiB (note_too_large). |
word_count | int | Word count of the stored body. Always 0 for encrypted notes. |
notebook_id | string | The owning notebook. |
is_encrypted | bool | Client-authoritative encryption flag — see Encryption below. |
thumbnail | string | Explicit list thumbnail: "" or a sha256:<64-hex> resource reference. Always present. |
source_url | string | URL the note came from, if any. |
source | string | Provenance marker, ≤ 64 chars (e.g. "web.clip"). |
is_web_clip | bool | true when the body is a read-only web-page snapshot (rendered in a sandboxed viewer, not re-editable). |
author | string | Free-form author string. |
latitude / longitude / altitude | number | Location metadata. Omitted from responses when unset. |
reminder_time | int | Reminder time, epoch ms. Omitted when unset. |
reminder_done_time | int | When the reminder was completed, epoch ms. Omitted when unset. |
is_public | bool | Public-share marker. Always present. |
public_token | string | Share token for building the public URL; "" when private. |
public_shared_at | int | Epoch ms of the most recent publish. Omitted when never shared. |
usn | int | Server-assigned update sequence number (sync ordering signal). |
deleted | bool | Tombstone flag. |
updated_at / created_at | int | UTC epoch milliseconds. |
{
"id": "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68",
"title": "Quarterly plan",
"content": "<p>Hello <strong>world</strong></p>",
"content_hash": "f1d2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70",
"content_length": 35,
"word_count": 2,
"notebook_id": "5b1f2c9a-77d4-4e21-b9c3-2a8f6d4e1c05",
"is_encrypted": false,
"thumbnail": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"source_url": "",
"source": "",
"is_web_clip": false,
"author": "",
"is_public": false,
"public_token": "",
"usn": 88,
"deleted": false,
"updated_at": 1784000000000,
"created_at": 1781200000000
}
Content format: Markdown in, HTML out
The canonical note body is a sanitized HTML fragment. On any write, the
content_format field controls how content is interpreted: html (the
default) or markdown. Markdown (CommonMark + GFM) is converted to HTML
server-side, so every client ends up editing one format. On reads,
?format=markdown returns the body as best-effort Markdown.
Attachments and rich media are typed <harbor-embed> elements with a fixed
attribute set (type, resource — a content-addressed sha256: reference,
src, title, width, height, align); plain <img> with an http(s)
src is also tolerated. The body is capped at 5 MiB of final stored bytes
(note_too_large when exceeded).
Encryption
is_encrypted is client-authoritative: when true, the client sends opaque
ciphertext for both title and content, and the server stores it verbatim —
never sanitized, converted, indexed, or searchable. word_count is 0, and
content_hash / content_length are computed over the ciphertext. Attached
files follow the note: the client re-encrypts each file, uploads it under a new
content hash, and calls POST /notes/:id/attachments/swap
to re-point the note and purge the old plaintext blobs.
Moving a note between notebooks
A note moves by PATCHing its notebook_id. If the destination notebook has
default_encrypt
and the note is still plaintext, the encryption and the move have to happen in
the same request. The server refuses the half-move:
{
"code": "cannot_move_plaintext_into_encrypted",
"message": "That notebook keeps its notes encrypted; encrypt the note and move it in the same request."
}
That is a 422, and nothing is written and no USN is spent when it fires —
the note is untouched, so a client can seal it and retry.
The request a correct client sends carries the ciphertext, the flag and the destination together, in one write:
curl -X PATCH https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68 \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "HRBC2.…",
"content": "HRBC2.…",
"content_format": "html",
"is_encrypted": true,
"notebook_id": "b9c1e0d7-4f2a-4c8b-9e15-3a7d6c2f0b48"
}'
The client does the encrypting — the server holds no keys and cannot do it for
you. A complete move therefore means sealing title and content,
re-encrypting each embedded attachment and uploading it under a new hash, then
calling POST /notes/:id/attachments/swap once the
note is saved.
Four scoping facts, because these are the edges integrators hit:
- The guard fires only on an actual move, compared against the resolved
destination. Re-saving a plaintext note that already lives in the encrypting
notebook is fine, and so is echoing the same
notebook_idback. - An empty
notebook_idmeans the user’s default notebook — and if that notebook hasdefault_encrypt, the rule applies to it. - The guard is on
PATCH /notes/:idonly. It is deliberately not onPOST /notesand not onPOST /sync/push: the native apps move notes through the sync queue, and rejecting one record mid-push would wedge a client’s whole queue. Both absences are permanent. Sealing on the way in is the client’s job; the422is a backstop for the public API and third-party scripts, not the mechanism. - Moving a note out changes nothing. An encrypted note stays encrypted
wherever it goes, stays out of the search index, and is never decrypted for
you. Removing encryption is a separate, explicit write
(
is_encrypted: falsewith the plaintext body).
List notes
GET /api/v1/notes · scope: notes
List the user’s notes. Trashed notes are excluded unless deleted=true.
| Field | Type | Required | Description |
|---|---|---|---|
limit | int | no | Default 100, hard cap 500 (clamped). |
offset | int | no | Default 0. |
order | string | no | Default -updated_at. Sortable: updated_at, created_at, title, usn (- prefix = descending). |
notebook_id | string | no | Filter to one notebook. |
tag | string | no | Filter to notes carrying this tag id. |
stack | string | no | Notes in any live notebook under this stack label. |
updated_since | int | no | Epoch ms; returns notes with updated_at >= this value. |
deleted | bool | no | Default false; true includes trashed notes. |
fields | string | no | meta omits content from each row (lighter list payloads). |
Each row adds two list-only conveniences, so a list view needs no per-row fetches:
tags— the note’s live tags as{id, name}, sorted by name. Omitted when the note has none. Tag names are plaintext, so encrypted notes still list their tags.cover— the first thumbnail-eligible attachment:hash,mime,is_encrypted,thumb_status(pending|processing|done|failed|skipped), andthumb_small_key/thumb_medium_keywhendone. Omitted when the note has no attachments.
Both are omitted from single-note reads and are unaffected by fields=meta
(only content is blanked).
curl "https://app.harbor.my/api/v1/notes?notebook_id=5b1f2c9a-77d4-4e21-b9c3-2a8f6d4e1c05&fields=meta&limit=50" \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"data": [
{
"id": "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68",
"title": "Quarterly plan",
"notebook_id": "5b1f2c9a-77d4-4e21-b9c3-2a8f6d4e1c05",
"is_encrypted": false,
"tags": [
{ "id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "not-reviewed" }
],
"cover": {
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"mime": "application/pdf",
"is_encrypted": false,
"thumb_status": "done",
"thumb_small_key": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855_thumb_s",
"thumb_medium_key": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855_thumb_m"
},
"usn": 88,
"deleted": false,
"updated_at": 1784000000000,
"created_at": 1781200000000
}
],
"paging": { "limit": 50, "offset": 0, "total": 1, "has_more": false }
}
Errors:
422 validation_failed— unknown sort field inorder.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
headers = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
resp = requests.get(
f"{BASE}/notes",
headers=headers,
params={"order": "-updated_at", "fields": "meta", "limit": 50},
)
resp.raise_for_status()
for note in resp.json()["data"]:
print(note["id"], note["title"])
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const params = new URLSearchParams({ order: "-updated_at", fields: "meta", limit: "50" });
const res = await fetch(`${BASE}/notes?${params}`, {
headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data, paging } = await res.json();
for (const note of data) console.log(note.id, note.title);
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://app.harbor.my/api/v1"
func main() {
req, _ := http.NewRequest("GET", base+"/notes?order=-updated_at&fields=meta&limit=50", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out struct {
Data []struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, n := range out.Data {
fmt.Println(n.ID, n.Title)
}
}
Create a note
POST /api/v1/notes · scope: notes
Create a note. The server resolves and validates the notebook, derives the content fields (sanitize + hash/length/word-count, or stored opaque when encrypted), and allocates a USN in the writing transaction.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | no | Client UUID; server-generated when absent. Must be valid and unused. |
notebook_id | string | no | A live notebook id. Defaults to the user’s default notebook. |
title | string | no | ≤ 255 chars (runes); ciphertext when is_encrypted. |
content | string | no | HTML or Markdown per content_format; ciphertext when is_encrypted. |
content_format | string | no | html (default) or markdown. |
is_encrypted | bool | no | Default false. Client-authoritative. |
thumbnail | string | no | "" or sha256:<64-hex> (format-validated; the blob is not verified). |
source_url | string | no | |
source | string | no | Provenance marker, ≤ 64 chars (e.g. "web.clip"). |
is_web_clip | bool | no | Default false. true stores a read-only page snapshot via the layout-preserving clip allowlist. |
author | string | no | |
latitude / longitude / altitude | number | no | |
reminder_time | int | no | Epoch ms. |
curl https://app.harbor.my/api/v1/notes \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"notebook_id": "5b1f2c9a-77d4-4e21-b9c3-2a8f6d4e1c05",
"title": "Quarterly plan",
"content": "# Hello\n\nworld",
"content_format": "markdown"
}'
Response — 201 Created:
{
"note": {
"id": "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68",
"title": "Quarterly plan",
"content": "<h1>Hello</h1>\n<p>world</p>",
"content_hash": "f1d2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70",
"content_length": 27,
"word_count": 2,
"notebook_id": "5b1f2c9a-77d4-4e21-b9c3-2a8f6d4e1c05",
"is_encrypted": false,
"thumbnail": "",
"usn": 88,
"deleted": false,
"updated_at": 1784000000000,
"created_at": 1784000000000
},
"usn": 88
}
Errors:
422 validation_failed— malformedidor JSON,notebook_idnot a live notebook, or a malformedthumbnail.422 note_title_too_long— title over 255 characters.422 note_too_large— final stored body over the 5 MiB cap.409 conflict— the suppliedidis already taken.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
headers = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
resp = requests.post(
f"{BASE}/notes",
headers=headers,
json={
"title": "Quarterly plan",
"content": "# Hello\n\nworld",
"content_format": "markdown",
},
)
resp.raise_for_status()
body = resp.json()
print(body["note"]["id"], "usn:", body["usn"])
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const res = await fetch(`${BASE}/notes`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Quarterly plan",
content: "# Hello\n\nworld",
content_format: "markdown",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { note, usn } = await res.json();
console.log(note.id, "usn:", usn);
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://app.harbor.my/api/v1"
func main() {
payload, _ := json.Marshal(map[string]string{
"title": "Quarterly plan",
"content": "# Hello\n\nworld",
"content_format": "markdown",
})
req, _ := http.NewRequest("POST", base+"/notes", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out struct {
Note struct {
ID string `json:"id"`
} `json:"note"`
USN int64 `json:"usn"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
fmt.Println(out.Note.ID, "usn:", out.USN)
}
Get a note
GET /api/v1/notes/:id · scope: notes
Fetch one note. Returns 404 when the note is missing or trashed (unless
?deleted=true).
| Field | Type | Required | Description |
|---|---|---|---|
deleted | bool | no | true returns the note even if trashed. |
format | string | no | markdown returns content as best-effort Markdown. Ignored for encrypted notes. |
The response is the bare note object — no usn wrapper, and no tags or
cover (those are list-only; fetch a note’s tags via GET /notes/:id/tags).
latitude, longitude, altitude, reminder_time, and reminder_done_time
are omitted when unset; public_shared_at is omitted when never shared;
is_public is always present with public_token "" when private;
thumbnail is always present ("" when unset).
curl https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68 \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"id": "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68",
"title": "Quarterly plan",
"content": "<p>Hello <strong>world</strong></p>",
"content_hash": "f1d2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70",
"content_length": 35,
"word_count": 2,
"notebook_id": "5b1f2c9a-77d4-4e21-b9c3-2a8f6d4e1c05",
"is_encrypted": false,
"thumbnail": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"source": "",
"is_web_clip": false,
"is_public": false,
"public_token": "",
"usn": 88,
"deleted": false,
"updated_at": 1784000000000,
"created_at": 1781200000000
}
Errors:
404 not_found— no such note, or it’s in the Trash anddeleted=truewas not passed.
Export a note as PDF
GET /api/v1/notes/:id/export.pdf · scope: notes
Export one note as a PDF, synchronously. The rendered output contains the note body, a title heading, a created/updated metadata line, and inline images. Embedded PDF attachments are merged into the document in order, each behind a labeled separator page, so the download is one self-contained file; other non-image attachments render as labeled references.
curl -L "https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/export.pdf" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-o quarterly-plan.pdf
The response is raw PDF bytes, not a JSON envelope:
Content-Type: application/pdfContent-Disposition: attachment; filename="<note title>.pdf"X-Skipped-Attachments: <n>(optional) — the count of embedded PDFs that could not be merged (e.g. corrupt or locked) and were skipped so the rest of the export still succeeded.
Export caps are configurable server-side: a per-image inline byte ceiling (oversized images degrade to a placeholder), a maximum number of embedded PDFs merged, and a maximum total output size.
Errors:
404 not_found422 encrypted_not_exportable— encrypted notes are ciphertext on the server, which cannot be rendered.422 too_many_embedded_pdfs— over the embedded-PDF merge cap.422 export_too_large— over the total output size cap.500 pdf_export_failed— the render itself failed.
Update a note
PATCH /api/v1/notes/:id · scope: notes
Partial update, last-write-wins (no locking). Only the fields present in the
body are touched; when content is present it is re-derived per
content_format. A fresh USN is allocated.
| Field | Type | Required | Description |
|---|---|---|---|
title | string | no | ≤ 255 chars (runes). |
content | string | no | Re-derived; interpreted per content_format. |
content_format | string | no | html (default) or markdown. |
notebook_id | string | no | Must be a live notebook. Moving into a default_encrypt notebook requires the note to be encrypted in the same request — see Moving a note between notebooks. |
is_encrypted | bool | no | Toggles opaque storage for this write. |
thumbnail | string | no | Set (sha256:<64-hex>) or clear (""); omit to leave unchanged. |
source_url | string | no | |
author | string | no | |
latitude / longitude / altitude | number | no | |
reminder_time | int | no | Epoch ms. |
reminder_done_time | int | no | Epoch ms. |
curl -X PATCH https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68 \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Quarterly plan (final)", "content": "<p>Updated</p>"}'
{
"note": {
"id": "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68",
"title": "Quarterly plan (final)",
"content": "<p>Updated</p>",
"usn": 89,
"deleted": false,
"updated_at": 1784000000000,
"created_at": 1781200000000
},
"usn": 89
}
Errors:
404 not_found422 validation_failed— invalid JSON,notebook_idnot a live notebook, or a malformedthumbnail.422 cannot_move_plaintext_into_encrypted— the note is plaintext andnotebook_idnames a different notebook withdefault_encrypt. Nothing is written and no USN is spent; encrypt the note in the same request instead. See Moving a note between notebooks.422 note_title_too_long422 note_too_large
Delete a note
DELETE /api/v1/notes/:id · scope: notes
Move a note to the Trash — soft and recoverable — by default. Pass
?permanent=true to expunge directly instead (the same operation as
POST /notes/:id/expunge): a permanent deleted=1 tombstone whose attachment
junctions are tombstoned, whose orphaned blobs are reclaimed, and whose OCR
text is removed from search.
| Field | Type | Required | Description |
|---|---|---|---|
permanent | bool | no | Default false. true skips the Trash and expunges permanently. |
curl -X DELETE "https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68" \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 204 No Content.
Errors:
404 not_found— no live note with that id.
Append to a note
POST /api/v1/notes/:id/append · scope: notes
Append a sanitized fragment to the end of a note’s body without sending the whole body — ideal for quick capture. The note is re-derived and a fresh USN is allocated. Encrypted notes are rejected: the server cannot splice ciphertext.
| Field | Type | Required | Description |
|---|---|---|---|
content | string | yes | Fragment to append. |
content_format | string | no | html (default) or markdown. |
curl -X POST https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/append \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "- one more thing", "content_format": "markdown"}'
{
"note": {
"id": "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68",
"usn": 90,
"deleted": false
},
"usn": 90
}
Errors:
404 not_found422 append_not_supported_encrypted— the note is encrypted.422 note_too_large— the appended body would exceed the 5 MiB cap.
Swap attachment hashes
POST /api/v1/notes/:id/attachments/swap · scope: notes
Re-point a note’s inline attachment junctions from old content hashes to new
ones and reclaim the superseded blobs. This is the server half of
client-side attachment encryption: the client re-encrypts (or decrypts)
each attached file, uploads the result under a new content hash, saves the note
with its <harbor-embed> references rewritten, then calls this endpoint.
For each pair the server links the to hash (role:"inline"), detaches the
from hash, and — after the transaction commits — reclaims each now-orphaned
from blob: the blob and its thumbnails are deleted, the resource and its OCR
result are tombstoned, and the OCR plaintext is removed from the search index.
A from blob still referenced by another live note is kept, so nothing leaks
across notes. The operation is idempotent — safe to retry after a partial
failure.
| Field | Type | Required | Description |
|---|---|---|---|
swaps | array | yes | 1–500 {from, to} pairs. Each hash is a bare 64-hex sha256 (no sha256: scheme). The to blob must already be committed. |
curl -X POST https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/attachments/swap \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"swaps": [
{
"from": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"to": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
}
]
}'
{
"swapped": 1,
"reclaimed": ["e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"]
}
swapped is the number of pairs applied; reclaimed lists the distinct old
hashes whose blobs were reclamation candidates (evict their cached bytes
client-side).
Errors:
404 not_found— unknown or trashed note.422 validation_failed— malformed hash, or an empty or oversizedswapsarray.422 swap_blob_missing— atoblob has not been committed yet.
Related
- Notebooks API — organize notes into notebooks and stacks.
- Tags API — tag notes and filter lists by tag.
- Files API — upload and download the content-addressed attachments notes embed.