Harbor
Developer docs menu

API reference

OAuth & Tokens API

Every endpoint that mints, refreshes, or revokes a Harbor credential — the token grants, the PKCE authorization flow, personal access tokens, and your registered apps.

This page is the full reference for Harbor’s authentication surface: the OAuth2 token endpoint (every grant type), the Authorization Code + PKCE flow, the scope catalog, token revocation, logout, registration, personal access tokens (PATs), and the developer registry for OAuth apps and their user grants. For a guided introduction, start with Authentication; the scope vocabulary applies everywhere below.

Most endpoints on this page are public — the credential travels in the request body. The management endpoints (PATs, OAuth apps, connected apps) require a bearer token with the profile scope.

Token formats

Every credential Harbor issues carries a distinctive prefix, so a leaked value is greppable and catchable by secret scanners:

PrefixWhat it is
at_OAuth access token — the bearer you send on API calls (1-hour lifetime)
rt_Refresh token — single-use, rotates on every refresh (30-day lifetime)
hbp_Personal access token — long-lived bearer for scripts and CI
hbs_Client secret for a confidential OAuth app
ac_Authorization code — one-time, short-lived (default 60 s)
mfc_Two-factor challenge — returned mid-login when 2FA is on

Send any bearer — at_ or hbp_ — the same way:

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

Scope requests are space-delimited strings and must be a subset of the client’s allowed scopes. Omit scope to receive the client’s full allowed set. On a refresh, scope may only narrow the existing grant. Requesting a scope that isn’t in the catalog — or isn’t allowed for the client — returns 400 invalid_scope.

Get a token

POST /api/v1/oauth/token · public — no bearer required

The OAuth2 token endpoint. It dispatches on grant_type and accepts JSON or form-encoded bodies. On success it returns a bare token object — this is the one response on the API that is not wrapped in data:

FieldTypeDescription
access_tokenstringThe bearer for API calls (at_…)
refresh_tokenstringSingle-use rotating refresh token (rt_…)
token_typestringAlways Bearer
expires_innumberAccess-token lifetime in seconds
scopestringSpace-delimited set actually granted
{
  "access_token": "at_9f3a7c1e…",
  "refresh_token": "rt_2b8d4f6a…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "notes notebooks sync"
}

Password grant

grant_type=password — the primary login, reserved for the first-party harbor-app client. Registered third-party apps can never use it.

FieldTypeRequiredDescription
grant_typestringyespassword
client_idstringyesA first-party client allowing the password grant (harbor-app)
usernamestringyesThe account email
passwordstringyesThe account password
scopestringnoSubset of the client’s allowed scopes; omitted → full allowed set
device_idstringnoRecorded for session management
device_namestringnoRecorded for session management
curl -X POST https://app.harbor.my/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "password",
    "client_id": "harbor-app",
    "username": "jane.doe@example.com",
    "password": "correct horse battery staple",
    "scope": "notes notebooks sync",
    "device_id": "ios-9F3A",
    "device_name": "Jane'\''s iPhone"
  }'
{
  "access_token": "at_9f3a7c1e…",
  "refresh_token": "rt_2b8d4f6a…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "notes notebooks sync"
}

The two-factor branch. When the account has 2FA enabled, a correct password does not return tokens. Instead you get 200 OK with a bare challenge object — note two_factor_required, and the absence of access_token:

{ "two_factor_required": true, "challenge": "mfc_7d4b…", "method": "email", "expires_in": 600 }

A one-time code is emailed to the user; complete the login with the mfa_otp grant below. Every client using the password grant must handle this branch.

Two-factor grant

grant_type=mfa_otp — completes a two-factor login. Exchange the challenge from the password-grant response plus the emailed code (or one of the user’s single-use recovery codes) for the token bundle. The challenge is single-use, short-lived, and attempt-capped.

FieldTypeRequiredDescription
grant_typestringyesmfa_otp
client_idstringyesharbor-app
challengestringyesThe mfc_… token from the password-grant response
codestringyesThe emailed numeric OTP, or a single-use recovery code
curl -X POST https://app.harbor.my/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "mfa_otp",
    "client_id": "harbor-app",
    "challenge": "mfc_7d4b…",
    "code": "483920"
  }'

The success response is the standard bare token bundle. Too many wrong codes on one challenge returns 429 two_factor_locked; a bad, expired, or consumed challenge returns a generic 401 invalid_grant.

