Harbor
Developer docs menu

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

FieldTypeDescription
idstringUUID. Client-supplied on create (for offline-first apps) or server-generated.
namestringDisplay name. Trimmed, 1–100 chars, unique (case-insensitive) among live notebooks. Never encrypted — always plaintext.
stackstringFree-text stack label. A notebook joins a stack by carrying its name here; empty when the notebook is not in a stack.
is_defaultboolExactly one notebook per account is the default. Set only via promotion on PATCH.
default_encryptboolThe 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_publicboolWhether the notebook is publicly shared (mirrored into the public-share registry).
usnintPer-user update sequence number. Every mutation allocates a fresh one, so sync clients can order changes.
deletedboolTombstone flag. Deleted notebooks stay visible to sync as tombstones.
updated_atintUTC epoch milliseconds.
created_atintUTC 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 with 422 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. Read is_encrypted per 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.

FieldTypeRequiredDescription
limitintnoPage size. Default 100, hard cap 500 (clamped).
offsetintnoRows to skip. Default 0.
orderstringnoSort order. Default name. Sortable: name, usn, created_at, updated_at; prefix with - for descending.
stackstringnoFilter to one stack (exact match on the label).
include_deletedboolnotrue 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 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}/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

FieldTypeRequiredDescription
idstringnoClient UUID (handy for offline-first creates). Server-generated when absent; must be a valid, unused UUID when supplied.
namestringyesTrimmed, 1–100 chars, unique (case-insensitive) among live notebooks.
stackstringnoFree-text stack label, trimmed. Matched by name to a stack.
default_encryptboolnoEncryption 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_failedname missing or too long, malformed id, or invalid JSON.
  • 409 notebook_name_exists — a live notebook already uses that name.
  • 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}/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

FieldTypeRequiredDescription
:idstring (path)yesThe notebook’s UUID.
include_deletedbool (query)notrue 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 without include_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.

FieldTypeRequiredDescription
namestringnoTrimmed, 1–100 chars, unique (case-insensitive) among live notebooks.
stackstringnoFree-text stack label, trimmed.
default_encryptboolnoEncryption 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_publicboolnoPublish or unpublish the notebook.
is_defaultboolnoPromotion 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-long name, 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.

FieldTypeRequiredDescription
:idstring (path)yesThe notebook’s UUID.
notesstring (query)noWhat 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_at set, deleted stays 0), so it appears in GET /api/v1/trash and 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.

FieldTypeRequiredDescription
idstringnoClient UUID (offline create); server-generated when absent.
namestringyesUnique 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-long name, or malformed id.
  • 409 stack_name_exists — the name is already in use by a stack.
  • 409 conflict — the supplied id is 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.

FieldTypeRequiredDescription
:namestring (path)yesThe current stack name, URL-encoded.
new_namestringyesThe 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_existsnew_name is 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-long new_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.

  • 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.