Harbor
Developer docs menu

API reference

Trash, History & Activity API

Every note carries its own safety net: a recoverable trash, a version history you can revert in one call, an append-only activity trail, and a live map of the links between notes.

These endpoints cover what happens to a note around its edits: deleting and un-deleting it, reading and reverting its past versions, auditing who changed what from which device, and seeing which notes link to which. Everything on this page requires a bearer token with the notes scope.

Two levels of delete

Harbor deletes notes in two distinct steps, tracked by two independent fields on the note object:

LevelFieldWhat it means
Trash (soft delete)in_trashA normal synced field on a live note (deleted stays false). The note sits in a recoverable recycle bin; trashed_at (epoch ms, omitted when not trashed) records when it entered and drives the auto-purge job.
Expunge (permanent)deletedThe canonical sync tombstone. Only an expunge sets it. Once set, the note is permanently gone and the tombstone propagates to every device.

Every transition — trash, restore, expunge — allocates a fresh USN (so it syncs), reindexes the note (trashing or expunging removes it from search), and writes an audit event: delete with metadata.kind of "trash" or "expunge", and restore with metadata.kind = "trash" on un-trash. Tag junctions survive a trip through the trash, so a restore brings the note’s tags back with it.

Live-note reads (GET /api/v1/notes, GET /api/v1/notes/:id) filter out both in_trash: true and deleted: true; pass ?deleted=true there to include trashed notes, or use GET /api/v1/trash below.

An auto-purge background job expunges notes that have sat in the trash past the retention window. It has no HTTP surface — it performs the same expunge transition described here.

Trash a note

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

Moves a note to the Trash by default: sets in_trash: true and trashed_at, keeps deleted: false, bumps the USN, and removes it from search. Idempotent if the note is already trashed. With ?permanent=true it skips the Trash and expunges immediately — the same operation as expunge below.

FieldTypeRequiredDescription
permanentbooleanNoDefault false. true expunges immediately (permanent tombstone) instead of trashing.
# Soft delete: the note lands in the Trash, fully recoverable
curl -X DELETE "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

# Permanent delete: skip the Trash entirely
curl -X DELETE "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10?permanent=true" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response: 204 No Content.

  • 404 not_found — no live note with that id.

Restore a note from the Trash

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

Returns a note from the Trash to the live set: clears in_trash and trashed_at, bumps the USN, and reindexes the note back into search. If the note’s original notebook was expunged while it sat in the trash, the note lands in your default notebook — a restore never dangles.

curl -X POST "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/restore" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response — the restored note object (bare, not wrapped in data):

{
  "id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
  "title": "Quarterly plan",
  "notebook_id": "5b1f2c9a-8d4e-4c7b-a2f1-3e9d6c0b8a45",
  "in_trash": false,
  "is_encrypted": false,
  "usn": 92,
  "deleted": false,
  "updated_at": 1750000060000,
  "created_at": 1749000000000
}
  • 422 not_in_trash — the note is not in the trash (nothing to restore).
  • 404 not_found — no live note with that id (e.g. already expunged).

Expunge a note (permanent delete)

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

Permanently deletes a note: sets the deleted: true sync tombstone with a fresh USN (so the deletion propagates to every device), removes it from search, and tombstones its attachment junctions. Any attachment losing its last live reference has its bytes reclaimed from object storage and its resource row tombstoned — permanently deleting your file deletes the bytes. A blob still referenced by another note, by a note sitting in the trash, or used as your avatar is kept. Works whether or not the note is currently in the trash.

curl -X POST "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/expunge" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response: 204 No Content.

  • 404 not_found — no live note with that id.

List the Trash

GET /api/v1/trash · scope: notes

Lists the notes currently in the Trash (in_trash: true, not yet expunged), most-recently-trashed first.

FieldTypeRequiredDescription
limitintegerNoDefault 100, hard cap 500 (clamped).
offsetintegerNoDefault 0.
orderstringNoDefault -trashed_at. Sortable: trashed_at, updated_at, created_at, title (- prefix = descending).
curl "https://app.harbor.my/api/v1/trash?limit=50" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response — note objects in the standard collection envelope; each row carries in_trash: true and its trashed_at:

{
  "data": [
    {
      "id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
      "title": "Quarterly plan",
      "notebook_id": "5b1f2c9a-8d4e-4c7b-a2f1-3e9d6c0b8a45",
      "in_trash": true,
      "trashed_at": 1750000000000,
      "usn": 90,
      "deleted": false,
      "updated_at": 1750000000000,
      "created_at": 1749000000000
    }
  ],
  "paging": { "limit": 50, "offset": 0, "total": 1, "has_more": false }
}
  • 422 validation_failed — unknown sort field.