Refresh grant

grant_type=refresh_token — rotates a refresh token. Each refresh token is single-use: a successful rotation issues a new access and refresh token and invalidates the one you presented. Persist the new refresh_token before you use the new access token.

FieldTypeRequiredDescription
grant_typestringyesrefresh_token
client_idstringyesThe client the token was issued to
refresh_tokenstringyesThe current rt_… token
scopestringnoMay only narrow the existing grant
curl -X POST https://app.harbor.my/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "client_id": "harbor-app",
    "refresh_token": "rt_2b8d4f6a…"
  }'

Rotation and reuse rules:

  • Replaying an already-rotated refresh token is treated as theft: the server revokes the entire token family — every descendant refresh token and its linked access tokens — and returns 401 invalid_grant. The user must log in again.
  • Exception: a short reuse-grace window (default 30 s) treats a replay as the client’s own retry — a backgrounded mobile app or extension service worker can die between the server rotating the token and the client saving the new bundle. Within the window, and only if the chain has not advanced past that token’s immediate successor, the replay returns a fresh bundle instead of revoking.
  • Rotation is atomic: two simultaneous uses of one token yield exactly one new bundle.

Authorization-code grant (PKCE)

grant_type=authorization_code — completes the Authorization Code + PKCE flow. Exchange the one-time code from the authorize redirect — plus the PKCE code_verifier and the exact redirect_uri the code was bound to — for the token bundle. The code is single-use and short-lived (default 60 s).

FieldTypeRequiredDescription
grant_typestringyesauthorization_code
client_idstringyesMust allow the grant — a seeded client (e.g. harbor-webclipper) or your registered app_… client
codestringyesThe ac_… code from the authorize redirect
redirect_uristringyesMust exactly match the URI the code was issued for, port included
code_verifierstringyesThe PKCE verifier: BASE64URL(SHA256(code_verifier)) must equal the stored S256 code_challenge
client_secretstringconditionalRequired only for a confidential app (hbs_…). Public clients never send one. Missing/incorrect → 400 invalid_client
curl -X POST https://app.harbor.my/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "app_9f3a2b7c",
    "code": "ac_5e1d8f…",
    "redirect_uri": "https://myapp.example.com/callback",
    "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  }'

Any code failure — unknown, expired, or already-consumed code, a client or redirect_uri mismatch, a wrong verifier, or a replay — returns a generic 401 invalid_grant, with no detail about which check failed.

Provider grants (Apple & Google)

grant_type=apple and grant_type=google are provider token exchanges for Sign in with Apple and Google sign-in, used by the first-party clients (harbor-app on the web, harbor-desktop natively). Both are off unless the server is configured for them — an unconfigured provider grant returns 400 unsupported_grant_type.

  • Apple posts an identity_token (Apple’s signed JWT, verified against Apple’s JWKS) plus a required nonce that must match the token’s nonce exactly. name is optional (Apple sends it only on the first sign-in); device_id/device_name are optional.
  • Google posts one of authorization_code (the web popup flow, exchanged server-side) or id_token (native apps; verified against Google’s JWKS with email_verified=true required). nonce is optional and applies to the id_token path only. If both fields are present, the code wins.

Both resolve the user by the provider’s stable sub: a first sign-in links to an existing account on a verified matching email, otherwise creates one (email marked verified). The success response is the same bare token bundle. Provider failures return 401 invalid_grant; a bad audience or nonce returns 400 invalid_request; account creation/linking can return 403 registration_disabled or 409 email_taken.

To discover which providers are enabled — and the client-side parameters to start their flows — call the public descriptor:

GET /api/v1/auth/providers · public — no bearer required

curl https://app.harbor.my/api/v1/auth/providers
{
  "apple": { "enabled": true, "client_id": "harbor-app", "services_id": "my.harbor.web", "redirect_uri": "https://app.harbor.my/login", "scope": "name email" },
  "google": { "enabled": true, "client_id": "harbor-app", "google_client_id": "harbor-web.apps.googleusercontent.com", "scope": "openid email profile" }
}

The response is a bare object (not data-wrapped); an unconfigured provider’s block is { "enabled": false }.

