API reference
Metadata API
A small JSON object on every note, notebook and stack that belongs to your integration. Harbor stores it, syncs it, and lets you filter by it. It never shows it to anyone.
Notes, notebooks and stacks each carry an optional metadata object. It’s for
scripts, integrations and third-party apps that need to label things in their
own terms. Harbor never shows it in an app and never acts on it.
Here’s the kind of thing it’s for. Say you’re building a photo gallery on top of Harbor. You mark the notebooks that are galleries:
{ "gallery": true, "layout": "grid" }
and each photo note with what your app knows about it:
{ "album": "Summer 2026", "favorite": true, "people": ["Ana", "Sam"] }
Now GET /notebooks?meta.gallery=true finds your galleries,
GET /notes?meta.album=Summer 2026 finds an album, and the search query
beach meta:favorite=true finds favorites with “beach” in them. None of it
shows up in the user’s Harbor apps.
It’s a place for labels, not a document store. Keep it small.
The routes for each record type live on their own pages: notes, notebooks and stacks.
Limits
| Rule | Value |
|---|---|
| Top level | A JSON object. {} means no metadata. |
| Size | At most 16 KB (16,384 bytes) of compact UTF-8 JSON per object. |
| Top-level keys | At most 64. |
| Top-level key format | 1–64 characters of A-Z a-z 0-9 _ -, case-sensitive. No . or :, so meta.KEY and meta:KEY=VALUE always parse. Keys inside nested objects can be any string. |
| Values | Any JSON: string, number, boolean, null, array, object. |
| Nesting | At most 5 levels. The top-level object is level 1; each nested object or array adds one. |
Harbor stores the object as compact JSON with its keys sorted. Numbers keep
their exact text, so 1.50 stays 1.50 and a 64-bit id is never rounded. The
size limit is measured on that stored form, so whitespace in your request
doesn’t count against it.
A value that breaks a rule is refused with 422 validation_failed, and
details.metadata says which rule:
{
"error": {
"code": "validation_failed",
"message": "The request was invalid.",
"details": { "metadata": "has 65 top-level keys; the limit is 64" }
}
}
The messages you’ll see:
details.metadata | Cause |
|---|---|
must be a JSON object | The body (or the metadata field) is an array, string or number. |
is 17010 bytes as compact JSON; the limit is 16384 | Too big. |
has 65 top-level keys; the limit is 64 | Too many keys. |
key "bad.key" is not allowed: keys are 1-64 characters of A-Z, a-z, 0-9, '_' or '-' | A top-level key breaks the format. |
nests more than 5 levels deep (the top-level object is level 1) | Too deep. |
A request body over 1 MiB is refused before any of that, with
413 payload_too_large.
Reading it
Every note, notebook and stack in every API response carries metadata. It’s
always there, and it’s {} when empty. That includes list rows, the Trash,
GET /tags/:id/notes, and sync pull.
On an encrypted note, metadata is plain text. Harbor stores and returns it unencrypted, the same as a note’s tags. Don’t put secrets in it.
Writing it
Only the REST API writes metadata. You have two ways in.
Alongside the record. POST /notes and POST /notebooks accept a
metadata field. So do PATCH /notes/:id and PATCH /notebooks/:id, where it
replaces the whole object: {} or null clears it, and leaving the field
out leaves it alone.
Through the metadata routes. Each record type has four:
| Route | Body | Does | Answers |
|---|---|---|---|
GET …/metadata | — | Reads it. | 200 {"metadata": {…}} |
PUT …/metadata | The new object | Replaces it. null clears it. | 200 {"metadata": {…}} |
PATCH …/metadata | A JSON Merge Patch | Changes some keys. | 200 {"metadata": {…}} |
DELETE …/metadata | — | Clears it. | 204 No Content |
PATCH follows JSON Merge Patch (RFC 7396):
a key set to null is removed, nested objects merge, and anything else is set.
The limits are checked on the result. A null body clears everything; any other
body that isn’t an object is refused.
Say a note stores this:
{ "album": "Summer 2026", "rating": 4, "location": { "city": "Lisbon" } }
and you PATCH it with:
{ "rating": null, "location": { "country": "PT" }, "favorite": true }
The note now holds:
{ "album": "Summer 2026", "favorite": true, "location": { "city": "Lisbon", "country": "PT" } }
Request bodies are the bare object. Responses wrap it under metadata, so a key
of your own called error or data is never mistaken for the API envelope.
A metadata-only change isn’t an edit. It gives the record a new usn, so
every device pulls the new value, but it doesn’t change updated_at, doesn’t
save a history version, and doesn’t reindex search. Writing exactly what’s
already stored changes nothing at all. (A PATCH /notes/:id that sends
metadata and another field is an ordinary edit.)
Metadata writes obey the same freeze as any other write: on a read-only account
every one is refused 403 plan_limit_reached, DELETE included. Reading keeps
working.
Filtering lists
GET /notes, GET /notebooks and GET /stacks all accept:
| Param | Matches |
|---|---|
meta.KEY=VALUE | Records whose top-level key KEY equals VALUE (see the matching rule). Repeatable; every one must match. An empty value means “equals the empty string”. |
meta_has=KEY | Records that have the top-level key, whatever its value, null included. Repeatable. |
has_metadata=true | Records with any metadata at all. false: records with none. |
They combine with each other and with every other filter on the endpoint, and
paging.total counts the filtered set.
curl -G https://app.harbor.my/api/v1/notes \
-H "Authorization: Bearer $HARBOR_TOKEN" \
--data-urlencode "meta.album=Summer 2026" \
--data-urlencode "meta.favorite=true" \
--data-urlencode "fields=meta"
A bad key, or a has_metadata that isn’t a boolean, is a
422 validation_failed with details keyed by the parameter:
{
"error": {
"code": "validation_failed",
"message": "The request was invalid.",
"details": {
"meta.bad.key": "key \"bad.key\" is not allowed: keys are 1-64 characters of A-Z, a-z, 0-9, '_' or '-'"
}
}
}
Searching
The search endpoint has a meta: operator that uses the same matching rule:
meta:favorite=true, meta:album="Summer 2026", meta:album, and
-meta:favorite=true. See meta: in the Search API.
The matching rule
List filters and meta: search match the same way. Only top-level keys are
matched. Comparing a value you ask for against what’s stored:
- A string matches when it’s equal, ignoring the case of the letters A–Z.
Other letters compare exactly, so
Édoes not matché. trueandfalsematch the stored booleans, in any case:TRUEmatchestrue. The strings"true"and"false"match too, as strings.- A number matches a stored number of the same value:
5matches5,5.0and5e0. It also matches the string"5", as a string.05isn’t a JSON number, so it only matches the string"05". - An array matches when any element directly inside it matches:
{"people": ["Ana", "Sam"]}matchesmeta.people=ana. Arrays and objects inside the array aren’t searched. - An object or
nullnever matches an equality test, but it does count for “has the key”.
Every Harbor client runs the same set of test cases for this rule, so an offline search in the apps answers the way the server does.
Stacks
A stack’s metadata lives on its row in the stack registry. A stack that exists only because notebooks carry its name has no registry row; the first metadata write creates one. Clearing metadata never creates one.
Renaming a stack keeps its metadata. Deleting a stack discards it. If you recreate a stack with the same name, it starts empty.
What carries it, and what doesn’t
Account export keeps it. Each record’s JSON carries it: the per-note .json
files and notebook entries in the HTML export, and manifest.json in the ENEX
and Markdown exports (which also gets a stacks list of the stacks that have
metadata). See exporting your account.
These don’t copy it, on purpose:
- duplicating a note;
- conflict copies;
- a note created from a template;
- ENEX files, in either direction.
It never appears on a public share, in an MCP tool result, or in a single-note PDF, Markdown or HTML export.
Sync
Pull carries
metadata on note, notebook and stack records. Push never changes it. The
server ignores the field on every pushed record and keeps what it has. That
stops a client that doesn’t know about metadata from wiping it, since push
replaces a record whole.
Related
- Notes API — the note routes.
- Notebooks & Stacks API — the notebook and stack routes.
- Search API — the
meta:operator. - Errors — the
422details.