Harbor
Developer docs menu

API reference

Account API

Everything about the account itself — profile, preferences, connections, usage — plus the export that makes Harbor yours to leave with.

The account surface covers who you are (profile), how Harbor behaves for you (settings), what’s plugged in (connections), how much of your plan you’ve used (usage), and the full-account export. Every endpoint here requires the profile scope — which every token gets by default — except the account-export pair, which needs only a valid bearer token, and the email-change confirmation, which is public.

None of this data is part of the note sync stream. Fetch it directly when your app starts and after you write it.

The profile object

Returned data-wrapped by GET /profile, PUT /profile, and POST /profile/avatar.

FieldTypeDescription
idstringUser id.
namestringDisplay name.
emailstringLogin email address.
email_verifiedboolWhether the login email has been verified.
pending_emailstring | nullA staged new email awaiting confirmation, or null.
avatar_urlstring | nullShort-lived presigned GET URL for the avatar (default TTL 300 s), or null when no avatar is set.
localestringPreferred locale, e.g. en-US.
timezonestringIANA time zone, e.g. America/New_York.
is_super_adminboolAdvisory flag for showing admin UI. The real gate is server-side.
created_atintCreated, UTC epoch milliseconds.
updated_atintLast updated, UTC epoch milliseconds.
{
  "data": {
    "id": "u_9c2e4f1a",
    "name": "Jane Doe",
    "email": "jane@example.com",
    "email_verified": true,
    "pending_email": null,
    "avatar_url": "https://s3.example.com/blobs/ab12…?X-Amz-Signature=…",
    "locale": "en-US",
    "timezone": "America/New_York",
    "is_super_admin": false,
    "created_at": 1749000000000,
    "updated_at": 1750000000000
  }
}

Get your profile

GET /api/v1/profile · scope: profile

Returns the current user’s profile with a freshly presigned avatar_url.

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

Response 200 — the profile object, data-wrapped.

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}/profile", headers=HEADERS)
resp.raise_for_status()
profile = resp.json()["data"]
print(profile["name"], profile["email"])

JavaScript

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

const resp = await fetch(`${BASE}/profile`, {
  headers: { Authorization: `Bearer ${process.env.HARBOR_TOKEN}` },
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const { data: profile } = await resp.json();
console.log(profile.name, profile.email);

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+"/profile", 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 {
			Name  string `json:"name"`
			Email string `json:"email"`
		} `json:"data"`
	}
	json.NewDecoder(resp.Body).Decode(&out)
	fmt.Println(out.Data.Name, out.Data.Email)
}

Update your profile

PUT /api/v1/profile · scope: profile

Only the fields present in the body are touched. name, locale, and timezone apply immediately. email is sensitive: it requires current_password and is staged — the new address lands in pending_email and gets a single-use confirmation token by email, while the login email stays unchanged until confirmed. A security notice goes to the current address.

FieldTypeRequiredDescription
namestringno1–120 characters, no control characters.
localestringnoMust not be empty.
timezonestringnoMust not be empty.
emailstringnoNew login email; triggers the staged-change flow.
current_passwordstringwhen email is presentRe-authentication proof.
curl -X PUT https://app.harbor.my/api/v1/profile \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Jane D.", "email": "new@example.com", "current_password": "…"}'

Response 200 — the updated profile object, data-wrapped. On an email change, pending_email reflects the staged address:

{
  "data": {
    "id": "u_9c2e4f1a",
    "name": "Jane D.",
    "email": "jane@example.com",
    "pending_email": "new@example.com",
    "email_verified": true,
    "avatar_url": null,
    "locale": "en-US",
    "timezone": "America/New_York",
    "is_super_admin": false,
    "created_at": 1749000000000,
    "updated_at": 1750000500000
  }
}

Notable errors:

  • 422 validation_failed — invalid JSON; bad name; empty locale or timezone; missing/invalid email; or email equals the current address.
  • 403 reauth_requiredemail present but current_password missing or incorrect. Distinct from a 401 (bad/expired bearer token).
  • 409 email_taken — the new email is already a login or pending email of another account.

Confirm an email change

POST /api/v1/profile/email/confirm · public — no bearer token

Confirms a staged email change with the single-use token from the verification email (the user may click the link on a device with no session). Promotes pending_email to email, stamps email_verified, clears pending_email, and consumes the token — atomically.

