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.
| Field | Type | Description |
|---|---|---|
id | string | User id. |
name | string | Display name. |
email | string | Login email address. |
email_verified | bool | Whether the login email has been verified. |
pending_email | string | null | A staged new email awaiting confirmation, or null. |
avatar_url | string | null | Short-lived presigned GET URL for the avatar (default TTL 300 s), or null when no avatar is set. |
locale | string | Preferred locale, e.g. en-US. |
timezone | string | IANA time zone, e.g. America/New_York. |
is_super_admin | bool | Advisory flag for showing admin UI. The real gate is server-side. |
created_at | int | Created, UTC epoch milliseconds. |
updated_at | int | Last 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.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | 1–120 characters, no control characters. |
locale | string | no | Must not be empty. |
timezone | string | no | Must not be empty. |
email | string | no | New login email; triggers the staged-change flow. |
current_password | string | when email is present | Re-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; badname; emptylocaleortimezone; missing/invalidemail; oremailequals the current address.403 reauth_required—emailpresent butcurrent_passwordmissing or incorrect. Distinct from a401(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.
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
hash | string | yes | 64-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.
| Field | Type | Default | Description |
|---|---|---|---|
theme | string | system | One of system, light, dark. |
default_notebook_id | string | null | null | A live notebook UUID, or null. |
default_sort | string | -updated_at | One of -updated_at, updated_at, -created_at, created_at, title, -title. |
locale | string | en-US | A valid BCP-47 language tag. |
timezone | string | UTC | A valid IANA time zone (Local is rejected). |
notification_prefs.email_reminders | bool | true | Reminder emails. |
notification_prefs.email_product_news | bool | false | Product news — opt-in. |
notification_prefs.email_security | bool | true | Security emails — cannot be disabled; a false is silently coerced back to true. |
notification_prefs.push_reminders | bool | true | Push reminders. |
notification_prefs.share_first_view_email | bool | false | Opt-in email when a public share gets its first view. |
editor_prefs.font_size | int | 16 | 10–32 inclusive. |
editor_prefs.font_family | string | sans | One of sans, serif, mono. |
editor_prefs.spellcheck | bool | true | Editor spellcheck. |
editor_prefs.autosave_seconds | int | 5 | 1–60 inclusive. |
editor_prefs.show_word_count | bool | false | Word count in the editor. |
recording_notice_ack_at | int | 0 | Epoch-ms of the one-time audio-recording consent acknowledgment. Only ever set, never cleared. |
updated_at | int | null | null | Epoch-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-47locale, an invalid IANAtimezone, or adefault_notebook_idthat does not reference a live notebook.detailsuses dotted paths likeeditor_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.
| Field | Type | Description |
|---|---|---|
provider | string | Provider key (google). |
available | bool | Whether this server can run the web connect flow. |
connected | bool | Whether this user has the provider connected. |
email | string | The connected Google account (display-only). Omitted when not connected. |
scopes | string[] | The scopes actually granted. Omitted when not connected. |
connected_at | int | Epoch-ms the connection was established. Omitted when not connected. |
google_client_id | string | Client id for the browser’s GIS popup. Present only while available. |
google_native_client_id | string | Public iOS client id for the native connect flow. Present only when configured. |
scope | string | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
authorization_code | string | yes | One-time code from Google. |
code_verifier | string | native only | PKCE verifier; its presence selects the native flow. |
redirect_uri | string | native only | The native app’s redirect URI. |
client | string | no | "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 missingcode_verifierorredirect_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.
| Field | Type | Required | Description |
|---|---|---|---|
url | query string | yes | URL-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.
| Field | Type | Description |
|---|---|---|
plan.code | string | Plan family code, e.g. starter, unlimited; "" if unresolved. |
plan.name | string | Human plan name; "" if unresolved. |
plan.source | string | free | stripe | apple | google | comp. |
plan.status | string | Subscription status for a paid plan (active, past_due, …); "" for free/comp. |
is_read_only | bool | Whole-account read-only flag, set when over-limit or payment lapsed. Clients block create/edit when true. |
usage.<resource>.used | int | Live + trashed count, excluding expunged. |
usage.<resource>.limit | int | null | The 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_reachedwithdetails{ 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 amanifest.json. Importable elsewhere.html— a self-contained, browsable offline website: unzip it and openindex.html, no server or internet required. Encrypted notes get a placeholder page, a sidecar markedis_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.
| Field | Type | Required | Description |
|---|---|---|---|
format | string | no | enex (default) or html. |
notebook_id | string | no | Scopes 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_error—formatis notenexorhtml.404 not_found—notebook_iddoes 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.
| Field | Type | Required | Description |
|---|---|---|---|
id | string (path) | yes | The 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_unitstrack progress (units are notebooks processed).notebook_idis present only for a notebook-scoped HTML export.download_urlandresult_expires_atappear only for a completed, unexpired job;error_textappears 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(scopeprofile) 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.
Related
- Files API — upload the avatar image bytes via the presigned-PUT flow.
- Notebooks API — the notebooks referenced by
default_notebook_idand notebook-scoped exports. - Security at Harbor — how your data is stored, and why the export exists: your notes are yours to leave with.