Harbor
Developer docs menu

API reference

Tags API

Tags are hierarchical labels for your notes — nest them up to three levels deep, attach them by id or by name, and query notes by tag.

Tags label notes, and they nest — a tag can live under a parent tag, Evernote-style. This page covers the tag CRUD endpoints plus the note–tag relation endpoints that attach, list, replace, and detach tags on a note.

Tag CRUD requires the tags scope. The note–tag relation endpoints — including GET /api/v1/tags/:id/notes — require the notes scope instead, because tagging is note organization. Each endpoint below states its scope.

The tag object

FieldTypeDescription
idstringUUID. May be supplied by the client on create; server-generated otherwise.
namestringDisplay name. Trimmed, 1–100 characters, no commas, unique account-wide (case-insensitive) among live tags.
parent_idstringThe parent tag’s id, or "" for a top-level tag.
usnintegerUpdate Sequence Number — bumped on every mutation; drives sync.
deletedbooleanTombstone flag. Deletes are soft; tombstones sync to other devices.
created_atintegerUTC epoch milliseconds.
updated_atintegerUTC epoch milliseconds.
{
  "id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
  "name": "Receipts",
  "parent_id": "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f",
  "usn": 12,
  "deleted": false,
  "updated_at": 1750000000000,
  "created_at": 1749000000000
}

Nesting. Tags nest at most 3 levels deep by default (a top-level tag is depth 1, so tag → sub-tag → sub-sub-tag is the limit). Both create and re-parent enforce the cap, and a re-parent accounts for the height of the subtree being moved — a dragged tag carries its descendants. Violations return 422 tag_max_depth.

List tags

GET /api/v1/tags · scope: tags

Lists your tags. Tombstoned tags are excluded unless include_deleted=true.

