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
| Field | Type | Description |
|---|---|---|
id | string | UUID. May be supplied by the client on create; server-generated otherwise. |
name | string | Display name. Trimmed, 1–100 characters, no commas, unique account-wide (case-insensitive) among live tags. |
parent_id | string | The parent tag’s id, or "" for a top-level tag. |
usn | integer | Update Sequence Number — bumped on every mutation; drives sync. |
deleted | boolean | Tombstone flag. Deletes are soft; tombstones sync to other devices. |
created_at | integer | UTC epoch milliseconds. |
updated_at | integer | UTC 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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size. Default 100, hard cap 500 (clamped). |
offset | integer | No | Rows to skip. Default 0. |
order | string | No | Sort field: name (default), usn, created_at, updated_at. Prefix - for descending. |
parent_id | string | No | Absent = all tags. Empty (parent_id=) = top-level tags only. A tag id = that tag’s direct children. |
include_deleted | boolean | No | true 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 inorder.
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
| Field | Type | Required | Description |
|---|---|---|---|
id | string | No | Client-supplied UUID; server-generated when absent. Must be valid and unused. |
name | string | Yes | Trimmed, 1–100 characters, may not contain a comma, unique account-wide (case-insensitive) among live tags. |
parent_id | string | No | A 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_failed—namemissing, contains a comma, or too long; malformedid; invalid JSON.409 tag_name_exists— a live tag already has that name (case-insensitive).409 conflict— the suppliedidis already taken.404 not_found—parent_idis 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
| Field | Type | Required | Description |
|---|---|---|---|
include_deleted | boolean | No | true 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 (andinclude_deletedwas 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.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Same rules as create: non-empty, no comma, ≤ 100 characters, unique among live tags. |
parent_id | string | No | A 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 requestedparent_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.
| Field | Type | Required | Description |
|---|---|---|---|
children | string | No | What 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.
| Field | Type | Description |
|---|---|---|
id | string | The junction’s own UUID. |
note_id | string | The note being tagged. |
tag_id | string | The tag applied. |
usn | integer | Update Sequence Number for this junction. |
deleted | boolean | Tombstone flag. |
created_at | integer | UTC epoch milliseconds. |
updated_at | integer | UTC 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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size. Default 100, hard cap 500 (clamped). |
offset | integer | No | Rows to skip. Default 0. |
order | string | No | Sort 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:
| Field | Type | Required | Description |
|---|---|---|---|
tag_id | string | One of the two | An existing live tag’s id. |
tag_name | string | One of the two | An 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, ortag_idis not a live tag.422 validation_failed— neithertag_idnortag_namegiven; invalid JSON; or a newtag_nameis 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.
| Field | Type | Required | Description |
|---|---|---|---|
tag_ids | string[] | Yes | The 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 requestedtag_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 thenotesscope — it returns note content, so a token with onlytagsgets403 insufficient_scope.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Page size. Default 100, hard cap 500 (clamped). |
offset | integer | No | Rows to skip. Default 0. |
order | string | No | Sort field: -updated_at (default), updated_at, created_at, title, usn. Prefix - for descending. |
notebook_id | string | No | Further 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.