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) — see the default notebook.
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. |
workspace | string | The id of the workspace this notebook belongs to, or "" when it’s in none. A notebook is in at most one workspace, and its notes belong to that workspace through it. The id comes from the workspaces list in the user’s settings. The server doesn’t check it against that list. |
is_default | bool | Exactly one notebook per account is the default. Set it with promotion on PATCH; see the default notebook. |
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). |
metadata | object | Your integration’s own labels — see notebook metadata. Always present; {} when empty. |
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",
"workspace": "",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"metadata": { "gallery": true },
"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.
The default notebook
is_default marks the account’s one default notebook. It’s where a note
created with no notebook_id lands, and where templates with no notebook,
forwarded email, imports and Trash restores go.
There’s always exactly one, so the rules follow from that:
- Move it by promoting another notebook with
PATCH /notebooks/:id {"is_default": true}. The old default is demoted for you, and both notebooks get a fresh USN so every device picks up the change. - There’s no “unset”.
"is_default": falseis refused422 cannot_unset_default. - You can’t delete it.
DELETEis refused422 cannot_delete_default. Promote another notebook first. - It can’t encrypt by default. A notebook with
default_encrypt: truecan’t be the default, and the default can’t havedefault_encryptturned on —422 default_notebook_cannot_encrypt. Email, imports and notes with no notebook all land here, and none of them can be encrypted on the way in. (Sync push doesn’t refuse this; it switchesdefault_encryptoff instead.) - A read-only account can’t move it. See update a notebook.
The Settings default_notebook_id is the same flag under another name — see
the default notebook in Settings.
Sync push has its own rules for this flag; see
Sync API.
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). |
workspace | string | no | Filter to one workspace’s notebooks (exact match on the id). |
meta.KEY | string | no | Notebooks whose metadata key KEY equals the value. Repeatable; all must match. |
meta_has | string | no | Notebooks whose metadata has this top-level key. Repeatable. |
has_metadata | bool | no | true: notebooks with any metadata. false: notebooks with none. |
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",
"workspace": "",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"metadata": { "gallery": true },
"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, 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}/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. |
workspace | string | no | Workspace id, trimmed. "" (the default) puts it in no workspace. |
metadata | object | no | Your own metadata object. {} or null for none. |
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", "metadata": {"gallery": true}}'
Response 201 Created — the created notebook, as a bare object (not wrapped in
data):
{
"id": "5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2",
"name": "Work",
"stack": "Projects",
"workspace": "",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"metadata": { "gallery": true },
"usn": 42,
"deleted": false,
"updated_at": 1750000000000,
"created_at": 1750000000000
}
422 validation_failed—namemissing or too long, malformedid, invalid JSON, ormetadatabreaking a limit.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",
"workspace": "",
"is_default": false,
"default_encrypt": false,
"is_public": false,
"metadata": { "gallery": true },
"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. |
workspace | string | no | Move the notebook to another workspace. "" takes it out of its workspace. |
metadata | object | no | Replaces the whole metadata object; {} or null clears it. A PATCH that sends only metadata isn’t an edit: the notebook gets a new usn but keeps its updated_at. |
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. Promoting the notebook that’s already the default demotes nothing, but it’s still a write: the notebook gets a fresh USN and updated_at. |
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
}
404 not_found— no such notebook.422 validation_failed— blank/too-longname, invalid JSON, ormetadatabreaking a limit.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.422 default_notebook_cannot_encrypt— the notebook would end up both the default anddefault_encrypt. The check looks at the result, so{"is_default": true, "default_encrypt": false}promotes an encrypting notebook and turns its encryption default off in one go.403 plan_limit_reached— the account is read-only (details.gateanddetails.reasonareaccount_read_only). On a read-only account every notebook edit is refused, a promotion included. Deleting still works.
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": ""
}
]
}
Notebook metadata
Every notebook carries a metadata object for your integration’s own labels.
Harbor never shows it or acts on it. The limits, key format and matching rule
are on the Metadata API page.
A metadata-only write gives the notebook a new usn, so every device picks it
up, but doesn’t change updated_at.
Get a notebook’s metadata
GET /api/v1/notebooks/:id/metadata · scope: notebooks
curl https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN"
{ "metadata": { "gallery": true } }
404 not_found— no such notebook.
Replace a notebook’s metadata
PUT /api/v1/notebooks/:id/metadata · scope: notebooks
The body is the new object, and it replaces whatever was there. null clears
it.
curl -X PUT https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"gallery": true, "layout": "grid"}'
Response 200 OK — the object as stored:
{ "metadata": { "gallery": true, "layout": "grid" } }
404 not_found— no such notebook.413 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 of a notebook’s metadata
PATCH /api/v1/notebooks/:id/metadata · scope: notebooks
A JSON Merge Patch (RFC 7396): a
key set to null is removed, nested objects merge, and anything else is set. A
null body clears everything.
curl -X PATCH https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"layout": null, "sort": "taken_at"}'
Response 200 OK — the merged object:
{ "metadata": { "gallery": true, "sort": "taken_at" } }
Errors are the same as PUT.
Clear a notebook’s metadata
DELETE /api/v1/notebooks/:id/metadata · scope: notebooks
curl -X DELETE https://app.harbor.my/api/v1/notebooks/5b1f2c9a-8c1d-4e6a-9f0b-2d7c3a41e8b2/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response 204 No Content.
404 not_found— no such notebook.403 plan_limit_reached— the account is read-only.
Finding notebooks by metadata
curl -G https://app.harbor.my/api/v1/notebooks \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode "meta.gallery=true"
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={"meta.gallery": "true"})
resp.raise_for_status()
for nb in resp.json()["data"]:
print(nb["name"], nb["metadata"])
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const params = new URLSearchParams({ "meta.gallery": "true" });
const resp = await fetch(`${BASE}/notebooks?${params}`, {
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.metadata);
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
const base = "https://app.harbor.my/api/v1"
func main() {
q := url.Values{}
q.Set("meta.gallery", "true")
req, _ := http.NewRequest("GET", base+"/notebooks?"+q.Encode(), 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 {
Name string `json:"name"`
Metadata map[string]any `json:"metadata"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
panic(err)
}
for _, nb := range out.Data {
fmt.Println(nb.Name, nb.Metadata)
}
}
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 + metadata + 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, its live member count, and its
metadata:
{ "name": "Projects", "notebook_count": 3, "metadata": { "client": "acme" } }
POST returns the created registry record — the full syncable object:
{
"id": "9c3e7a51-2f84-4d16-b0aa-64e19d5c8f07",
"name": "Projects",
"metadata": {},
"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. Empty
stacks report a notebook_count of 0.
| Field | Type | Required | Description |
|---|---|---|---|
limit | int | no | By default the whole set comes back in one page, however many stacks there are. Pass a limit to page; it’s capped at 500. |
offset | int | no | Rows to skip. Default 0. |
order | string | no | name (default) or notebook_count; prefix with - for descending. Ties break by name. |
meta.KEY | string | no | Stacks whose metadata key KEY equals the value. Repeatable; all must match. |
meta_has | string | no | Stacks whose metadata has this top-level key. Repeatable. |
has_metadata | bool | no | true: stacks with any metadata. false: stacks with none, which includes every stack that exists only as a notebook label. |
Names sort case-sensitively, byte by byte, the same as every other Harbor
list. Every uppercase letter comes before every lowercase one, so apple,
Banana and Zebra come back as Banana, Zebra, apple.
curl https://app.harbor.my/api/v1/stacks \
-H "Authorization: Bearer $HARBOR_TOKEN"
{
"data": [
{ "name": "Archive", "notebook_count": 1, "metadata": {} },
{ "name": "Empty", "notebook_count": 0, "metadata": {} },
{ "name": "Projects", "notebook_count": 3, "metadata": { "client": "acme" } }
],
"paging": { "limit": 3, "offset": 0, "total": 3, "has_more": false }
}
422 validation_failed— unknown sort field inorder, or a malformed metadata filter.
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",
"metadata": {},
"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. The stack keeps its metadata.
| 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, "metadata": { "client": "acme" } }
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.
The stack’s metadata is discarded. A new stack with the same name starts
empty.
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, DELETE or the metadata routes below. Rename
such a stack by updating its member notebooks’ stack labels instead.
Stack metadata
Stacks carry a metadata object too, with the same
limits and matching rule as notes and notebooks.
It lives on the stack’s registry row, which has two consequences:
- A stack that exists only because notebooks carry its name has no registry
row, and reads as
{}. The first metadata write creates the row. Clearing metadata never creates one. - Sync allows two registry rows with the same name. When that happens, reads return the oldest row’s metadata and writes update all of them.
Renaming a stack keeps its metadata. Deleting a stack discards it.
Get a stack’s metadata
GET /api/v1/stacks/:name/metadata · scope: notebooks
curl https://app.harbor.my/api/v1/stacks/Projects/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN"
{ "metadata": { "client": "acme" } }
404 stack_not_found— no stack uses:name.
Replace a stack’s metadata
PUT /api/v1/stacks/:name/metadata · scope: notebooks
The body is the new object; null clears it. Each registry row gets a fresh
usn; updated_at doesn’t move.
curl -X PUT https://app.harbor.my/api/v1/stacks/Projects/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"client": "acme", "billable": true}'
Response 200 OK:
{ "metadata": { "billable": true, "client": "acme" } }
404 stack_not_found— no stack uses:name.413 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 of a stack’s metadata
PATCH /api/v1/stacks/:name/metadata · scope: notebooks
A JSON Merge Patch (RFC 7396),
applied to the stack’s current value. A null body clears everything.
curl -X PATCH https://app.harbor.my/api/v1/stacks/Projects/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"billable": null, "rate": 150}'
Response 200 OK:
{ "metadata": { "client": "acme", "rate": 150 } }
Errors are the same as PUT.
Clear a stack’s metadata
DELETE /api/v1/stacks/:name/metadata · scope: notebooks
curl -X DELETE https://app.harbor.my/api/v1/stacks/Projects/metadata \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response 204 No Content.
404 stack_not_found— no stack uses:name.403 plan_limit_reached— the account is read-only.
Related
- Notes API — the notes that live inside notebooks, including note-level encryption and the Trash.
- Metadata API — limits, matching and sync for notebook and stack metadata.
- 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.