Token endpoint errors

  • 400 unsupported_grant_type — unknown grant, or an Apple/Google grant the server isn’t configured for.
  • 400 invalid_client — unknown client, a grant the client doesn’t allow, or a missing/incorrect confidential client_secret.
  • 400 invalid_scope — a scope outside the catalog or outside the client’s allowed set.
  • 400 invalid_request — bad Apple/Google audience or nonce.
  • 401 invalid_grant — bad credentials; unknown/expired/used/revoked refresh token; bad, expired, or consumed 2FA challenge or wrong code; bad authorization code; failed provider token. Always generic — nothing is enumerated.
  • 429 two_factor_locked — too many wrong codes on one 2FA challenge.
  • 403 email_unverified — only when the server requires a verified email to log in.
  • 403 registration_disabled / 409 email_taken — provider grants that would create or link an account.

The Authorization Code + PKCE flow

The redirect-based login for clients that must not hold the user’s password: the Web Clipper, the CLI, the desktop app, and every third-party OAuth app. The client opens Harbor’s hosted login + consent page; the user authenticates on Harbor; on approval Harbor redirects back to the client’s registered redirect_uri with a one-time code, which the client exchanges for tokens with the authorization-code grant.

The rules:

  • PKCE is mandatory and S256-only. code_challenge_method=plain is rejected. Because PKCE proves possession, public clients need no secret.
  • redirect_uri must exactly match a registered URI, with one spec-sanctioned exception: a registered loopback URI (http://127.0.0.1:<port>/… or http://[::1]:<port>/…) matches on scheme + host + path with any port (RFC 8252 §7.3), so a native or CLI client can bind a random ephemeral port. The localhost hostname is not port-loosened — use the IP literals.
  • The code is single-use and short-lived (default 60 s), bound to its client, its concrete redirect_uri, and its PKCE challenge. The token step re-checks the redirect_uri exactly, port included.
  • Declining never mints a code: the hosted consent page redirects to redirect_uri?error=access_denied&state=… itself.

Three first-party redirect clients are seeded — all public (no secret), grants authorization_code refresh_token:

Client IDScopesRedirect URIs
harbor-webclippernotes notebooks tags files profileBrowser-extension redirect (configured server-side)
harbor-clinotes notebooks tags sync files search profilehttp://127.0.0.1/callback, http://[::1]/callback (any port)
harbor-desktopnotes notebooks tags sync files search profileharbor-desktop://oauth/callback, plus the same loopback fallbacks

Validate an authorization request

GET /api/v1/oauth/authorize · public — no bearer required

Validates the authorization request and returns the client and requested-scope metadata that the hosted consent screen renders. Mints nothing.

FieldTypeRequiredDescription
response_typestringyesMust be code
client_idstringyesThe requesting client
redirect_uristringyesExact match to a registered URI (loopback URIs match any port)
scopestringnoSpace-delimited; ⊆ the client’s allowed scopes
statestringnoOpaque; echoed back on the redirect — verify it
code_challengestringyesBASE64URL(SHA256(code_verifier))
code_challenge_methodstringyesS256 only
curl "https://app.harbor.my/api/v1/oauth/authorize?response_type=code&client_id=app_9f3a2b7c&redirect_uri=https%3A%2F%2Fmyapp.example.com%2Fcallback&scope=notes%20files&state=opaque-xyz&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256"
{
  "client_id": "app_9f3a2b7c",
  "client_name": "Cool App",
  "owner": "Jane Developer",
  "first_party": false,
  "verified": false,
  "scopes": ["notes", "files"],
  "scope_details": [
    { "id": "notes", "label": "Notes", "description": "Read and write your notes." },
    { "id": "files", "label": "Files", "description": "Upload, download, and manage file attachments." }
  ],
  "redirect_uri": "https://myapp.example.com/callback",
  "state": "opaque-xyz"
}

The response is a bare object. owner names the app’s developer (null for a seeded Harbor client); first_party and verified are trust flags the consent screen shows so users know exactly who is asking.

Errors:

  • 400 invalid_client — unknown client, or the client doesn’t allow the authorization_code grant.
  • 400 invalid_request — an unregistered redirect_uri (the user is never redirected to it), a bad response_type, or a missing/plain PKCE challenge.
  • 400 invalid_scope — a scope outside the catalog or the client’s allowed set.

Approve the request and mint a code

POST /api/v1/oauth/authorize · bearer required (any valid token; no scope requirement)

The signed-in user approving the client. Re-validates the request server-side, mints a single-use PKCE-bound authorization code for the bearer’s user (only sha256(code) is stored), and returns the redirect target to navigate to. The user comes from the token, never the body. In production this call is made by Harbor’s hosted consent page — your client only handles the redirect.

The body carries the same fields as the GET query, as JSON: response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method.

curl -X POST https://app.harbor.my/api/v1/oauth/authorize \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "response_type": "code",
    "client_id": "app_9f3a2b7c",
    "redirect_uri": "https://myapp.example.com/callback",
    "scope": "notes files",
    "state": "opaque-xyz",
    "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    "code_challenge_method": "S256"
  }'
{ "redirect_uri": "https://myapp.example.com/callback?code=ac_5e1d8f…&state=opaque-xyz" }