FieldTypeRequiredDescription
tokenstringyesThe single-use token from the email (ec_…).
curl -X POST https://app.harbor.my/api/v1/profile/email/confirm \
  -H "Content-Type: application/json" \
  -d '{"token": "ec_…"}'

Response 200

{ "data": { "email": "new@example.com", "email_verified": true } }

Notable errors:

  • 400 invalid_token — missing, malformed, unknown, expired, consumed, or replayed. All cases return the same generic message (anti-enumeration).
  • 409 email_taken — the address was claimed by another account between the request and the confirmation.

Set your avatar

POST /api/v1/profile/avatar · scope: profile

Points the avatar at an already-uploaded image, referenced by its content hash — upload the bytes first via the Files API presigned-PUT flow. The object key is derived in your own namespace, so you can only reference your own blobs. Supported types: image/png, image/jpeg, image/webp, image/gif; maximum 5 MiB by default.

FieldTypeRequiredDescription
hashstringyes64-character hex SHA-256 of the uploaded blob.
curl -X POST https://app.harbor.my/api/v1/profile/avatar \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}'

Response 200 — the updated profile object with a fresh presigned avatar_url, data-wrapped.

Notable errors:

  • 422 validation_failed — invalid JSON; the hash is not a 64-char hex SHA-256; no blob exists for the hash; unsupported image type; or the image is too large.

Remove your avatar

DELETE /api/v1/profile/avatar · scope: profile

Clears the avatar reference. The content-addressed blob itself is the file store’s concern.

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

Response 204 No Content.

The settings object

Account-level preferences: theme, default notebook, default sort, locale, timezone, and nested notification and editor preferences. Settings are not part of the sync stream — fetch them on app start and after a PUT; last write wins across devices. Defaults are code-owned, so the response is always complete even before the first write.

FieldTypeDefaultDescription
themestringsystemOne of system, light, dark.
default_notebook_idstring | nullnullA live notebook UUID, or null.
default_sortstring-updated_atOne of -updated_at, updated_at, -created_at, created_at, title, -title.
localestringen-USA valid BCP-47 language tag.
timezonestringUTCA valid IANA time zone (Local is rejected).
notification_prefs.email_remindersbooltrueReminder emails.
notification_prefs.email_product_newsboolfalseProduct news — opt-in.
notification_prefs.email_securitybooltrueSecurity emails — cannot be disabled; a false is silently coerced back to true.
notification_prefs.push_remindersbooltruePush reminders.
notification_prefs.share_first_view_emailboolfalseOpt-in email when a public share gets its first view.
editor_prefs.font_sizeint1610–32 inclusive.
editor_prefs.font_familystringsansOne of sans, serif, mono.
editor_prefs.spellcheckbooltrueEditor spellcheck.
editor_prefs.autosave_secondsint51–60 inclusive.
editor_prefs.show_word_countboolfalseWord count in the editor.
recording_notice_ack_atint0Epoch-ms of the one-time audio-recording consent acknowledgment. Only ever set, never cleared.
updated_atint | nullnullEpoch-ms of the last write; null until the first write.

Get your settings

GET /api/v1/settings · scope: profile

Returns the effective settings: the stored document overlaid on the code-owned defaults.

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

Response 200

{
  "data": {
    "theme": "system",
    "default_notebook_id": null,
    "default_sort": "-updated_at",
    "locale": "en-US",
    "timezone": "UTC",
    "notification_prefs": {
      "email_reminders": true,
      "email_product_news": false,
      "email_security": true,
      "push_reminders": true,
      "share_first_view_email": false
    },
    "editor_prefs": {
      "font_size": 16,
      "font_family": "sans",
      "spellcheck": true,
      "autosave_seconds": 5,
      "show_word_count": false
    },
    "recording_notice_ack_at": 0,
    "updated_at": null
  }
}

updated_at is null when the account has never written settings.

Update your settings

PUT /api/v1/settings · scope: profile

A partial (or full) update. Top-level scalars you provide overwrite; notification_prefs and editor_prefs deep-merge field by field, so you can set just editor_prefs.font_size without clearing the rest. An empty body {} is a no-op that returns the current settings. Unknown keys are ignored.

default_notebook_id has three behaviors: omitted → unchanged; explicit null → cleared; a string → adopted (must reference a live notebook). After merging, the whole document is re-validated and email_security is coerced back to true.

The body accepts any subset of the fields in the settings object — all optional.