FieldTypeRequiredDescription
limitintegerNoPage size. Default 100, hard cap 500 (clamped).
offsetintegerNoRows to skip. Default 0.
orderstringNoSort field: name (default), usn, created_at, updated_at. Prefix - for descending.
parent_idstringNoAbsent = all tags. Empty (parent_id=) = top-level tags only. A tag id = that tag’s direct children.
include_deletedbooleanNotrue includes tombstones. Default false.
curl "https://app.harbor.my/api/v1/tags?order=name" \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{
  "data": [
    {
      "id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
      "name": "Receipts",
      "parent_id": "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f",
      "usn": 12,
      "deleted": false,
      "updated_at": 1750000000000,
      "created_at": 1749000000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}

Errors:

  • 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}/tags", headers=headers, params={"order": "name"})
resp.raise_for_status()
for tag in resp.json()["data"]:
    print(tag["name"], tag["id"])

JavaScript

const BASE = "https://app.harbor.my/api/v1";

const resp = await fetch(`${BASE}/tags?order=name`, {
  headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const { data } = await resp.json();
for (const tag of data) console.log(tag.name, tag.id);

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+"/tags?order=name", 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"`
			ParentID string `json:"parent_id"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		panic(err)
	}
	for _, t := range out.Data {
		fmt.Println(t.Name, t.ID)
	}
}

Create a tag

POST /api/v1/tags · scope: tags

FieldTypeRequiredDescription
idstringNoClient-supplied UUID; server-generated when absent. Must be valid and unused.
namestringYesTrimmed, 1–100 characters, may not contain a comma, unique account-wide (case-insensitive) among live tags.
parent_idstringNoA live tag’s id, or "" for top-level. Must not push the new tag past the 3-level depth cap.
curl -X POST "https://app.harbor.my/api/v1/tags" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Receipts", "parent_id": "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f"}'

Returns 201 Created with the tag object, bare (not wrapped in data):

{
  "id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
  "name": "Receipts",
  "parent_id": "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f",
  "usn": 12,
  "deleted": false,
  "updated_at": 1750000000000,
  "created_at": 1749000000000
}

Errors:

  • 422 validation_failedname missing, contains a comma, or too long; malformed id; invalid JSON.
  • 409 tag_name_exists — a live tag already has that name (case-insensitive).
  • 409 conflict — the supplied id is already taken.
  • 404 not_foundparent_id is not a live tag.
  • 422 tag_max_depth — the new tag would nest past the depth cap.

Get a tag

GET /api/v1/tags/:id · scope: tags

FieldTypeRequiredDescription
include_deletedbooleanNotrue returns a tombstoned tag instead of 404.
curl "https://app.harbor.my/api/v1/tags/7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Returns 200 OK with the tag object, bare.

Errors:

  • 404 not_found — the tag is missing or tombstoned (and include_deleted was not set).

Update a tag

PATCH /api/v1/tags/:id · scope: tags

Renames and/or re-parents a tag. Only the fields you send are touched; the tag gets a fresh usn.

FieldTypeRequiredDescription
namestringNoSame rules as create: non-empty, no comma, ≤ 100 characters, unique among live tags.
parent_idstringNoA live tag’s id, or "" to promote the tag to top-level.

Two structural checks apply to parent_id:

  • Cycles. A tag may not be its own parent, and the server walks the proposed parent chain and rejects any move that would create a cycle (422 tag_cycle).
  • Depth. The move is rejected when the new position would nest the tag — or any of its descendants — past the 3-level cap: the dragged tag carries its subtree (422 tag_max_depth). Promoting to top-level (parent_id: "") can never deepen the tree, so it is always allowed.
curl -X PATCH "https://app.harbor.my/api/v1/tags/7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Receipts 2026"}'

Returns 200 OK with the updated tag object, bare:

{
  "id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
  "name": "Receipts 2026",
  "parent_id": "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f",
  "usn": 13,
  "deleted": false,
  "updated_at": 1750000000000,
  "created_at": 1749000000000
}

Errors:

  • 404 not_found — the tag, or the requested parent_id, is missing or tombstoned.
  • 422 validation_failed — invalid name or invalid JSON.
  • 409 tag_name_exists — the new name collides with a live tag.
  • 422 tag_cycle — self-parent or a cycle in the parent chain.
  • 422 tag_max_depth — the move would nest the tag or its descendants past the cap.

Delete a tag

DELETE /api/v1/tags/:id · scope: tags

Tombstones a tag. The server first untags every note carrying it (tombstoning those note–tag junctions), then re-parents or orphans the tag’s children per ?children=, then tombstones the tag itself. Each affected record gets its own new usn, so the whole cascade syncs cleanly.

FieldTypeRequiredDescription
childrenstringNoWhat happens to the tag’s children. reparent_to_grandparent (default) moves them up to this tag’s parent; orphan makes them top-level (parent_id: "").
curl -X DELETE "https://app.harbor.my/api/v1/tags/7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51?children=orphan" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Returns 204 No Content.

Errors:

  • 404 not_found — the tag is missing or already tombstoned.

Note–tag relations

The link between a note and a tag is a first-class record — a junction with its own UUID and the standard sync columns, not a composite key. Attaching or detaching a tag is therefore an independent, offline-creatable, tombstone-able record; two devices can tag the same note offline without a collision, and at most one live junction exists per (note_id, tag_id). Detaching is a tombstone, never a hard delete.

All five endpoints in this section require the notes scope, not tags — tagging is note organization.

FieldTypeDescription
idstringThe junction’s own UUID.
note_idstringThe note being tagged.
tag_idstringThe tag applied.
usnintegerUpdate Sequence Number for this junction.
deletedbooleanTombstone flag.
created_atintegerUTC epoch milliseconds.
updated_atintegerUTC epoch milliseconds.
{
  "id": "b4d90c3f-5e7a-4d2b-9f8c-6a1e3b0d7c5e",
  "note_id": "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a",
  "tag_id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
  "usn": 91,
  "deleted": false,
  "updated_at": 1750000000000,
  "created_at": 1750000000000
}

List a note’s tags

GET /api/v1/notes/:id/tags · scope: notes

Lists the live tags attached to a note, paged. Returns tag objects (see the tag object), not junctions.

FieldTypeRequiredDescription
limitintegerNoPage size. Default 100, hard cap 500 (clamped).
offsetintegerNoRows to skip. Default 0.
orderstringNoSort field: name (default), usn, created_at, updated_at.
curl "https://app.harbor.my/api/v1/notes/9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a/tags" \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{
  "data": [
    {
      "id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
      "name": "Receipts",
      "parent_id": "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f",
      "usn": 12,
      "deleted": false,
      "updated_at": 1750000000000,
      "created_at": 1749000000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}

Errors:

  • 404 not_found — the note is missing or trashed.

Attach a tag to a note

POST /api/v1/notes/:id/tags · scope: notes

Attaches a tag by existing id or by name. Sending a tag_name with no matching live tag creates the tag — this is the tag-by-typing flow. The call is idempotent: if a live junction already exists you get 200 with the existing junction instead of a duplicate.

Send exactly one of:

FieldTypeRequiredDescription
tag_idstringOne of the twoAn existing live tag’s id.
tag_namestringOne of the twoAn existing live tag’s name (case-insensitive), or a new tag to create.
curl -X POST "https://app.harbor.my/api/v1/notes/9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a/tags" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tag_name": "Receipts"}'

Returns 201 Created (new junction) or 200 OK (a live junction already existed) — either way, the junction object, bare:

{
  "id": "b4d90c3f-5e7a-4d2b-9f8c-6a1e3b0d7c5e",
  "note_id": "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a",
  "tag_id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
  "usn": 91,
  "deleted": false,
  "updated_at": 1750000000000,
  "created_at": 1750000000000
}

Errors:

  • 404 not_found — the note is missing or trashed, or tag_id is not a live tag.
  • 422 validation_failed — neither tag_id nor tag_name given; invalid JSON; or a new tag_name is invalid (blank, contains a comma, or too long).

Python

import os
import requests

BASE = "https://app.harbor.my/api/v1"
headers = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}

note_id = "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a"
resp = requests.post(f"{BASE}/notes/{note_id}/tags",
                     headers=headers, json={"tag_name": "Receipts"})
resp.raise_for_status()
junction = resp.json()
# 201 = newly attached, 200 = was already attached
print(resp.status_code, junction["tag_id"])

JavaScript

const BASE = "https://app.harbor.my/api/v1";
const noteId = "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a";

const resp = await fetch(`${BASE}/notes/${noteId}/tags`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ tag_name: "Receipts" }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const junction = await resp.json();
// 201 = newly attached, 200 = was already attached
console.log(resp.status, junction.tag_id);

Go

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const base = "https://app.harbor.my/api/v1"

func main() {
	noteID := "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a"
	body, _ := json.Marshal(map[string]string{"tag_name": "Receipts"})

	req, _ := http.NewRequest("POST", base+"/notes/"+noteID+"/tags", 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 junction struct {
		ID    string `json:"id"`
		TagID string `json:"tag_id"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&junction); err != nil {
		panic(err)
	}
	// 201 = newly attached, 200 = was already attached
	fmt.Println(resp.StatusCode, junction.TagID)
}

Replace a note’s tags

PUT /api/v1/notes/:id/tags · scope: notes

Replaces a note’s complete tag set in one call. The server diffs tag_ids against the note’s current live junctions: it creates junctions for added tags, tombstones junctions for removed tags (each with its own usn), and leaves unchanged ones alone.

FieldTypeRequiredDescription
tag_idsstring[]YesThe exact set of live tag ids the note should carry. [] removes all tags.
curl -X PUT "https://app.harbor.my/api/v1/notes/9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a/tags" \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tag_ids": ["7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51", "1f0b8c2d-6a4e-4f3b-9d7c-2e5a0b1c8d9f"]}'

Returns 200 OK with the resulting live junctions as a collection. The paging block reflects the returned set — there is no real paging here:

{
  "data": [
    {
      "id": "b4d90c3f-5e7a-4d2b-9f8c-6a1e3b0d7c5e",
      "note_id": "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a",
      "tag_id": "7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51",
      "usn": 92,
      "deleted": false,
      "updated_at": 1750000000000,
      "created_at": 1750000000000
    }
  ],
  "paging": { "limit": 1, "offset": 0, "total": 1, "has_more": false }
}

Errors:

  • 404 not_found — the note, or any requested tag_id, is missing or tombstoned.
  • 422 validation_failed — invalid JSON.

Detach a tag from a note

DELETE /api/v1/notes/:id/tags/:tag_id · scope: notes

Tombstones the live junction. Idempotent: if no live junction exists, it still returns 204.

curl -X DELETE "https://app.harbor.my/api/v1/notes/9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a/tags/7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Returns 204 No Content.

Errors:

  • 404 not_found — the note is missing or trashed.

List notes with a tag

GET /api/v1/tags/:id/notes · scope: notes

Lists the live notes carrying a tag, paged. Returns note objects (see the Notes API).

Scope trap: despite living under /tags/, this endpoint requires the notes scope — it returns note content, so a token with only tags gets 403 insufficient_scope.

FieldTypeRequiredDescription
limitintegerNoPage size. Default 100, hard cap 500 (clamped).
offsetintegerNoRows to skip. Default 0.
orderstringNoSort field: -updated_at (default), updated_at, created_at, title, usn. Prefix - for descending.
notebook_idstringNoFurther filter the results to one notebook.
curl "https://app.harbor.my/api/v1/tags/7a3c5e91-4b2d-4c8f-a1e6-9d0b3f7c2a51/notes?limit=20" \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{
  "data": [
    {
      "id": "9c2e4f7b-1d3a-4e6c-8b5f-7a0d2e9c4b1a",
      "title": "Quarterly plan",
      "notebook_id": "5b1f2c9a-3d7e-4a8b-9c0d-1e2f3a4b5c6d",
      "usn": 88,
      "deleted": false,
      "updated_at": 1750000000000,
      "created_at": 1749000000000
    }
  ],
  "paging": { "limit": 20, "offset": 0, "total": 1, "has_more": false }
}

Each item is a full note object — the example above is abridged; see the Notes API for every field.

  • Notes API — the notes these tags organize.
  • Sync API — how tags and note–tag junctions flow between devices via USNs and tombstones.
  • Errors — the error envelope and status codes used above.