Errors: 401 unauthorized (no bearer), plus the same 400 invalid_client / 400 invalid_request / 400 invalid_scope as the GET.

Worked example: PKCE end to end

The complete flow in curl, driving the consent step by hand. This example uses a registered public app with the redirect https://myapp.example.com/callback and scopes notes files. (In production, steps 3–4 happen on Harbor’s hosted consent page; your client’s job is generating the verifier, catching the redirect, and exchanging the code.)

# 1. Generate a PKCE code verifier, its S256 challenge, and a state value.
CODE_VERIFIER=$(openssl rand -base64 96 | tr -d '=+/\n' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary | openssl base64 -A \
  | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)

# 2. Validate the request and preview what the consent screen will show.
curl "https://app.harbor.my/api/v1/oauth/authorize?response_type=code&client_id=app_9f3a2b7c&redirect_uri=https%3A%2F%2Fmyapp.example.com%2Fcallback&scope=notes%20files&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"

# 3. Approve as the signed-in user (any valid bearer for that user).
#    Returns the redirect URL carrying the one-time code.
curl -X POST https://app.harbor.my/api/v1/oauth/authorize \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"response_type\": \"code\",
    \"client_id\": \"app_9f3a2b7c\",
    \"redirect_uri\": \"https://myapp.example.com/callback\",
    \"scope\": \"notes files\",
    \"state\": \"$STATE\",
    \"code_challenge\": \"$CODE_CHALLENGE\",
    \"code_challenge_method\": \"S256\"
  }"
# → { "redirect_uri": "https://myapp.example.com/callback?code=ac_5e1d8f…&state=…" }

# 4. Parse the code from the redirect. Verify the returned state
#    matches the one you sent before trusting the code.
CODE="ac_5e1d8f…"

# 5. Exchange the code — within 60 seconds; it is single-use.
#    A confidential app would also send its "client_secret" here.
curl -X POST https://app.harbor.my/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d "{
    \"grant_type\": \"authorization_code\",
    \"client_id\": \"app_9f3a2b7c\",
    \"code\": \"$CODE\",
    \"redirect_uri\": \"https://myapp.example.com/callback\",
    \"code_verifier\": \"$CODE_VERIFIER\"
  }"
# → { "access_token": "at_…", "refresh_token": "rt_…",
#     "token_type": "Bearer", "expires_in": 3600, "scope": "notes files" }

# 6. Call the API with the access token.
curl https://app.harbor.my/api/v1/notes \
  -H "Authorization: Bearer at_9f3a7c1e…"

# 7. When the access token expires (~1 hour), rotate the refresh token.
#    Save the new refresh_token from the response — the old one is dead.
curl -X POST https://app.harbor.my/api/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "client_id": "app_9f3a2b7c",
    "refresh_token": "rt_2b8d4f6a…"
  }'

