Harbor
Developer docs menu

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

FieldTypeDescription
idstringClient-supplied UUID (server-generated when omitted at create).
titlestringUp to 255 characters (runes). Opaque ciphertext when is_encrypted.
contentstringSanitized HTML fragment — an allowlist subset of standard HTML, not ENML. Opaque ciphertext when is_encrypted.
content_hashstringHash over the final stored bytes (the sanitized HTML, or the ciphertext).
content_lengthintLength of the final stored bytes. Capped at 5 MiB (note_too_large).
word_countintWord count of the stored body. Always 0 for encrypted notes.
notebook_idstringThe owning notebook.
is_encryptedboolClient-authoritative encryption flag — see Encryption below.
thumbnailstringExplicit list thumbnail: "" or a sha256:<64-hex> resource reference. Always present.
source_urlstringURL the note came from, if any.
sourcestringProvenance marker, ≤ 64 chars (e.g. "web.clip").
is_web_clipbooltrue when the body is a read-only web-page snapshot (rendered in a sandboxed viewer, not re-editable).
authorstringFree-form author string.
latitude / longitude / altitudenumberLocation metadata. Omitted from responses when unset.
reminder_timeintReminder time, epoch ms. Omitted when unset.
reminder_done_timeintWhen the reminder was completed, epoch ms. Omitted when unset.
is_publicboolPublic-share marker. Always present.
public_tokenstringShare token for building the public URL; "" when private.
public_shared_atintEpoch ms of the most recent publish. Omitted when never shared.
usnintServer-assigned update sequence number (sync ordering signal).
deletedboolTombstone flag.
updated_at / created_atintUTC 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_id back.
  • An empty notebook_id means the user’s default notebook — and if that notebook has default_encrypt, the rule applies to it.
  • The guard is on PATCH /notes/:id only. It is deliberately not on POST /notes and not on POST /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; the 422 is 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: false with the plaintext body).

List notes

GET /api/v1/notes · scope: notes

List the user’s notes. Trashed notes are excluded unless deleted=true.

FieldTypeRequiredDescription
limitintnoDefault 100, hard cap 500 (clamped).
offsetintnoDefault 0.
orderstringnoDefault -updated_at. Sortable: updated_at, created_at, title, usn (- prefix = descending).
notebook_idstringnoFilter to one notebook.
tagstringnoFilter to notes carrying this tag id.
stackstringnoNotes in any live notebook under this stack label.
updated_sinceintnoEpoch ms; returns notes with updated_at >= this value.
deletedboolnoDefault false; true includes trashed notes.
fieldsstringnometa 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), and thumb_small_key / thumb_medium_key when done. 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 in order.

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.

FieldTypeRequiredDescription
idstringnoClient UUID; server-generated when absent. Must be valid and unused.
notebook_idstringnoA live notebook id. Defaults to the user’s default notebook.
titlestringno≤ 255 chars (runes); ciphertext when is_encrypted.
contentstringnoHTML or Markdown per content_format; ciphertext when is_encrypted.
content_formatstringnohtml (default) or markdown.
is_encryptedboolnoDefault false. Client-authoritative.
thumbnailstringno"" or sha256:<64-hex> (format-validated; the blob is not verified).
source_urlstringno
sourcestringnoProvenance marker, ≤ 64 chars (e.g. "web.clip").
is_web_clipboolnoDefault false. true stores a read-only page snapshot via the layout-preserving clip allowlist.
authorstringno
latitude / longitude / altitudenumberno
reminder_timeintnoEpoch 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 — malformed id or JSON, notebook_id not a live notebook, or a malformed thumbnail.
  • 422 note_title_too_long — title over 255 characters.
  • 422 note_too_large — final stored body over the 5 MiB cap.
  • 409 conflict — the supplied id is 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).

FieldTypeRequiredDescription
deletedboolnotrue returns the note even if trashed.
formatstringnomarkdown 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 and deleted=true was 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/pdf
  • Content-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_found
  • 422 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.

FieldTypeRequiredDescription
titlestringno≤ 255 chars (runes).
contentstringnoRe-derived; interpreted per content_format.
content_formatstringnohtml (default) or markdown.
notebook_idstringnoMust 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_encryptedboolnoToggles opaque storage for this write.
thumbnailstringnoSet (sha256:<64-hex>) or clear (""); omit to leave unchanged.
source_urlstringno
authorstringno
latitude / longitude / altitudenumberno
reminder_timeintnoEpoch ms.
reminder_done_timeintnoEpoch 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_found
  • 422 validation_failed — invalid JSON, notebook_id not a live notebook, or a malformed thumbnail.
  • 422 cannot_move_plaintext_into_encrypted — the note is plaintext and notebook_id names a different notebook with default_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_long
  • 422 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.

FieldTypeRequiredDescription
permanentboolnoDefault 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.

FieldTypeRequiredDescription
contentstringyesFragment to append.
content_formatstringnohtml (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_found
  • 422 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.

FieldTypeRequiredDescription
swapsarrayyes1–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 oversized swaps array.
  • 422 swap_blob_missing — a to blob has not been committed yet.
  • 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.