curl -X PUT https://app.harbor.my/api/v1/settings \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "theme": "dark",
    "default_notebook_id": "5b1f2c9a-7e44-4c1b-9f6a-2d8e0b3c4a17",
    "editor_prefs": { "font_size": 18, "show_word_count": true }
  }'

Response 200 — the full merged settings object, data-wrapped, with a non-null updated_at.

Notable errors:

  • 400 bad_request — the body is not valid JSON.
  • 422 validation_failed — out-of-range or non-enum values, an invalid BCP-47 locale, an invalid IANA timezone, or a default_notebook_id that does not reference a live notebook. details uses dotted paths like editor_prefs.font_size.

The connection object

Connections link your Harbor account to a third-party provider — the Settings → Connections surface. Google is the only provider today: connecting grants Harbor read-only Drive metadata access (drive.metadata.readonly — file names only, never contents) so the editor can resolve the real title of a pasted Google Doc, Sheet, or Slides link. Provider OAuth tokens are stored encrypted at rest (AES-256-GCM) and are never returned over the API. Connections are not part of the sync stream.

FieldTypeDescription
providerstringProvider key (google).
availableboolWhether this server can run the web connect flow.
connectedboolWhether this user has the provider connected.
emailstringThe connected Google account (display-only). Omitted when not connected.
scopesstring[]The scopes actually granted. Omitted when not connected.
connected_atintEpoch-ms the connection was established. Omitted when not connected.
google_client_idstringClient id for the browser’s GIS popup. Present only while available.
google_native_client_idstringPublic iOS client id for the native connect flow. Present only when configured.
scopestringThe scope string the client should request. Present when either client is configured.

List connections

GET /api/v1/connections · scope: profile

Every supported provider with its availability and the caller’s connection status. A small fixed set — no paging envelope.

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

Response 200

{
  "data": [
    {
      "provider": "google",
      "available": true,
      "connected": false,
      "google_client_id": "1234-abc.apps.googleusercontent.com",
      "scope": "openid email https://www.googleapis.com/auth/drive.metadata.readonly"
    }
  ]
}

Connect Google

POST /api/v1/connections/google · scope: profile

Connects (or re-connects) Google. Two request shapes; the server picks the flow from the body. Web (the default) sends just the authorization_code from the Google Identity Services auth-code popup. Native (public iOS client) also sends code_verifier and redirect_uri — both required — and is exchanged with PKCE and no client secret; the native app should request access_type=offline and prompt=consent so a refresh token is returned.

Re-connecting overwrites the stored grant; if Google omits a refresh token on re-consent, the previous one is kept.

FieldTypeRequiredDescription
authorization_codestringyesOne-time code from Google.
code_verifierstringnative onlyPKCE verifier; its presence selects the native flow.
redirect_uristringnative onlyThe native app’s redirect URI.
clientstringno"native" to force the native flow.
curl -X POST https://app.harbor.my/api/v1/connections/google \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"authorization_code": "4/0AbCD…"}'

Response 200{ "data": <connection object> } with connected: true.

Notable errors:

  • 503 google_not_configured — the requested client is not configured on the server.
  • 400 bad_request — missing code, or a native request missing code_verifier or redirect_uri.
  • 400 google_auth_failed — the exchange was rejected (expired/reused code, failed PKCE).

Disconnect Google

DELETE /api/v1/connections/google · scope: profile

Best-effort revokes the grant at Google (Harbor disappears from your Google permissions), then deletes the stored tokens. Revocation failures are non-fatal.

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

Response 204 No Content.

Notable errors:

  • 409 google_not_connected — nothing to disconnect.

Resolve a Google Drive title

GET /api/v1/connections/google/drive-title · scope: profile

Resolves a Google document URL to its real title through the caller’s Google connection. The stored access token is refreshed proactively near expiry and reactively (one refresh + retry) on a Google 401.

FieldTypeRequiredDescription
urlquery stringyesURL-encoded Google Doc / Sheet / Slides / Forms / Drive-file link. Recognized shapes: docs.google.com/{document|spreadsheets|presentation|forms}/d/<id> (optional /u/<n>/), drive.google.com/file/d/<id>, legacy drive.google.com/open?id=<id>.
curl -G https://app.harbor.my/api/v1/connections/google/drive-title \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  --data-urlencode "url=https://docs.google.com/spreadsheets/d/1AbCdEfGh/edit"

