Harbor
Developer docs menu

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

RuleValue
Top levelA JSON object. {} means no metadata.
SizeAt most 16 KB (16,384 bytes) of compact UTF-8 JSON per object.
Top-level keysAt most 64.
Top-level key format1–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.
ValuesAny JSON: string, number, boolean, null, array, object.
NestingAt 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.metadataCause
must be a JSON objectThe body (or the metadata field) is an array, string or number.
is 17010 bytes as compact JSON; the limit is 16384Too big.
has 65 top-level keys; the limit is 64Too 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:

RouteBodyDoesAnswers
GET …/metadata—Reads it.200 {"metadata": {…}}
PUT …/metadataThe new objectReplaces it. null clears it.200 {"metadata": {…}}
PATCH …/metadataA JSON Merge PatchChanges 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:

ParamMatches
meta.KEY=VALUERecords 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=KEYRecords that have the top-level key, whatever its value, null included. Repeatable.
has_metadata=trueRecords 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 é.
  • true and false match the stored booleans, in any case: TRUE matches true. The strings "true" and "false" match too, as strings.
  • A number matches a stored number of the same value: 5 matches 5, 5.0 and 5e0. It also matches the string "5", as a string. 05 isn’t a JSON number, so it only matches the string "05".
  • An array matches when any element directly inside it matches: {"people": ["Ana", "Sam"]} matches meta.people=ana. Arrays and objects inside the array aren’t searched.
  • An object or null never 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.