API reference
Sharing API
Turn any unencrypted note into a public, read-only page with one call — behind an unguessable link you can revoke at any time.
Sharing publishes a note as a public, read-only page reachable by an unguessable token. A note is either fully private or public read-only — there is no fine-grained ACL and no collaboration: visitors can read, never edit.
The three share-management endpoints require the
notes scope. The public read endpoints
require no authentication at all.
Two rules worth stating up front:
- Public links are read-only. There is no way for a visitor to change a note through its public link — sharing is publishing, not collaborating.
- Encrypted notes can never be shared. The server only holds ciphertext
for an encrypted note and can never render it
publicly. Publishing one fails with
403 encrypted_cannot_share, and the public endpoints treat an encrypted note’s token as unresolvable.
How links work: token, slug, and URL
share_tokenis the resolution key: an opaque, crypto-random base62 token with at least 160 bits of entropy. It is never derived from the note id, your user id, or a counter — the link is unguessable.slugis a URL-safe, human-readable label — either your requested slug (sanitized to URL-safe lowercase) or a fragment of the note’s title plus a random suffix. It’s cosmetic; the token, not the slug, resolves the link.public_urlis the absolute public link: the request’s own origin plus the viewer route/note/<token>— e.g.https://app.harbor.my/note/<token>. It’s derived per request with no configuration (behind a TLS-terminating proxy the scheme comes fromX-Forwarded-Proto), so a self-hosted Harbor gets correct links automatically.
Share state also travels over sync via the note’s
is_public / public_token / public_shared_at marker, so every device sees
a publish or unpublish. And every public-share surface is served with
X-Robots-Tag: noindex, nofollow — search engines are told not to index
shared notes or follow their links.
The share object
The management endpoints return this object, wrapped in { "data": … }.
| Field | Type | Description |
|---|---|---|
note_id | string | Id of the shared note. |
share_token | string | Opaque, crypto-random base62 token (≥ 160 bits of entropy). The resolution key. |
slug | string | URL-safe human label — a sanitized requested slug, or a title fragment plus a random suffix. Not used for resolution. |
public_url | string | The absolute public link: request origin + /note/<token>. |
is_public | boolean | true while the share is live. |
view_count | integer | Public reads recorded for the share (owner views excluded). |
created_at | integer | When the share was created, UTC epoch ms. |
revoked_at | integer or null | When the link was revoked, UTC epoch ms; null while live. |
{
"data": {
"note_id": "9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b",
"share_token": "Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q",
"slug": "quarterly-plan-7Fk2",
"public_url": "https://app.harbor.my/note/Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q",
"is_public": true,
"view_count": 12,
"created_at": 1750000000000,
"revoked_at": null
}
}
Publish a note
POST /api/v1/notes/:id/share · scope: notes
Publish a note as a public, read-only page. Idempotent: publishing an already-public note returns its existing live share unchanged — same token, same slug, no sync churn. The note must be live (not trashed, not deleted) and must not be encrypted.
There is no “update” call: to change the slug or mint a fresh link, revoke the share and publish again. Re-publishing creates a brand-new share (new token — the old link stops working).
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Path — the note id. |
slug | string | no | Body (JSON, optional) — a custom slug, sanitized to URL-safe lowercase. When omitted, one is generated from the title plus a random suffix. |
curl https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b/share \
-X POST \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug": "quarterly-plan"}'
Response — 201 Created on a fresh publish, 200 OK when the note was
already public:
{
"data": {
"note_id": "9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b",
"share_token": "Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q",
"slug": "quarterly-plan-7Fk2",
"public_url": "https://app.harbor.my/note/Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q",
"is_public": true,
"view_count": 0,
"created_at": 1750000000000,
"revoked_at": null
}
}
Errors:
404 not_found— the note is missing, deleted, or in the trash.403 encrypted_cannot_share— the note is encrypted; the server only holds ciphertext and can never render it publicly.409 slug_taken— your requested slug is already in use. (A generated slug that collides is silently retried, never surfaced.)422 validation_failed— a body was sent but isn’t valid JSON.
Python
import os
import requests
BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
note_id = "9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b"
# Publish the note as a public read-only page (idempotent).
resp = requests.post(
f"{BASE}/notes/{note_id}/share",
headers=HEADERS,
json={"slug": "quarterly-plan"},
)
resp.raise_for_status()
share = resp.json()["data"]
print(share["public_url"]) # https://app.harbor.my/note/<token>
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const noteId = "9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b";
// Publish the note as a public read-only page (idempotent).
const res = await fetch(`${BASE}/notes/${noteId}/share`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ slug: "quarterly-plan" }),
});
if (!res.ok) throw new Error(`Publish failed: ${res.status}`);
const { data: share } = await res.json();
console.log(share.public_url); // https://app.harbor.my/note/<token>
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const base = "https://app.harbor.my/api/v1"
func main() {
noteID := "9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b"
body := bytes.NewBufferString(`{"slug": "quarterly-plan"}`)
// Publish the note as a public read-only page (idempotent).
req, _ := http.NewRequest("POST", base+"/notes/"+noteID+"/share", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
Data struct {
PublicURL string `json:"public_url"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
fmt.Println(out.Data.PublicURL) // https://app.harbor.my/note/<token>
}
Get a note’s share
GET /api/v1/notes/:id/share · scope: notes
Return the current live share for a note you own, including view_count
(this is where a “Views” display reads from). Read-only: no token is minted
and nothing syncs.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Path — the note id. |
curl https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b/share \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 200 OK, the same data-wrapped share object publish returns:
{
"data": {
"note_id": "9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b",
"share_token": "Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q",
"slug": "quarterly-plan-7Fk2",
"public_url": "https://app.harbor.my/note/Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q",
"is_public": true,
"view_count": 12,
"created_at": 1750000000000,
"revoked_at": null
}
}
Errors:
404 not_found— every failure mode collapses to this one generic 404: the note was never shared, its share was revoked, it belongs to another user, or it doesn’t exist. The endpoint never reveals whether a note exists outside your account.
Unpublish a note
DELETE /api/v1/notes/:id/share · scope: notes
Revoke a note’s public link. The public marker is cleared (and the unpublish
flows to every device over sync), then the share is stamped revoked_at — the
row is kept for audit and view history. The link stops resolving immediately.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Path — the note id. |
curl https://app.harbor.my/api/v1/notes/9c2e7b10-4f3a-4d2e-9b1c-8a7d6e5f4a3b/share \
-X DELETE \
-H "Authorization: Bearer $HARBOR_TOKEN"
Response — 204 No Content. Idempotent: a note that is already private,
was never shared, or doesn’t exist at all returns 204 too — there is nothing
to reveal and nothing to undo.
Errors:
- None under normal operation (always
204); an infrastructure failure surfaces as500 internal_error.
Read a shared note (public)
GET /api/v1/public/notes/:token · public — no auth, no scope
Resolve and render a shared note for anyone holding its token. No bearer token
is required; the endpoint is rate-limited under the public_share profile. A
bearer is optional — when present and valid it is used only to recognize the
note’s owner (see owner exclusion below); it never rejects a request.
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Path — the share token. |
curl https://app.harbor.my/api/v1/public/notes/Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q
Response — 200 OK, the public render, data-wrapped. Only display-safe fields
are exposed; the owner id, the note id, and all sync columns are never
included:
{
"data": {
"title": "Quarterly plan",
"content_html": "<p>Hello <strong>world</strong></p>",
"author": "Jane Doe",
"source_url": "https://example.com/clip",
"is_web_clip": false,
"created_at": 1749000000000,
"updated_at": 1750000000000,
"attachments": [
{
"resource_id": "0f9c2b1e-6d4a-4e8b-b2c1-3f5a7d9e0c2b",
"filename": "diagram.png",
"mime": "image/png",
"size": 1048576,
"width": 1200,
"height": 800,
"url": "https://s3.us-east-1.amazonaws.com/harbor-blobs/…?X-Amz-Signature=…",
"url_expires_at": 1750000300000
}
],
"view_count": 13
}
}
Field notes:
content_htmlis the sanitized note body. In-appharbor:links are rewritten to#so a public page can never link into the owner’s private notes.is_web_clipmarks a note whose body is a rendered web-page snapshot; render those via the sandboxed clip frame rather than as prose.attachmentslists only this note’s linked, non-encrypted resources. Each carries a short-lived presignedurl(TTL fromPUBLIC_SHARE_URL_TTL_SECONDS, default 300 seconds) and itsurl_expires_at;width/heightappear when known.- An audio attachment whose transcription has completed also carries a
transcript:segments[](each{ "speaker", "text", "start_ms", "end_ms" }), aspeaker_namesmap, asummary({ "status", "text", "model", "generated_at" }), pluslanguage,audio_duration_ms,engine,id, andupdated_at. view_countreflects the read just recorded for a non-owner view; an owner preview returns the count unchanged.
Behavior worth knowing:
- Anti-enumeration. Every failure mode — unknown token, revoked token,
deleted or trashed note, a note no longer public, or an encrypted note —
collapses to the same generic
404 not_found. A probe cannot distinguish “never existed” from “revoked” from “private”. Only an infrastructure failure yields a500. - Owner exclusion. A view by the note’s owner — recognized by an optional
valid bearer, or by the signed httpOnly
harbor_share_ownercookie set when they published or loaded their own link — is not counted and never triggers the first-view email. The counter stays about real visitors. - First-view email (opt-in, default off). With
notification_prefs.share_first_view_emailenabled, the first counted non-owner view sends the owner a one-timeshare_first_viewemail (note title plus a deep link) — exactly once per share, race-safe, async and best-effort. With the setting off, views are still counted but the first-view slot isn’t consumed, so enabling it later still fires on the next first view. Re-publishing creates a new share, which can first-view again.
Python
import requests
BASE = "https://app.harbor.my/api/v1"
token = "Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q"
# Public read — no Authorization header needed.
resp = requests.get(f"{BASE}/public/notes/{token}")
resp.raise_for_status()
note = resp.json()["data"]
print(note["title"], "-", note["view_count"], "views")
JavaScript
const BASE = "https://app.harbor.my/api/v1";
const token = "Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q";
// Public read — no Authorization header needed.
const res = await fetch(`${BASE}/public/notes/${token}`);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); // 404 = unresolvable token
const { data: note } = await res.json();
console.log(`${note.title} - ${note.view_count} views`);
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
const base = "https://app.harbor.my/api/v1"
func main() {
token := "Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q"
// Public read — no Authorization header needed.
res, err := http.Get(base + "/public/notes/" + token)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out struct {
Data struct {
Title string `json:"title"`
ViewCount int `json:"view_count"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
fmt.Printf("%s - %d views\n", out.Data.Title, out.Data.ViewCount)
}
Link-preview image
GET /api/v1/public/notes/:token/og-image.png · public — no auth
The og:image / twitter:image used by a shared note’s link preview, sized
1200×630 (summary_large_image). Rate-limited under the public_share
profile.
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | Path — the share token. |
curl -I https://app.harbor.my/api/v1/public/notes/Xa9KdT4vbQm2LcRfW8yHnZ3sJp6q/og-image.png
Response:
- If the note has an image attachment — a
302redirect to a freshly minted presigned URL for the first image-mime attachment. - Otherwise — a generated branded title card: the note title on a dark
Harbor-branded PNG, word-wrapped up to 4 lines and ellipsized. Cached in
memory keyed by
token+ the note’supdated_at, so an edit invalidates it automatically. - Either way,
Cache-Control: public, max-age=300.
Errors:
404 not_foundfor any unresolvable token (unknown, revoked, private, encrypted) — never a broken-image response, which would itself signal that the token exists.
The public viewer page
GET /note/:token · public — no auth
The page a share link actually points at (public_url). It serves the app
shell with note-specific Open Graph / Twitter Card meta spliced into <head>
server-side — link unfurlers (iMessage, Slack, X, WhatsApp…) fetch the HTML
but never run JavaScript, so this is how they see a rich preview. The client
then calls GET /api/v1/public/notes/:token to render the note.
For a live, public, non-encrypted note the <head> carries:
og:title/twitter:title— the note title ("Untitled note"if blank).og:description/twitter:description— a ~200-character plaintext snippet.og:url— the canonicalpublic_url.og:image/twitter:image— the absolute og-image endpoint URL;twitter:cardissummary_large_image.og:type=article,og:site_name=Harbor, and a document<title>of"<note title> · Harbor".
Anti-enumeration: an unknown, revoked, non-public, or encrypted token — even
an infrastructure error — serves the exact same generic shell with default
meta, always 200 OK, never a 404. No title or snippet is ever exposed for a
token that shouldn’t work.
Legacy /p/:token redirect
GET /p/:token · public — no auth
Earlier builds emitted share links of the form /p/<token>. That route now
answers with a 301 Moved Permanently to /note/<token> (relative, so it
resolves on whatever origin served it), carrying
X-Robots-Tag: noindex, nofollow. The app no longer emits /p links; only
this compatibility redirect remains.
Related
- Notes API — create and manage the notes you share.
- Authentication & scopes — minting a
token with the
notesscope. - Encryption — why an encrypted note can never be shared.