Empty the Trash

DELETE /api/v1/trash · scope: notes

Expunges every note currently in the Trash. Each becomes a fresh-USN deleted: true tombstone, is removed from search, and records a delete audit event with metadata.kind = "expunge"; attachment blobs left with no remaining reference are reclaimed. Returns how many notes were expunged.

curl -X DELETE "https://app.harbor.my/api/v1/trash" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response — bare object (not wrapped in data):

{ "expunged": 3 }

Note history

Every time a note’s content or attributes change, Harbor captures a version snapshot. History is server-owned bookkeeping — it is not synced (no usn/deleted sync columns) and never travels through the sync channel.

Two behaviors keep the timeline useful instead of noisy:

  • Deduplication. Snapshots are content-addressed and deduped by content_hash plus attributes within a note — no-op saves write nothing. Encrypted notes store the ciphertext envelope verbatim and dedup on the ciphertext hash; the server never sees plaintext.
  • Session coalescing. History is per editing session, not per save. A continuous run of changed saves from the same source_device within the coalesce window (default 15 minutes) folds into one evolving snapshot: created_at stays pinned to the session start while updated_at advances with each edit. Edits arriving from a different device and server-side reverts (source_device: "server") are never folded in, so they remain distinct checkpoints. A window of 0 disables coalescing.

A retention/prune job bounds growth (a recent keep-everything window, then a per-note cap, always keeping each note’s oldest and newest snapshot). Pruning is a background concern with no HTTP surface.

The version snapshot object

FieldTypeDescription
idstring (UUID)Snapshot id (not the note id).
note_idstring (UUID)The note this version belongs to.
usn_at_snapshotintegerThe note’s USN at capture time — correlates with audit events via the shared USN anchor.
titlestringThe version’s title. Full fetch only; ciphertext when is_encrypted.
contentstringThe version’s content. Full fetch only; ciphertext when is_encrypted.
content_hashstringContent-address hash used for dedup.
attributes_jsonstringCanonical JSON snapshot of the note’s attributes (notebook_id, source_url, author, latitude, longitude, altitude, reminder_time, reminder_done_time). Full fetch only.
is_encryptedbooleanWhether title/content are ciphertext.
source_devicestringThe sync device_id that caused the modification; omitted for local/web REST edits, "server" for server-side reverts.
created_atinteger (epoch ms)Capture time; for a coalesced session, the session start (fixed).
updated_atinteger (epoch ms)Last-edit time; advances within a coalesced session, equals created_at for a one-off snapshot.

List a note’s history

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

Lists a note’s versions as metadata only — no content or attributes_json bodies, so the list stays cheap. Newest first by default. History is readable for any note that exists, including a trashed one; a fully missing or expunged note returns 404.

FieldTypeRequiredDescription
limitintegerNoDefault 100, hard cap 500 (clamped).
offsetintegerNoDefault 0.
orderstringNoDefault -created_at. Sortable: created_at, updated_at, usn_at_snapshot (- prefix = descending).
curl "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/history" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response:

