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). To get one for a note you already have, copy it from the note’s Info panel in any Harbor app, or read it from harbor notes list. |
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. |
metadata | object | Your integration’s own labels — see Metadata. Always present; {} when empty. Plain text even when is_encrypted. |
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": "",
"metadata": { "album": "Summer 2026", "favorite": true },
"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). |
meta.KEY | string | no | Notes whose metadata key KEY equals the value. Repeatable; all must match. |
meta_has | string | no | Notes whose metadata has this top-level key. Repeatable. |
has_metadata | bool | no | true: notes with any metadata. false: notes with none. |
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, or a malformed metadata filter (detailsnames the parameter).
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. |
metadata | object | no | Your own metadata object. {} or null for none. |
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",
"metadata": {"project": "q3-planning"}
}'
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": "",
"metadata": { "project": "q3-planning" },
"usn": 88,
"deleted": false,
"updated_at": 1784000000000,
"created_at": 1784000000000
},
"usn": 88
}
Errors:
422 validation_failed— malformedidor JSON,notebook_idnot a live notebook, a malformedthumbnail, ormetadatabreaking a limit (details.metadatasays which).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.
Export a note as Markdown
GET /api/v1/notes/:id/export.md · scope: notes
Export one note as Markdown, synchronously. The body is the same file the
whole-account Markdown export
writes — one serializer, so the file you get here and the file inside the
archive are identical: YAML frontmatter (title, tags, created, updated,
source), the rendered body, and any linked task with no anchor in the body
appended under a ## Tasks heading.
The response has two shapes, decided by the note. Read the filename from
Content-Disposition rather than assuming an extension.
| The note | Content-Type | Body |
|---|---|---|
| No attachments | text/markdown; charset=utf-8 | the .md file |
| Has attachments | application/zip | <Note Title>.md plus a files/ folder its links resolve into |
| Query | Type | Description |
|---|---|---|
zip | bool | ?zip=1 always returns the archive form, so a script gets one predictable shape. |
curl -L "https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/export.md" \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-o quarterly-plan.md
Errors:
404 not_found— no such note, or it is trashed.422 encrypted_not_exportable— encrypted notes are ciphertext on the server, so there is nothing to render.
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. |
metadata | object | no | Replaces the whole metadata object; {} or null clears it. A PATCH that sends only metadata isn’t an edit: the note gets a new usn but keeps its updated_at, and no history version is saved. |
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, a malformedthumbnail, ormetadatabreaking a limit.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.
Metadata
Every note carries a metadata object: a small bag of JSON labels that belongs
to your integration. Harbor never shows it or acts on it, but it syncs to every
device and you can filter lists and
search by it. The limits, the
key format and the matching rule are on the Metadata API
page.
On an encrypted note, metadata is plain text, the same as its tags. Don’t put secrets in it.
A metadata-only write gives the note a new usn but doesn’t touch
updated_at, save a history version, or reindex search. All four routes below
work on a note in the Trash too.
Get a note’s metadata
GET /api/v1/notes/:id/metadata · scope: notes
curl https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN"
{ "metadata": { "album": "Summer 2026", "favorite": true, "people": ["Ana", "Sam"] } }
Errors:
404 not_found— no such note, or it has been permanently deleted.
Replace a note’s metadata
PUT /api/v1/notes/:id/metadata · scope: notes
The body is the new object, and it replaces whatever was there. null clears
it.
curl -X PUT https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"album": "Summer 2026", "favorite": true, "people": ["Ana", "Sam"]}'
Response — 200 OK, the object as stored (keys sorted):
{ "metadata": { "album": "Summer 2026", "favorite": true, "people": ["Ana", "Sam"] } }
Errors:
404 not_found413 payload_too_large— the body is over 1 MiB.422 validation_failed— the object breaks a limit;details.metadatasays which.403 plan_limit_reached— the account is read-only.
Change some keys
PATCH /api/v1/notes/:id/metadata · scope: notes
The body is a JSON Merge Patch (RFC 7396).
A key set to null is removed, nested objects merge, and anything else is set.
The limits are checked on the result. A null body clears everything.
curl -X PATCH https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"people": null, "location": {"city": "Lisbon"}}'
Response — 200 OK, the merged object:
{ "metadata": { "album": "Summer 2026", "favorite": true, "location": { "city": "Lisbon" } } }
Errors: the same as PUT, plus 422 validation_failed with
details.metadata: "must be a JSON object" when the body is an array, string
or number.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
headers = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
note_id = "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68"
resp = requests.patch(
f"{BASE}/notes/{note_id}/metadata",
headers=headers,
json={"people": None, "location": {"city": "Lisbon"}},
)
resp.raise_for_status()
print(resp.json()["metadata"])
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const noteId = "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68";
const res = await fetch(`${BASE}/notes/${noteId}/metadata`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ people: null, location: { city: "Lisbon" } }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { metadata } = await res.json();
console.log(metadata);
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://app.harbor.my/api/v1"
func main() {
noteID := "9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68"
patch, _ := json.Marshal(map[string]any{
"people": nil,
"location": map[string]string{"city": "Lisbon"},
})
req, _ := http.NewRequest("PATCH", base+"/notes/"+noteID+"/metadata", bytes.NewReader(patch))
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 {
Metadata map[string]any `json:"metadata"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
fmt.Println(out.Metadata)
}
Clear a note’s metadata
DELETE /api/v1/notes/:id/metadata · scope: notes
curl -X DELETE https://app.harbor.my/api/v1/notes/9c2e6f4a-8b31-4c6e-9d2a-7f5e0c1b3a68/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 204 No Content.
Errors:
404 not_found403 plan_limit_reached— the account is read-only.
Filtering by metadata
curl -G https://app.harbor.my/api/v1/notes \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode "meta.album=Summer 2026" \
--data-urlencode "meta_has=people" \
--data-urlencode "fields=meta"
String values ignore case, so meta.album=summer 2026 finds the same notes.
meta.people=ana matches a note whose people array contains "Ana". The
full rule is in the matching rule.
Related
- Notebooks API — organize notes into notebooks and stacks.
- Tags API — tag notes and filter lists by tag.
- Metadata API — limits, matching and sync for note, notebook and stack metadata.
- Files API — upload and download the content-addressed attachments notes embed.