Harbor
Developer docs menu

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.
  • share_token is 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.
  • slug is 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_url is 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 from X-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": … }.

FieldTypeDescription
note_idstringId of the shared note.
share_tokenstringOpaque, crypto-random base62 token (≥ 160 bits of entropy). The resolution key.
slugstringURL-safe human label — a sanitized requested slug, or a title fragment plus a random suffix. Not used for resolution.
public_urlstringThe absolute public link: request origin + /note/<token>.
is_publicbooleantrue while the share is live.
view_countintegerPublic reads recorded for the share (owner views excluded).
created_atintegerWhen the share was created, UTC epoch ms.
revoked_atinteger or nullWhen 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).

FieldTypeRequiredDescription
idstringyesPath — the note id.
slugstringnoBody (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.

FieldTypeRequiredDescription
idstringyesPath — 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.

FieldTypeRequiredDescription
idstringyesPath — 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 as 500 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.

FieldTypeRequiredDescription
tokenstringyesPath — 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_html is the sanitized note body. In-app harbor: links are rewritten to # so a public page can never link into the owner’s private notes.
  • is_web_clip marks a note whose body is a rendered web-page snapshot; render those via the sandboxed clip frame rather than as prose.
  • attachments lists only this note’s linked, non-encrypted resources. Each carries a short-lived presigned url (TTL from PUBLIC_SHARE_URL_TTL_SECONDS, default 300 seconds) and its url_expires_at; width/height appear when known.
  • An audio attachment whose transcription has completed also carries a transcript: segments[] (each { "speaker", "text", "start_ms", "end_ms" }), a speaker_names map, a summary ({ "status", "text", "model", "generated_at" }), plus language, audio_duration_ms, engine, id, and updated_at.
  • view_count reflects 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 a 500.
  • Owner exclusion. A view by the note’s owner — recognized by an optional valid bearer, or by the signed httpOnly harbor_share_owner cookie 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_email enabled, the first counted non-owner view sends the owner a one-time share_first_view email (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)
}

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.

FieldTypeRequiredDescription
tokenstringyesPath — 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 302 redirect 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’s updated_at, so an edit invalidates it automatically.
  • Either way, Cache-Control: public, max-age=300.

Errors:

  • 404 not_found for 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 canonical public_url.
  • og:image / twitter:image — the absolute og-image endpoint URL; twitter:card is summary_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.