Response 200

{ "data": { "title": "Family Budget 2026" } }

Notable errors — all designed for silent client fallback (the pasted URL just stays a URL):

  • 400 invalid_drive_url — not a recognizable Google document link.
  • 503 google_not_configured — the server has no Google integration.
  • 409 google_not_connected — the caller has no Google connection.
  • 409 google_reauth_required — the stored grant died; reconnect in Settings.
  • 404 drive_file_not_found — unknown file, or not visible to the connected account.
  • 403 drive_file_forbidden — the connected account cannot access the file.
  • 502 google_drive_error — Google was unreachable (transient).

A broken Google grant is deliberately never mapped to 401, because Harbor clients treat a 401 as a sign-out signal.

Check usage & limits

GET /api/v1/usage · scope: profile

The caller’s usage snapshot: how much of each plan-capped resource is used and what the limit is — the data behind usage meters and the at-login over-limit overlay. The meter and the create-time plan_limit_reached guard share one counting engine, so they never disagree. Not paginated.

Counting rule: used includes live and trashed rows and excludes expunged ones — trashing does not free a slot; expunging does. Tasks count both completed and open. A limit of null means unlimited.

FieldTypeDescription
plan.codestringPlan family code, e.g. starter, unlimited; "" if unresolved.
plan.namestringHuman plan name; "" if unresolved.
plan.sourcestringfree | stripe | apple | google | comp.
plan.statusstringSubscription status for a paid plan (active, past_due, …); "" for free/comp.
is_read_onlyboolWhole-account read-only flag, set when over-limit or payment lapsed. Clients block create/edit when true.
usage.<resource>.usedintLive + trashed count, excluding expunged.
usage.<resource>.limitint | nullThe plan’s cap, or null for unlimited.

usage always carries all five resources: notes, notebooks, tags, files, tasks.

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

Response 200

{
  "data": {
    "plan": { "code": "starter", "name": "Starter", "source": "free", "status": "" },
    "is_read_only": false,
    "usage": {
      "notes":     { "used": 42, "limit": 50 },
      "notebooks": { "used": 2,  "limit": 3 },
      "tags":      { "used": 5,  "limit": 20 },
      "files":     { "used": 1,  "limit": 50 },
      "tasks":     { "used": 0,  "limit": 100 }
    }
  }
}

Notable errors:

  • 401 unauthorized, 403 insufficient_scope.
  • Related: creating past a cap returns 403 plan_limit_reached with details { resource, used, limit, plan_code, upgrade_url } — the same code the sync push apply path returns.

Record an activity heartbeat

POST /api/v1/activity/heartbeat · scope: profile

The explicit “app foregrounded” signal: records genuinely human activity (throttled to at most once per UTC day) so an idle free account can be told apart from one a background device is keeping warm. Background sync, token refresh, health checks, and automated polls deliberately do not count as activity; interactive logins, user-initiated REST writes, and explicit searches do. The request body is ignored.

curl -X POST https://app.harbor.my/api/v1/activity/heartbeat \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200

{ "data": { "last_active_at": 1750000000000, "active_today": true } }

last_active_at is epoch-ms of the last recorded human action (null until the account’s first), and appears nowhere else — not on profile payloads. active_today says whether it falls within the current UTC day.

Export your account

Your notes are yours to leave with — the export endpoints produce a complete, GDPR-style copy of your account as a single ZIP, generated by an async background job and downloaded via a short-lived presigned URL. Two formats:

  • enex (default) — one ENEX file per notebook, the original attachment bytes foldered by blob SHA-256, and a manifest.json. Importable elsewhere.
  • html — a self-contained, browsable offline website: unzip it and open index.html, no server or internet required. Encrypted notes get a placeholder page, a sidecar marked is_encrypted: true, and a skipped count.

Both export endpoints require a bearer token but no additional scope.

Start an export

POST /api/v1/account/export · bearer required, no additional scope

Starts an export job. Idempotent-ish: a queued/running export of the same format and scope is returned instead of starting another — an ENEX export and an HTML export can run concurrently.

The body is optional; an absent or empty body starts a whole-account ENEX export.

FieldTypeRequiredDescription
formatstringnoenex (default) or html.
notebook_idstringnoScopes an HTML export to a single notebook. Omit for the whole account; ignored for ENEX.
curl -X POST https://app.harbor.my/api/v1/account/export \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"format": "html"}'

