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) — see the default notebook.

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.
workspacestringThe 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_defaultboolExactly one notebook per account is the default. Set it with promotion on PATCH; see the default notebook.
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).
metadataobjectYour integration’s own labels — see notebook metadata. Always present; {} when empty.
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",
  "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": false is refused 422 cannot_unset_default.
  • You can’t delete it. DELETE is refused 422 cannot_delete_default. Promote another notebook first.
  • It can’t encrypt by default. A notebook with default_encrypt: true can’t be the default, and the default can’t have default_encrypt turned 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 switches default_encrypt off 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 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).
workspacestringnoFilter to one workspace’s notebooks (exact match on the id).
meta.KEYstringnoNotebooks whose metadata key KEY equals the value. Repeatable; all must match.
meta_hasstringnoNotebooks whose metadata has this top-level key. Repeatable.
has_metadataboolnotrue: notebooks with any metadata. false: notebooks with none.
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",
      "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 in order, or a malformed metadata filter (details names 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

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.
workspacestringnoWorkspace id, trimmed. "" (the default) puts it in no workspace.
metadataobjectnoYour 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 — name missing or too long, malformed id, invalid JSON, or metadata breaking a limit.
  • 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",
  "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 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.
workspacestringnoMove the notebook to another workspace. "" takes it out of its workspace.
metadataobjectnoReplaces 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_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. 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-long name, invalid JSON, or metadata breaking 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 and default_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.gate and details.reason are account_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.

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": ""
    }
  ]
}

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.metadata says 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.

FieldTypeRequiredDescription
limitintnoBy default the whole set comes back in one page, however many stacks there are. Pass a limit to page; it’s capped at 500.
offsetintnoRows to skip. Default 0.
orderstringnoname (default) or notebook_count; prefix with - for descending. Ties break by name.
meta.KEYstringnoStacks whose metadata key KEY equals the value. Repeatable; all must match.
meta_hasstringnoStacks whose metadata has this top-level key. Repeatable.
has_metadataboolnotrue: 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 in order, 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.

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",
  "metadata": {},
  "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. The stack keeps its metadata.

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, "metadata": { "client": "acme" } }
  • 409 stack_name_exists — new_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. 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.metadata says 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.
  • 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.