How a real client captures the redirect, without the user ever seeing the code:

  • Browser extension — register the value of chrome.identity.getRedirectURL() as the redirect URI and drive the flow with chrome.identity.launchWebAuthFlow(...); the browser intercepts the redirect and hands the full callback URL to the extension.
  • Native app — a custom URL scheme (like the desktop app’s harbor-desktop://oauth/callback) opened through ASWebAuthenticationSession or the platform equivalent; the OS delivers the redirect straight to the app. Custom-scheme URIs match the allowlist exactly (no port loosening).
  • CLI — bind a throwaway local HTTP server to a random port and use http://127.0.0.1:<that-port>/callback; the loopback rule matches any port. Either way, the token exchange must present the exact concrete redirect_uri — port included — that the code was minted with.

Token exchange in Python, JavaScript, and Go

Python

import requests

BASE = "https://app.harbor.my/api/v1"
CLIENT_ID = "app_9f3a2b7c"
REDIRECT_URI = "https://myapp.example.com/callback"


# Exchange the one-time authorization code — plus the PKCE
# verifier it was bound to — for an access + refresh token pair.
def exchange_code(code: str, code_verifier: str) -> dict:
    resp = requests.post(f"{BASE}/oauth/token", json={
        "grant_type": "authorization_code",
        "client_id": CLIENT_ID,
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "code_verifier": code_verifier,
        # A confidential app also sends:
        # "client_secret": os.environ["HARBOR_CLIENT_SECRET"],
    })
    resp.raise_for_status()
    return resp.json()  # bare token object — not wrapped in "data"


# Rotate the single-use refresh token for a fresh bundle.
# Persist the new refresh_token before using the access token.
def refresh(refresh_token: str) -> dict:
    resp = requests.post(f"{BASE}/oauth/token", json={
        "grant_type": "refresh_token",
        "client_id": CLIENT_ID,
        "refresh_token": refresh_token,
    })
    resp.raise_for_status()
    return resp.json()


tokens = exchange_code("ac_5e1d8f…", "the-verifier-from-step-1")
print(tokens["access_token"], tokens["expires_in"])

JavaScript

const BASE = "https://app.harbor.my/api/v1";
const CLIENT_ID = "app_9f3a2b7c";
const REDIRECT_URI = "https://myapp.example.com/callback";

// Exchange the one-time authorization code — plus the PKCE
// verifier it was bound to — for an access + refresh token pair.
async function exchangeCode(code, codeVerifier) {
  const resp = await fetch(`${BASE}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      client_id: CLIENT_ID,
      code,
      redirect_uri: REDIRECT_URI,
      code_verifier: codeVerifier,
    }),
  });
  if (!resp.ok) throw new Error(`token exchange failed: ${resp.status}`);
  return resp.json(); // bare token object — not wrapped in "data"
}

// Rotate the single-use refresh token for a fresh bundle.
// Persist the new refresh_token immediately — the old one is dead.
async function refresh(refreshToken) {
  const resp = await fetch(`${BASE}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "refresh_token",
      client_id: CLIENT_ID,
      refresh_token: refreshToken,
    }),
  });
  if (!resp.ok) throw new Error(`refresh failed: ${resp.status}`);
  return resp.json();
}

Go

package main

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

const (
	base        = "https://app.harbor.my/api/v1"
	clientID    = "app_9f3a2b7c"
	redirectURI = "https://myapp.example.com/callback"
)

// TokenBundle is the bare object the token endpoint returns.
// It is not wrapped in a "data" envelope.
type TokenBundle struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	Scope        string `json:"scope"`
}

// postToken sends a grant body to POST /oauth/token and decodes
// the resulting token bundle.
func postToken(body map[string]string) (*TokenBundle, error) {
	buf, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	resp, err := http.Post(base+"/oauth/token", "application/json", bytes.NewReader(buf))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("token request failed: %s", resp.Status)
	}
	var t TokenBundle
	if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
		return nil, err
	}
	return &t, nil
}

// ExchangeCode swaps the one-time authorization code — plus the
// PKCE verifier it was bound to — for an access + refresh pair.
func ExchangeCode(code, verifier string) (*TokenBundle, error) {
	return postToken(map[string]string{
		"grant_type":    "authorization_code",
		"client_id":     clientID,
		"code":          code,
		"redirect_uri":  redirectURI,
		"code_verifier": verifier,
	})
}

// Refresh rotates the single-use refresh token for a fresh bundle.
// Persist the new refresh token before using the access token.
func Refresh(refreshToken string) (*TokenBundle, error) {
	return postToken(map[string]string{
		"grant_type":    "refresh_token",
		"client_id":     clientID,
		"refresh_token": refreshToken,
	})
}

List grantable scopes

GET /api/v1/oauth/scopes · public — no bearer required

The canonical scope catalog — the single list that PAT creation, app registration, and the consent screen all render, in presentation order. Requesting a scope outside this catalog anywhere else returns 400 invalid_scope.