Response 202 Accepted

{ "data": { "export_job_id": "0f9c2b1e-6a3d-4f7e-8b2c-1d5e9a0c3f68", "status": "queued", "format": "html" } }

status is one of queued, running, completed, failed — in synchronous mode it may already be completed. HTML exports run at low priority.

Notable errors:

  • 422 validation_errorformat is not enex or html.
  • 404 not_foundnotebook_id does not exist or is not the caller’s.
  • 500 internal_error — infrastructure failure.
  • An export_in_progress (409) code exists for callers preferring a hard conflict, but the default flow returns the existing job.

Python

import os
import time
import requests

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

# Start a whole-account ENEX export.
job = requests.post(f"{BASE}/account/export", headers=HEADERS,
                    json={"format": "enex"}).json()["data"]

# Poll until it finishes, then grab the download URL.
while True:
    status = requests.get(f"{BASE}/account/export/{job['export_job_id']}",
                          headers=HEADERS).json()["data"]
    if status["status"] in ("completed", "failed"):
        break
    time.sleep(5)

if status["status"] == "completed":
    print("Download:", status["download_url"])
else:
    print("Export failed:", status.get("error_text"))

JavaScript

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

const resp = await fetch(`${BASE}/account/export`, {
  method: "POST",
  headers,
  body: JSON.stringify({ format: "enex" }),
});
const { data: job } = await resp.json();
console.log("Export job:", job.export_job_id, job.status);

Go

package main

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

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

func main() {
	body := bytes.NewBufferString(`{"format": "enex"}`)
	req, _ := http.NewRequest("POST", base+"/account/export", 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 out struct {
		Data struct {
			ExportJobID string `json:"export_job_id"`
			Status      string `json:"status"`
		} `json:"data"`
	}
	json.NewDecoder(resp.Body).Decode(&out)
	fmt.Println("Export job:", out.Data.ExportJobID, out.Data.Status)
}

Poll & download an export

GET /api/v1/account/export/:id · bearer required, no additional scope

Polls an export job. When the job is completed and the result blob has not expired, the response includes a short-lived presigned download_url (default TTL 900 s). The export blob itself is retained for 72 hours by default; after that the job is reported without a URL.

FieldTypeRequiredDescription
idstring (path)yesThe export job id from POST /account/export.
curl https://app.harbor.my/api/v1/account/export/0f9c2b1e-6a3d-4f7e-8b2c-1d5e9a0c3f68 \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response 200

{
  "data": {
    "id": "0f9c2b1e-6a3d-4f7e-8b2c-1d5e9a0c3f68",
    "kind": "export",
    "format": "html",
    "status": "completed",
    "total_units": 4,
    "done_units": 4,
    "download_url": "https://s3.example.com/exports/…?X-Amz-Signature=…",
    "result_expires_at": 1750003600000,
    "updated_at": 1750000000000
  }
}
  • total_units / done_units track progress (units are notebooks processed).
  • notebook_id is present only for a notebook-scoped HTML export.
  • download_url and result_expires_at appear only for a completed, unexpired job; error_text appears only on a failed job.

Download the ZIP with the presigned URL — no auth header needed, the signature is in the URL:

curl -o harbor-export.zip "https://s3.example.com/exports/…?X-Amz-Signature=…"

Notable errors:

  • 404 not_found — unknown export id, or not the caller’s.

Password changes & account deletion

Changing your password and deleting your account are first-party security operations, and we don’t fully document the destructive flows here — do them from the Harbor app under Settings → Security. For completeness:

  • POST /api/v1/profile/change-password (scope profile) requires your current password, enforces the strength policy, and revokes every refresh and access token for the account — every other session must sign in again. Personal access tokens are independent credentials and are not revoked.
  • Account deletion (POST /api/v1/account/delete) requires your current password plus a verbatim confirmation phrase, and is a soft-delete with a grace period (30 days by default): nothing is destroyed immediately, and a pending deletion can be cancelled from a signed-in session (POST /api/v1/account/delete/cancel) until the window closes. After the grace period, a scheduler permanently purges the account.
  • Files API — upload the avatar image bytes via the presigned-PUT flow.
  • Notebooks API — the notebooks referenced by default_notebook_id and notebook-scoped exports.
  • Security at Harbor — how your data is stored, and why the export exists: your notes are yours to leave with.