API reference
Notebooks & Stacks API
Notebooks are the containers your notes live in, and stacks group notebooks — this page covers full CRUD for both, plus the background jobs that dispose of a deleted notebook's notes.
A notebook holds notes; a stack holds notebooks. Notes never live in a stack directly — the hierarchy is always note → notebook → stack. Every account has exactly one default notebook (new notes land there unless you say otherwise, and it can never be deleted).
All endpoints on this page require a bearer token with the
notebooks scope.
The notebook object
| Field | Type | Description |
|---|---|---|
id | string | UUID. Client-supplied on create (for offline-first apps) or server-generated. |
name | string | Display name. Trimmed, 1–100 chars, unique (case-insensitive) among live notebooks. Never encrypted — always plaintext. |
stack | string | Free-text stack label. A notebook joins a stack by carrying its name here; empty when the notebook is not in a stack. |
is_default | bool | Exactly one notebook per account is the default. Set only via promotion on PATCH. |
default_encrypt | bool | The notebook’s encryption default: new notes created here are encrypted, and a plaintext note moved here is encrypted on the way in. See default_encrypt is an entry rule. |
is_public | bool | Whether the notebook is publicly shared (mirrored into the public-share registry). |
usn | int | Per-user update sequence number. Every mutation allocates a fresh one, so sync clients can order changes. |
deleted | bool | Tombstone flag. Deleted notebooks stay visible to sync as tombstones. |
updated_at | int | UTC epoch milliseconds. |
created_at | int | UTC epoch milliseconds. |
{
"id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"name": "Work",
"stack": "Projects",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1749000000000
}
Notebooks are syncable records: writes allocate a USN in the same transaction, deletes are tombstones, and conflicts resolve last-write-wins.
default_encrypt is an entry rule
This field used to be documented as a client hint that “never changes existing notes”. That was true when the only thing it affected was note creation. It is now an entry invariant: a note becomes encrypted when it enters the notebook, whether it is created there or moved there.
Four things follow. The third is the one that trips people up; the fourth is a deliberate exception:
- Creating a note in the notebook: the client encrypts it. Unchanged.
- Moving a plaintext note in: the client must encrypt it and move it in the
same
PATCH, or the server rejects the write with422 cannot_move_plaintext_into_encrypted. See Moving a note between notebooks. - Toggling the flag on a notebook that already holds notes: still changes nothing about those notes. It never re-encrypts, and never decrypts, what is already inside. Only entering triggers encryption.
- Restoring from the Trash is not covered. A note whose original notebook
was deleted is restored into the account’s default notebook through
its own path, which the guard does not sit
on. If that default notebook has
default_encrypt, a plaintext note lands inside it. This is a known, deliberate gap rather than an oversight — restore is unchanged by the entry rule — so do not assume every note in an encrypting notebook is ciphertext. Readis_encryptedper note.
Moving a note out is equally inert — an encrypted note stays encrypted wherever it goes. There is no automatic decryption anywhere in the API.
The server holds no keys, so it can only refuse a bad write; it can never perform the encryption itself. That is why the guard exists on the REST move path and why a server-side ENEX import into an encrypting notebook is rejected outright rather than sealed — see Import & Export.
List notebooks
GET /api/v1/notebooks · scope: notebooks
Returns the standard collection envelope. Tombstoned notebooks are excluded unless you ask for them.
| Field | Type | Required | Description |
|---|---|---|---|
limit | int | no | Page size. Default 100, hard cap 500 (clamped). |
offset | int | no | Rows to skip. Default 0. |
order | string | no | Sort order. Default name. Sortable: name, usn, created_at, updated_at; prefix with - for descending. |
stack | string | no | Filter to one stack (exact match on the label). |
include_deleted | bool | no | true includes tombstones. Default false. |
curl "https://app.harbor.my/api/v1/notebooks?order=-updated_at&stack=Projects" \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"data": [
{
"id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"name": "Work",
"stack": "Projects",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1749000000000
}
],
"paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}
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}/notebooks", headers=headers,
params={"order": "-updated_at"})
resp.raise_for_status()
for nb in resp.json()["data"]:
print(nb["name"], "·", nb["stack"] or "(no stack)")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const resp = await fetch(`${BASE}/notebooks?order=-updated_at`, {
headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const { data } = await resp.json();
for (const nb of data) console.log(nb.name, "·", nb.stack || "(no stack)");
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+"/notebooks?order=-updated_at", 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"`
Name string `json:"name"`
Stack string `json:"stack"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, nb := range out.Data {
fmt.Println(nb.Name, "·", nb.Stack)
}
}
Create a notebook
POST /api/v1/notebooks · scope: notebooks
| Field | Type | Required | Description |
|---|---|---|---|
id | string | no | Client UUID (handy for offline-first creates). Server-generated when absent; must be a valid, unused UUID when supplied. |
name | string | yes | Trimmed, 1–100 chars, unique (case-insensitive) among live notebooks. |
stack | string | no | Free-text stack label, trimmed. Matched by name to a stack. |
default_encrypt | bool | no | Encryption default for this notebook — applies to notes created here and to plaintext notes moved in. Default false. |
is_default cannot be set on create — it is ignored if sent. Promote a
notebook to default with PATCH instead.
curl -X POST https://app.harbor.my/api/v1/notebooks \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Work", "stack": "Projects", "default_encrypt": false}'
Response 201 Created — the created notebook, as a bare object (not wrapped in
data):
{
"id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"name": "Work",
"stack": "Projects",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1750000000000
}
422 validation_failed—namemissing or too long, malformedid, or invalid JSON.409 notebook_name_exists— a live notebook already uses that name.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}/notebooks", headers=headers,
json={"name": "Work", "stack": "Projects"})
resp.raise_for_status()
notebook = resp.json()
print("created", notebook["id"], "usn", notebook["usn"])
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const resp = await fetch(`${BASE}/notebooks`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Work", stack: "Projects" }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const notebook = await resp.json();
console.log("created", notebook.id, "usn", notebook.usn);
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://app.harbor.my/api/v1"
func main() {
body, _ := json.Marshal(map[string]any{
"name": "Work",
"stack": "Projects",
})
req, _ := http.NewRequest("POST", base+"/notebooks", bytes.NewReader(body))
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 nb struct {
ID string `json:"id"`
USN int `json:"usn"`
}
if err := json.NewDecoder(resp.Body).Decode(&nb); err != nil {
panic(err)
}
fmt.Println("created", nb.ID, "usn", nb.USN)
}
Get a notebook
GET /api/v1/notebooks/:id · scope: notebooks
| Field | Type | Required | Description |
|---|---|---|---|
:id | string (path) | yes | The notebook’s UUID. |
include_deleted | bool (query) | no | true returns a tombstoned notebook instead of 404. |
curl https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2 \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response 200 OK — the notebook, as a bare object:
{
"id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"name": "Work",
"stack": "Projects",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1749000000000
}
404 not_found— missing, or tombstoned withoutinclude_deleted=true.
Update a notebook
PATCH /api/v1/notebooks/:id · scope: notebooks
Partial update: only the fields present in the body are touched, and a fresh USN is allocated.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | Trimmed, 1–100 chars, unique (case-insensitive) among live notebooks. |
stack | string | no | Free-text stack label, trimmed. |
default_encrypt | bool | no | Encryption default for this notebook. Toggling it does not re-encrypt or decrypt the notes already inside — it applies to notes created here and moved in from now on. |
is_public | bool | no | Publish or unpublish the notebook. |
is_default | bool | no | Promotion only. true makes this notebook the default and transactionally clears the previous default (which gets its own new USN). false is rejected — there must always be exactly one default. |
curl -X PATCH https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2 \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Work — Active", "is_default": true}'
Response 200 OK — the updated notebook, as a bare object:
{
"id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"name": "Work — Active",
"is_default": true,
"usn": 43,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1749000000000
}
422 validation_failed— blank/too-longname, or invalid JSON.409 notebook_name_exists— the new name collides with another live notebook.422 cannot_unset_default— you sent"is_default": false. Promote another notebook instead.
Delete a notebook
DELETE /api/v1/notebooks/:id · scope: notebooks
Tombstones the notebook (with a fresh USN). The notebook row disappears from lists and sync immediately; its notes are then disposed of by an async background cascade job in batches, so deleting a notebook with thousands of notes never stalls the request. Each affected note gets its own new USN as it is processed.
| Field | Type | Required | Description |
|---|---|---|---|
:id | string (path) | yes | The notebook’s UUID. |
notes | string (query) | no | What happens to the notebook’s notes. move_to_default (default) or trash. |
The two dispositions:
move_to_default— reassigns every note (including trashed ones) to the default notebook and reindexes each so search reflects its new home.trash— runs the single-note delete pipeline on each note: the note is trashed (in_trash=1,trashed_atset,deletedstays0), so it appears inGET /api/v1/trashand is individually restorable. Attachment blobs are retained until a real expunge / empty-trash. Notes already in the Trash are left untouched.
curl -X DELETE "https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2?notes=trash" \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response 202 Accepted — the cascade job’s status row, wrapped in data:
{
"data": {
"id": "a4f6d3e1-7b28-4c50-8a9e-16f4c2d90b73",
"notebook_id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"notebook_name": "Work",
"mode": "trash",
"status": "queued",
"total_notes": 1234,
"processed_notes": 0,
"error": "",
"updated_at": 1750000000000,
"created_at": 1750000000000
}
}
A notebook with no notes to dispose of completes inline; otherwise poll the
job (below) until it reaches completed or failed.
404 not_found— no such notebook.422 cannot_delete_default— the default notebook cannot be deleted. Promote another notebook first.
Poll a notebook-delete job
GET /api/v1/notebook-delete-jobs/:id · scope: notebooks
Poll one cascade job returned by DELETE /api/v1/notebooks/:id. status is
queued | running | completed | failed; processed_notes climbs to
total_notes as batches complete. Jobs are operational per-user state, not
syncable records.
curl https://app.harbor.my/api/v1/notebook-delete-jobs/a4f6d3e1-7b28-4c50-8a9e-16f4c2d90b73 \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"data": {
"id": "a4f6d3e1-7b28-4c50-8a9e-16f4c2d90b73",
"notebook_id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"notebook_name": "Work",
"mode": "trash",
"status": "running",
"total_notes": 1234,
"processed_notes": 400,
"error": "",
"updated_at": 1750000000000,
"created_at": 1750000000000
}
}
404 not_found— unknown id, or the job belongs to another user.
List notebook-delete jobs
GET /api/v1/notebook-delete-jobs · scope: notebooks
Lists the caller’s in-flight cascade jobs (queued/running), newest
first — so a client reloading mid-cascade can rediscover them. When nothing is
in flight, the single most recent job that reached a terminal status within
the last 2 minutes is returned instead, so a reload landing just after
completion still reflects the result.
curl https://app.harbor.my/api/v1/notebook-delete-jobs \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"data": [
{
"id": "a4f6d3e1-7b28-4c50-8a9e-16f4c2d90b73",
"notebook_id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"mode": "trash",
"status": "running",
"total_notes": 1234,
"processed_notes": 400,
"error": ""
}
]
}
Stacks
A stack is a named grouping of notebooks. Membership is Evernote-faithful: a
notebook belongs to a stack via its free-text stack label, matched by
name — there is no stack_id foreign key, so ENEX import/export round-trips
a notebook’s stack unchanged. On top of that, Harbor keeps a small stack
registry (a syncable stack record: id + name + the sync columns) that
gives a stack stable identity and lets an empty stack persist across
devices.
The stack set you see is therefore the union of the distinct non-empty
notebook stack labels and the registry rows. There is deliberately no
unique-name constraint at the storage layer (sync is last-write-wins); name
uniqueness is enforced at the API layer only. Stacks are never encrypted and
carry no encryption default of their own — default_encrypt
is a notebook-level flag, and the ciphertext itself lives on the note.
GET returns each stack as a name plus its live member count:
{ "name": "Projects", "notebook_count": 3 }
POST returns the created registry record — the full syncable object:
{
"id": "9c3e7a51-2f84-4d16-b0aa-64e19d5c8f07",
"name": "Projects",
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1749000000000
}
List stacks
GET /api/v1/stacks · scope: notebooks
Lists every stack — the union of notebook labels and registry rows — sorted by
name (case-insensitive). Stacks are few, so the whole set is returned in one
page. Empty stacks report a notebook_count of 0.
curl https://app.harbor.my/api/v1/stacks \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"data": [
{ "name": "Archive", "notebook_count": 1 },
{ "name": "Empty", "notebook_count": 0 },
{ "name": "Projects", "notebook_count": 3 }
],
"paging": { "limit": 3, "offset": 0, "total": 3, "has_more": false }
}
Create a stack
POST /api/v1/stacks · scope: notebooks
Creates a new, empty stack — a registry row, with no phantom same-named
notebook. Add notebooks by setting their stack label on create or PATCH.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | no | Client UUID (offline create); server-generated when absent. |
name | string | yes | Unique case-insensitively among live stacks. A stack “exists” if either a registry row or any live notebook already uses the name. |
curl -X POST https://app.harbor.my/api/v1/stacks \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Projects"}'
Response 201 Created — the registry record (a USN is allocated in the
writing transaction):
{
"id": "9c3e7a51-2f84-4d16-b0aa-64e19d5c8f07",
"name": "Projects",
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1749000000000
}
422 validation_failed— missing/blank/over-longname, or malformedid.409 stack_name_exists— the name is already in use by a stack.409 conflict— the suppliedidis already taken.
Rename a stack
PATCH /api/v1/stacks/:name · scope: notebooks
Renames a stack. :name is the current stack name, URL-encoded. The
rename relabels every live notebook in the stack (each gets a fresh USN) and
renames the registry row if one exists, so both members and an empty stack
follow.
| Field | Type | Required | Description |
|---|---|---|---|
:name | string (path) | yes | The current stack name, URL-encoded. |
new_name | string | yes | The new name. A case-only change of the same stack is allowed. |
curl -X PATCH https://app.harbor.my/api/v1/stacks/Projects \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"new_name": "Projects 2026"}'
Response 200 OK — the updated list object:
{ "name": "Projects 2026", "notebook_count": 3 }
409 stack_name_exists—new_nameis already used by a different stack.404 stack_not_found— no stack (no members and no registry row) uses:name.422 validation_failed— missing/blank/over-longnew_name.
Delete a stack
DELETE /api/v1/stacks/:name · scope: notebooks
Deletes a stack by ungrouping it: every live notebook’s stack label is
cleared (each gets a fresh USN) — the notebooks are kept, just moved out of
the stack — and any registry row is tombstoned. Never touches is_default.
curl -X DELETE https://app.harbor.my/api/v1/stacks/Projects%202026 \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response 204 No Content (empty body).
404 stack_not_found— no stack uses:name.
Because :name travels in the URL path, a stack name containing a literal /
cannot be addressed by PATCH or DELETE. Rename such a stack by updating
its member notebooks’ stack labels instead.
Related
- Notes API — the notes that live inside notebooks, including note-level encryption and the Trash.
- Sync API — how notebook and stack changes flow to other devices via USNs and tombstones.
- Conventions — envelopes, pagination, error codes, and the epoch-ms time format used here.