curl https://app.harbor.my/api/v1/oauth/scopes
{
  "data": [
    { "id": "notes", "label": "Notes", "description": "Read and write your notes." },
    { "id": "notebooks", "label": "Notebooks", "description": "Read and write your notebooks and stacks." },
    { "id": "tags", "label": "Tags", "description": "Read and write your tags." },
    { "id": "files", "label": "Files", "description": "Upload, download, and manage file attachments." },
    { "id": "search", "label": "Search", "description": "Search across your notes and attachments." },
    { "id": "sync", "label": "Sync", "description": "Sync your data across devices for offline access." },
    { "id": "profile", "label": "Profile", "description": "Read and update your account profile and settings." }
  ]
}

Revoke a token

POST /api/v1/oauth/revoke · public — the token itself is the credential

Revoke a token, RFC 7009 style. A refresh token revokes its whole family (and the access tokens descended from it); an access token revokes just itself.

FieldTypeRequiredDescription
tokenstringyesThe token to revoke
token_type_hintstringnoe.g. refresh_token
curl -X POST https://app.harbor.my/api/v1/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{ "token": "rt_2b8d4f6a…", "token_type_hint": "refresh_token" }'

The response is 200 OK with an empty body — always 200, even for an unknown token, so the endpoint leaks nothing about token validity.

Log out

POST /api/v1/auth/logout · bearer required

Log out the current session — revokes the bearer’s refresh-token family. Pass all_devices: true to revoke every session for the user.

FieldTypeRequiredDescription
all_devicesboolnoRevoke every session for the user
curl -X POST https://app.harbor.my/api/v1/auth/logout \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "all_devices": false }'

Returns 204 No Content. Note that logging out everywhere does not revoke personal access tokens — a PAT is an independent credential.

Register an account

POST /api/v1/auth/register · public — no bearer required

Create an account. The password is hashed with argon2id and the user’s per-user database is provisioned; a verification email is enqueued best-effort. As an OAuth-app developer you rarely call this — end users normally sign up in the Harbor app — but it’s here for completeness.

FieldTypeRequiredDescription
emailstringyesNormalized to lowercase
passwordstringyes8–256 chars; trivial/blocklisted passwords rejected
namestringyes1–120 chars
localestringnoDefault en
timezonestringnoIANA name; default UTC
curl -X POST https://app.harbor.my/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "Jane.Doe@Example.com",
    "password": "correct horse battery staple",
    "name": "Jane Doe",
    "timezone": "America/New_York"
  }'
{
  "data": {
    "user": { "id": "5b1f2c9a-8e4d-4f7a-b2c1-9d3e6f8a1b4c", "email": "jane.doe@example.com", "name": "Jane Doe" },
    "token": { "access_token": "at_9f3a7c1e…", "refresh_token": "rt_2b8d4f6a…", "token_type": "Bearer", "expires_in": 3600 }
  }
}

Returns 201 Created. The token bundle is included when the server issues tokens on registration (the default).

Errors: 422 validation_failed (per-field details), 409 email_taken, 403 registration_disabled, 400 bad_request, 500 internal_error.

Password-reset and email-verification endpoints also exist and are public (single-use token flows with the same anti-enumeration posture); they matter to app developers only indirectly and aren’t covered here.

Personal access tokens

A personal access token (PAT) is a long-lived bearer for scripts, CI, and your own integrations — an ordinary access token under the hood, sent as Authorization: Bearer hbp_…, with its granted scopes gating what it can do exactly like a session token. The raw value is shown exactly once at mint (only its sha256 is stored), and the hbp_ prefix makes a leaked token greppable by secret scanners.

PATs deliberately survive “log out everywhere” and password resets — they are independent credentials, revoked only by DELETE /api/v1/tokens/:id or account deletion.

All PAT endpoints require a bearer with the profile scope and act on the caller’s own tokens.

Create a personal access token

POST /api/v1/tokens · scope: profile

FieldTypeRequiredDescription
namestringyesUser-facing label, 1–120 chars
scopesstring[]yes≥1 scope, each in the catalog; duplicates collapsed
expires_innumbernoLifetime in seconds; omit or null for a token that never expires
curl -X POST https://app.harbor.my/api/v1/tokens \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "CI deploy key", "scopes": ["notes", "files"], "expires_in": 2592000 }'
{
  "data": {
    "id": "b1f2c9a0-4c7e-4d2a-9b1f-3e8a5c2d7f40",
    "token": "hbp_9f3a2b7c1e5d8f4a…",
    "name": "CI deploy key",
    "scopes": ["notes", "files"],
    "token_kind": "pat",
    "expires_at": 1752592000000,
    "created_at": 1750000000000
  }
}

