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, date and time formats, workspaces, 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 | — | The id of the account’s default notebook. It’s an alias of the notebook’s is_default flag, not a stored value — see the default notebook. null only if the account somehow has no default. |
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). |
date_format | string | medium | One of medium, us_slash, eu_slash, iso, long. |
time_format | string | 12h | One of 12h, 24h. |
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 | Does nothing. It’s still accepted and returned so apps that send it keep working. Values outside 1–60 are rejected. |
editor_prefs.show_word_count | bool | false | Word count in the editor. |
editor_prefs.show_list_guides | bool | false | A faint line beside nested list items in the editor. Public share pages, version history and exports never draw it. |
workspaces | array | [] | The account’s workspaces, each { "id", "name", "color" }. See workspaces. |
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": "5b1f2c9a-7e44-4c1b-9f6a-2d8e0b3c4a17",
"default_sort": "-updated_at",
"locale": "en-US",
"timezone": "UTC",
"date_format": "medium",
"time_format": "12h",
"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,
"show_list_guides": false
},
"workspaces": [],
"recording_notice_ack_at": 0,
"updated_at": null
}
}
updated_at is null when the account has never written settings.
default_notebook_id is always read live from the notebooks, so it’s set even
then.
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. workspaces
is replaced whole — see workspaces. An empty body {} changes
no values and returns the current settings, though it still sets updated_at.
Unknown keys are ignored.
default_notebook_id doesn’t behave like the other keys. Sending an id moves
the account’s default to that notebook; null and "" change nothing. See
the default 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— a value is out of range or not in its enum,localeisn’t valid BCP-47,timezoneisn’t a valid IANA zone,default_notebook_iddoesn’t reference a live notebook, orworkspacesbreaks one of its rules.detailsuses dotted paths likeeditor_prefs.font_sizeandworkspaces.1.name(the second entry — it counts from zero), anddefault_notebook_idfor an unknown notebook.422 default_notebook_cannot_encrypt—default_notebook_idnames a notebook withdefault_encrypt: true. That notebook can’t be the default.403 plan_limit_reached—default_notebook_idwould move the default while the account is read-only.details.gateanddetails.reasonareaccount_read_only. The default doesn’t move, and nothing else in the request is saved either — re-send it without the key. APUTthat re-sends the current default never trips this check, because it doesn’t move anything.
The default notebook
Every account has exactly one default notebook. It’s where a note with no
notebook lands, and where templates, forwarded email, imports and Trash
restores go when they have nowhere else to be. It’s the is_default flag on
the notebook itself.
default_notebook_id in settings is a second way to read and set that same
flag. It’s kept so older clients keep working. New code should move the
default on the notebook:
curl -X PATCH https://app.harbor.my/api/v1/notebooks/5b1f2c9a-7e44-4c1b-9f6a-2d8e0b3c4a17 \
-H "Authorization: Bearer $HARBOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_default": true}'
If you do use settings, here’s what each value does on PUT:
default_notebook_id | What happens |
|---|---|
| a notebook id | That notebook becomes the default, exactly as PATCH /notebooks/:id {"is_default": true} does. The old default is demoted and both notebooks get a fresh USN, so other devices see the move on their next sync pull. |
| the current default’s id | 200, nothing written, no USN spent. |
null or "" | 200, nothing happens. It doesn’t clear the default — there’s no such thing as no default. |
| key left out | Nothing happens. |
GET always reports the notebook that holds the flag right now, never a
stored value. Settings themselves aren’t in the sync stream, but the notebook
rows are, so a device that syncs sees a change of default without re-reading
settings.
On a read-only account, moving the default is refused everywhere it can be
done — here, on PATCH /notebooks/:id, and through
sync push — with
plan_limit_reached. Here and on PATCH that’s a 403; on sync push it’s a
per-change rejection inside a 200. On this endpoint the check comes before the unknown-notebook and
encrypting-notebook 422s, so a frozen account is told it’s frozen.
Workspaces
A workspace groups notebooks under a name like Work, and workspaces is where
the account keeps them. A notebook joins one by carrying its id in
workspace, and its notes
come with it. Standalone tasks and saved searches carry a workspace id the
same way.
The server doesn’t check those ids against this list. A notebook with an id that isn’t here still saves. It just won’t show up in any workspace.
{
"workspaces": [
{ "id": "9d2c6a41-3f0e-4b8a-a7c5-1e6f2b9d8c30", "name": "Work", "color": "#1184f0" }
]
}
idis required and yours to pick: up to 64 bytes, unique in the list. Use a UUID, and never reuse one.nameis required, up to 60 bytes.coloris""for none, or one of ten values:#ef4444red,#f97316orange,#f59e0bamber,#22c55egreen,#14b8a6teal,#1184f0blue,#6366f1indigo,#a855f7purple,#ec4899pink,#64748bslate. It’s lower-cased on save.- All three are trimmed before they’re checked and saved.
- At most 20 workspaces.
PUT replaces the whole list. There’s no per-item merge. Leave the key out
(or send null) to keep the list as it is, send [] to clear it, and
otherwise send every workspace you want to keep.
Removing a workspace deletes nothing. Notebooks, tasks and saved searches
that still carry its id stay where they are. They just stop matching any
workspace, so clear their workspace when you remove one. That’s also why ids
shouldn’t be reused: a new workspace with an old id picks up everything still
carrying it.
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. Three 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.markdown— one.mdfile per note in notebook-shaped folders, with a YAML frontmatter header and every attachment written once underfiles/. It is the same dialect Harbor’s Obsidian importer reads, so an archive round-trips back in throughobsidian_zip. Encrypted notes are skipped and counted; the server holds only ciphertext.
The Markdown archive is laid out with notebook folders at the archive root,
not under a notebooks/ prefix:
manifest.json
Boatyard Reference/Mangrove Belle — hull survey.md
Recipes & Kitchen/Sourdough, third attempt.md
files/hull-survey-2026.pdf
That is deliberate, and re-import depends on it: the Obsidian importer maps a
note’s top-level folder to its notebook, so a wrapper directory would
re-import the whole account into one notebook named after the wrapper. Stacks
have no representation in the layout for the same reason and are recorded in
manifest.json instead. files/ holds no .md, so it is never mistaken for a
notebook.
Each note’s frontmatter carries exactly the five keys the importer maps —
title, tags, created, updated, source — with tags and source
omitted when empty. There is no per-note JSON sidecar; manifest.json at the
root is the machine-readable index.
All 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. Each format has its
own slot, so ENEX, HTML and Markdown exports can all run concurrently — and
scope does not create an extra slot: a notebook-scoped Markdown export occupies
the same Markdown slot as a whole-account one.
The body is optional; an absent or empty body starts a whole-account ENEX export.
| Field | Type | Required | Description |
|---|---|---|---|
format | string | no | enex (default), html, or markdown. |
notebook_id | string | no | Scopes the export to a single notebook, in any format. Omit for the whole account. |
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 notenex,html, ormarkdown.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 default notebook that
default_notebook_idpoints at, and notebook-scoped exports. - Security at Harbor — how your data is stored, and why the export exists: your notes are yours to leave with.