{
  "data": [
    {
      "id": "7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83",
      "note_id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
      "usn_at_snapshot": 88,
      "content_hash": "f1d2a9c4e07b8d35",
      "is_encrypted": false,
      "source_device": "ipad-2",
      "created_at": 1750000000000,
      "updated_at": 1750000300000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}
  • 404 not_found — no such note for this user.
  • 422 validation_failed — unknown sort field.

Python

import os
import requests

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

note_id = "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10"

resp = requests.get(f"{BASE}/notes/{note_id}/history", headers=headers,
                    params={"limit": 50})
resp.raise_for_status()

for version in resp.json()["data"]:
    device = version.get("source_device", "web")
    print(f"{version['id']}  usn={version['usn_at_snapshot']}  from={device}")

JavaScript

const BASE = "https://app.harbor.my/api/v1";
const headers = { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` };

const noteId = "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10";

const resp = await fetch(`${BASE}/notes/${noteId}/history?limit=50`, { headers });
if (!resp.ok) throw new Error(`History list failed: ${resp.status}`);

const { data } = await resp.json();
for (const v of data) {
  console.log(v.id, `usn=${v.usn_at_snapshot}`, new Date(v.created_at).toISOString());
}

Go

package main

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

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

// main lists a note's version history and prints each snapshot's
// id, USN anchor, and capture time.
func main() {
	noteID := "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10"

	req, _ := http.NewRequest("GET", base+"/notes/"+noteID+"/history?limit=50", 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"`
			USNAtSnapshot int64  `json:"usn_at_snapshot"`
			CreatedAt     int64  `json:"created_at"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		panic(err)
	}

	for _, v := range out.Data {
		fmt.Printf("%s  usn=%d  created_at=%d\n", v.ID, v.USNAtSnapshot, v.CreatedAt)
	}
}

Get one version

GET /api/v1/notes/:id/history/:version_id · scope: notes

Fetches one version’s full snapshot, including title, content, and attributes_json. The version must belong to :id — a snapshot of one note can never be read through another note’s URL.

curl "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/history/7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response — the full snapshot object (bare, not wrapped in data):

{
  "id": "7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83",
  "note_id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
  "usn_at_snapshot": 88,
  "title": "Quarterly plan",
  "content": "<p>Hello <strong>world</strong></p>",
  "content_hash": "f1d2a9c4e07b8d35",
  "attributes_json": "{\"notebook_id\":\"5b1f2c9a-8d4e-4c7b-a2f1-3e9d6c0b8a45\",\"source_url\":\"\",\"author\":\"\",\"latitude\":null,\"longitude\":null,\"altitude\":null,\"reminder_time\":null,\"reminder_done_time\":null}",
  "is_encrypted": false,
  "source_device": "ipad-2",
  "created_at": 1750000000000,
  "updated_at": 1750000300000
}
  • 404 not_found — the version id does not exist or does not belong to :id.

Revert to a version

POST /api/v1/notes/:id/history/:version_id/revert · scope: notes

One call restores a past version as a new current version — history is forward-only, so nothing is ever rewritten. The snapshot’s title and content are copied onto the live note (ciphertext copied verbatim for an encrypted note), a fresh USN is allocated so the revert syncs to every device as a normal edit, and the post-write hooks run: a new history snapshot of the reverted content, a search reindex, link extraction, and an audit restore event with metadata.kind = "revert".

The note must be live and not in the trash — restore it from the trash first if needed.

curl -X POST "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/history/7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83/revert" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response — the updated note plus its new USN, the same {note, usn} shape as the note write endpoints:

{
  "note": {
    "id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
    "title": "Quarterly plan",
    "content": "<p>Hello <strong>world</strong></p>",
    "content_hash": "f1d2a9c4e07b8d35",
    "notebook_id": "5b1f2c9a-8d4e-4c7b-a2f1-3e9d6c0b8a45",
    "is_encrypted": false,
    "usn": 91,
    "deleted": false,
    "updated_at": 1750000050000,
    "created_at": 1749000000000
  },
  "usn": 91
}
  • 409 note_in_trash — the note is in the trash; restore it first.
  • 404 not_found — the note is missing/expunged, or the version id does not belong to it.

Python

import os
import requests

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

note_id = "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10"
version_id = "7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83"

resp = requests.post(f"{BASE}/notes/{note_id}/history/{version_id}/revert",
                     headers=headers)
resp.raise_for_status()

body = resp.json()
print(f"Reverted \"{body['note']['title']}\" — note is now at USN {body['usn']}")

JavaScript

const BASE = "https://app.harbor.my/api/v1";
const headers = { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` };

const noteId = "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10";
const versionId = "7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83";

const resp = await fetch(`${BASE}/notes/${noteId}/history/${versionId}/revert`, {
  method: "POST",
  headers,
});
if (!resp.ok) throw new Error(`Revert failed: ${resp.status}`);

const { note, usn } = await resp.json();
console.log(`Reverted "${note.title}" — note is now at USN ${usn}`);

Go

package main

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

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

// main reverts a note to a prior version and prints the note's new USN.
func main() {
	noteID := "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10"
	versionID := "7b3e9c10-2a5d-4f8b-b1c6-9e4d7a2f5c83"

	url := fmt.Sprintf("%s/notes/%s/history/%s/revert", base, noteID, versionID)
	req, _ := http.NewRequest("POST", url, 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 {
		Note struct {
			ID    string `json:"id"`
			Title string `json:"title"`
		} `json:"note"`
		USN int64 `json:"usn"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		panic(err)
	}

	fmt.Printf("Reverted %q — note is now at USN %d\n", out.Note.Title, out.USN)
}

Note audit log

A per-note, append-only activity trail: what happened, from which device, the note’s USN at the time, when, plus a small action-specific metadata object. Like history, the audit log is server-owned and not synced; rows are immutable once written (only the prune job removes them).

The log deliberately stores no note plaintext — only the event, the actor device, an anchoring USN, a timestamp, and small counts/ids/flags — so it is safe to keep for encrypted notes too. It correlates with history snapshots via the shared USN anchor, and unlike history it also captures non-content events: moves, tags, shares, and deletions.

Actions

ActionMeaning
createNote created.
updateTitle/content changed.
appendContent appended.
deleteMoved to trash or expunged — metadata.kind is "trash" or "expunge".
restoreRestored from trash or reverted from history — metadata.kind is "trash" or "revert".
tagTag added/removed.
moveNotebook changed.
shareMade public / unshared.

The audit event object

FieldTypeDescription
idstring (UUID)Event id.
note_idstring (UUID)The note the event belongs to.
actionstringOne of the fixed actions above.
device_idstringThe actor: the sync/REST device_id, or "server" for server-initiated events such as the trash auto-purge.
usnintegerThe note’s USN at the time of the event (the timeline anchor); may be 0 for events recorded without a USN anchor.
metadataobject | nullSmall action-specific object — the kind discriminator, tag add/remove sets, move from/to notebook ids, update changed-flags — or null. Never contains note text.
created_atinteger (epoch ms)When the event was recorded.

List a note’s audit events

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

Lists a note’s audit events, newest first by default. The trail outlives the note — it records the deletion itself — so a trashed or expunged note still returns its trail here. Only a note id never known to this user (no note row and no audit rows) returns 404.

FieldTypeRequiredDescription
limitintegerNoDefault 100, hard cap 500 (clamped).
offsetintegerNoDefault 0.
orderstringNoDefault -created_at. Sortable: created_at, usn (- prefix = descending).
actionstringNoFilter to one action: create, update, append, delete, restore, tag, move, share.
curl "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/audit?action=delete" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response:

{
  "data": [
    {
      "id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "note_id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
      "action": "delete",
      "device_id": "ipad-2",
      "usn": 90,
      "metadata": { "kind": "trash" },
      "created_at": 1750000000000
    },
    {
      "id": "9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
      "note_id": "9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10",
      "action": "create",
      "device_id": "ipad-2",
      "usn": 41,
      "metadata": null,
      "created_at": 1749000000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 2, "has_more": false }
}
  • 404 not_found — the note id was never known to this user.
  • 422 validation_failed — unknown sort field.

Harbor tracks note-to-note links (“linked notes” / “what links here”). In-app links live in note content as <harbor-note-link note="<uuid>"> — the target note id only, no title, so the title is resolved client-side at render time and linking to an encrypted note never stores its title. The legacy form <a href="harbor:note/<uuid>"> is still parsed; both upgrade to the title-less element on the next re-save.

On every note save, the server extracts link targets (both forms) into a derived edge table. Links are derived, server-owned, and not synced — there is no write endpoint; edit the note’s content to change its links. Only a canonical 36-character hyphenated UUID target becomes an edge; other harbor: targets (notebook/tag links) and self-links are ignored.

broken is evaluated live on every read: it is true exactly when the target does not resolve to a present, non-expunged note (and target is null). A trashed target is not broken — its summary simply carries in_trash: true. Both endpoints embed a trimmed note summary {id, title, in_trash} for the other end of the edge.

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

The outgoing links from note :id — the edges its body contains — ordered by target id for deterministic paging.

FieldTypeRequiredDescription
limitintegerNoDefault 100, hard cap 500 (clamped).
offsetintegerNoDefault 0.
curl "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/links" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response:

{
  "data": [
    {
      "target_note_id": "3d8f5a21-7c4e-4b9a-8e2d-1f6c9b0a4e57",
      "broken": false,
      "target": { "id": "3d8f5a21-7c4e-4b9a-8e2d-1f6c9b0a4e57", "title": "Roadmap", "in_trash": false }
    },
    {
      "target_note_id": "0000ffff-1111-4222-8333-444455556666",
      "broken": true,
      "target": null
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 2, "has_more": false }
}
  • 404 not_found — note :id is missing/expunged. A trashed note still lists its links.

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

The notes that link to note :id (incoming edges), ordered by source id. Only live source notes are listed — trashed (in_trash: true) or expunged (deleted: true) sources are excluded.

FieldTypeRequiredDescription
limitintegerNoDefault 100, hard cap 500 (clamped).
offsetintegerNoDefault 0.
curl "https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4b8e-9d2f-6a1c5e8b7f10/backlinks" \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response:

{
  "data": [
    {
      "source_note_id": "7a1b2c3d-4e5f-4678-9a0b-1c2d3e4f5a6b",
      "source": { "id": "7a1b2c3d-4e5f-4678-9a0b-1c2d3e4f5a6b", "title": "Weekly review", "in_trash": false }
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}
  • 404 not_found — note :id is missing/expunged.
  • Notes API — the note object, CRUD, and the {note, usn} write shape these endpoints share.
  • Search API — trashing or expunging a note removes it from the search index; restoring reindexes it.
  • Notes features — how trash, history, and note links appear in the Harbor apps.