Returns 201 Created. The token field is the raw value, returned once — store it now. expires_at is epoch-ms, or null for a never-expiring token.

Errors and edge cases:

  • 422 validation_failed — missing/blank name, or a non-positive expires_in.
  • 400 invalid_scope — an unknown scope, or an empty scope set.
  • 409 pat_limit_reached — the user already holds the maximum number of live PATs (server cap, default 10).
  • 403 insufficient_scope / 401 — the usual bearer failures.
  • A PAT minted with only notes is rejected with 403 insufficient_scope on, say, a sync-scoped route — its own scopes are enforced on every request.

List your tokens

GET /api/v1/tokens · scope: profile

Lists the caller’s live (non-revoked) PATs, most recently created first. Session tokens are never included, and the raw token value and its hash are never returned — only the mint response ever shows the token.

curl https://app.harbor.my/api/v1/tokens \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{
  "data": [
    {
      "id": "b1f2c9a0-4c7e-4d2a-9b1f-3e8a5c2d7f40",
      "name": "CI deploy key",
      "scopes": ["notes", "files"],
      "created_at": 1750000000000,
      "last_used_at": 1750003600000,
      "expires_at": 1752592000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}

last_used_at is null until the PAT is first presented; expires_at is null for a never-expiring token. Standard paging block (limit, offset, total, has_more).

Rename a token

PATCH /api/v1/tokens/:id · scope: profile

Only the name is mutable — a token’s scopes and expiry are fixed at creation. To change those, mint a new token and revoke the old one.

FieldTypeRequiredDescription
idstring (path)yesThe PAT id
namestringyesThe new label
curl -X PATCH https://app.harbor.my/api/v1/tokens/b1f2c9a0-4c7e-4d2a-9b1f-3e8a5c2d7f40 \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "CI deploy key (staging)" }'
{
  "data": {
    "id": "b1f2c9a0-4c7e-4d2a-9b1f-3e8a5c2d7f40",
    "name": "CI deploy key (staging)",
    "scopes": ["notes", "files"],
    "created_at": 1750000000000,
    "last_used_at": 1750003600000,
    "expires_at": 1752592000000
  }
}

Errors: 422 validation_failed (missing/blank name); 404 not_found for an id that isn’t the caller’s, is revoked, or is a login-session token.

Revoke a personal access token

DELETE /api/v1/tokens/:id · scope: profile

Revokes the PAT — it immediately fails authentication and drops off the list. Not reversible; mint a new token if needed.

curl -X DELETE https://app.harbor.my/api/v1/tokens/b1f2c9a0-4c7e-4d2a-9b1f-3e8a5c2d7f40 \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Returns 204 No Content. An id that isn’t the caller’s, doesn’t exist, or is a login-session token returns 404 not_found — the endpoint never confirms an id it can’t act on.

OAuth apps

The developer self-service registry for third-party OAuth apps — clients other Harbor users can authorize through the Authorization Code + PKCE flow. Every registered app:

  • is owned by you — you can only see and edit your own;
  • can never use the password grant: its allowed grants are forced to authorization_code refresh_token;
  • is either public (PKCE-only, no secret) or confidential (a client secret is additionally required at the token endpoint).

One privacy note worth knowing before you build: encrypted note titles, bodies, and attachments stay ciphertext to any app or PAT. The server never holds the keys, so a third-party integration can never read a user’s encrypted content.

All app endpoints require a bearer with the profile scope.

Register an app

POST /api/v1/oauth/apps · scope: profile

FieldTypeRequiredDescription
namestringyes1–120 chars; what users see on the consent screen
typestringyespublic or confidentialimmutable after creation
redirect_urisstring[]yes≥1 exact URI: https on any host, or http on loopback (127.0.0.1 / [::1] / localhost). No wildcards, no fragments
scopesstring[]yes≥1 scope, each in the catalog — the maximum your app can request
curl -X POST https://app.harbor.my/api/v1/oauth/apps \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Web App",
    "type": "confidential",
    "redirect_uris": ["https://myapp.example.com/callback"],
    "scopes": ["notes", "files"]
  }'
{
  "data": {
    "client_id": "app_9f3a2b7c",
    "name": "My Web App",
    "type": "confidential",
    "redirect_uris": ["https://myapp.example.com/callback"],
    "scopes": ["notes", "files"],
    "created_at": 1750000000000,
    "updated_at": 1750000000000,
    "client_secret": "hbs_4d8e1f7a…"
  }
}

