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:
| Level | Field | What it means |
|---|---|---|
| Trash (soft delete) | in_trash | A 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) | deleted | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
permanent | boolean | No | Default 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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Default 100, hard cap 500 (clamped). |
offset | integer | No | Default 0. |
order | string | No | Default -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_hashplus 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_devicewithin the coalesce window (default 15 minutes) folds into one evolving snapshot:created_atstays pinned to the session start whileupdated_atadvances 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 of0disables 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
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Snapshot id (not the note id). |
note_id | string (UUID) | The note this version belongs to. |
usn_at_snapshot | integer | The note’s USN at capture time — correlates with audit events via the shared USN anchor. |
title | string | The version’s title. Full fetch only; ciphertext when is_encrypted. |
content | string | The version’s content. Full fetch only; ciphertext when is_encrypted. |
content_hash | string | Content-address hash used for dedup. |
attributes_json | string | Canonical JSON snapshot of the note’s attributes (notebook_id, source_url, author, latitude, longitude, altitude, reminder_time, reminder_done_time). Full fetch only. |
is_encrypted | boolean | Whether title/content are ciphertext. |
source_device | string | The sync device_id that caused the modification; omitted for local/web REST edits, "server" for server-side reverts. |
created_at | integer (epoch ms) | Capture time; for a coalesced session, the session start (fixed). |
updated_at | integer (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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Default 100, hard cap 500 (clamped). |
offset | integer | No | Default 0. |
order | string | No | Default -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
| Action | Meaning |
|---|---|
create | Note created. |
update | Title/content changed. |
append | Content appended. |
delete | Moved to trash or expunged — metadata.kind is "trash" or "expunge". |
restore | Restored from trash or reverted from history — metadata.kind is "trash" or "revert". |
tag | Tag added/removed. |
move | Notebook changed. |
share | Made public / unshared. |
The audit event object
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Event id. |
note_id | string (UUID) | The note the event belongs to. |
action | string | One of the fixed actions above. |
device_id | string | The actor: the sync/REST device_id, or "server" for server-initiated events such as the trash auto-purge. |
usn | integer | The note’s USN at the time of the event (the timeline anchor); may be 0 for events recorded without a USN anchor. |
metadata | object | null | Small 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_at | integer (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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Default 100, hard cap 500 (clamped). |
offset | integer | No | Default 0. |
order | string | No | Default -created_at. Sortable: created_at, usn (- prefix = descending). |
action | string | No | Filter 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.
Note links & backlinks
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.
List a note’s outgoing links
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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Default 100, hard cap 500 (clamped). |
offset | integer | No | Default 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:idis missing/expunged. A trashed note still lists its links.
List a note’s backlinks
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.
| Field | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Default 100, hard cap 500 (clamped). |
offset | integer | No | Default 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:idis missing/expunged.
Related
- 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.