Returns 201 Created with the server-generated client_id (prefixed app_). For a confidential app, client_secret (prefixed hbs_) is returned exactly once — only its sha256 is stored. Public apps have no secret.

Errors: 422 validation_failed (bad name, type, or redirect URI), 400 invalid_scope, 409 app_limit_reached (per-owner cap, default 10).

List your apps

GET /api/v1/oauth/apps · scope: profile

Lists the caller’s apps, newest first. Never includes a secret.

curl https://app.harbor.my/api/v1/oauth/apps \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{
  "data": [
    {
      "client_id": "app_9f3a2b7c",
      "name": "My Web App",
      "type": "confidential",
      "redirect_uris": ["https://myapp.example.com/callback"],
      "scopes": ["notes", "files"],
      "created_at": 1750000000000,
      "updated_at": 1750000000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}

Get an app

GET /api/v1/oauth/apps/:id · scope: profile

Fetch one of your apps (no secret, ever).

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

Returns the data-wrapped app object. 404 not_found for an id that isn’t the caller’s.

Update an app

PATCH /api/v1/oauth/apps/:id · scope: profile

Edit name, redirect_uris, and/or scopes — each optional, same validation as create. The type is immutable; register a new app to change it.

FieldTypeRequiredDescription
idstring (path)yesThe app’s client id
namestringnoNew display name
redirect_urisstring[]noReplaces the registered URIs
scopesstring[]noReplaces the allowed scope set
curl -X PATCH https://app.harbor.my/api/v1/oauth/apps/app_9f3a2b7c \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "redirect_uris": ["https://myapp.example.com/callback", "https://staging.myapp.example.com/callback"] }'

Returns 200 with the updated, data-wrapped app. Errors: 422 validation_failed, 400 invalid_scope, 404 not_found.

Delete an app

DELETE /api/v1/oauth/apps/:id · scope: profile

Deletes the app and revokes every outstanding access and refresh token issued for it, across all users — the app is cut off immediately.

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

Returns 204 No Content; 404 not_found for a non-owned or unknown id.

Rotate a client secret

POST /api/v1/oauth/apps/:id/secret · scope: profile

Rotates a confidential app’s client secret. The stored hash is replaced immediately — there is no overlap window, so the old secret stops working at once. The new raw secret is returned exactly once.

curl -X POST https://app.harbor.my/api/v1/oauth/apps/app_9f3a2b7c/secret \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{ "data": { "client_id": "app_9f3a2b7c", "type": "confidential", "client_secret": "hbs_7c2e9b4f…" } }

Errors: 422 for a public app (it has no secret); 404 not_found for a non-owned or unknown id.

Connected apps

The flip side of the registry: the third-party apps a user has authorized, and how to disconnect them. Both endpoints require a bearer with the profile scope. First-party Harbor clients (the app, CLI, desktop) never appear here — a user can’t accidentally disconnect Harbor itself.

List connected apps

GET /api/v1/oauth/grants · scope: profile

Lists the distinct third-party apps the caller currently has live tokens for.

curl https://app.harbor.my/api/v1/oauth/grants \
  -H "Authorization: Bearer $HARBOR_TOKEN"
{
  "data": [
    {
      "client_id": "app_9f3a2b7c",
      "name": "Cool App",
      "owner": "Jane Developer",
      "verified": false,
      "scopes": ["notes", "files"],
      "first_authorized": 1750000000000,
      "last_authorized": 1751000000000
    }
  ],
  "paging": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}

scopes is the union of everything granted across that app’s authorizations; first_authorized / last_authorized are epoch-ms.

Disconnect an app

DELETE /api/v1/oauth/grants/:clientId · scope: profile

Revokes every access and refresh token that app holds for the caller — other users’ grants and other apps are untouched. The app must go through consent again to come back.

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

Returns 204 No Content. A first-party client — or an app the caller has no live grant for — returns 404 not_found.

  • Authentication — the guided intro: creating a PAT in the app, registering an app, and choosing between them
  • Errors — the error envelope and status codes referenced throughout this page
  • Notes API — the first resource most integrations call once they hold a token