Harbor
Developer docs menu

API reference

Sync & Keystore API

The USN engine behind every Harbor client — a per-user change feed you pull, change envelopes you push, and the zero-knowledge keystore that rides along.

This is the advanced surface for building a full Harbor client — an app that keeps a complete local copy of a user’s notes and works offline. Clients pull server changes since a cursor, push their local changes, register devices, and acknowledge how far they have caught up. Ordering is server-authoritative.

Most integrations don’t need this page. If you’re automating notes, notebooks, tags, or files, use the plain REST resource endpoints — they’re simpler and give you the same data. Raw sync is for offline-capable clients that maintain their own local store (desktop apps, mobile apps, CLIs with a cache). Start with the Notes API unless you know you need a change feed.

Every endpoint on this page requires a bearer token with the sync scope and is strictly scoped to the current user.

How sync works

  • USN (update sequence number). A per-scope, server-assigned, monotonic integer. Every write to a syncable record allocates the next USN in the same transaction, so usn totally orders all changes in a scope. REST mutations allocate USNs too — a note edited in the web app shows up in your pull feed like any other change.
  • Scope. A USN counter. In v1 there is exactly one scope per user, and its scope_id is the user’s id — every request must send the caller’s own user id as scope_id; a mismatch is 403 scope_forbidden. scope_max_usn (returned on most responses) is the highest USN assigned so far — poll it cheaply to see whether there is anything new to pull.
  • Syncable types. notebook, note, tag, note_tag, keystore, shortcut, note_template, task, saved_search, stack, resource (attachment metadata), and note_resource (the note↔attachment junction). One more type, ocr_text, is pull-only — the server is its sole author, and it is rejected on push. Attachment bytes never ride the delta — only metadata does; bytes move separately via presigned upload/download (see the Files API).
  • Tombstones. Deletes are soft: the record comes through with deleted: true so every device learns to drop it. Tombstones are retained until they fall below the device GC floor — the minimum last_acked_usn across the user’s non-stale devices — then garbage-collected.
  • resync_required. If a client’s cursor falls below the retained-history floor (intervening tombstones were GC’d), it cannot catch up incrementally. Pull returns 409 resync_required and the client must reset its local store and restart from after_usn=0.

The change envelope

Both directions — pull (server→client) and push (client→server) — carry the same typed wire shape:

{
  "type": "note",
  "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70",
  "usn": 88,
  "deleted": false,
  "base_usn": 80,
  "change_id": "c-7f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b",
  "record": { "...": "the full record payload" }
}
FieldTypeDescription
typestringOne of the syncable types above.
idstringThe record’s client UUID.
usnintServer-assigned. Filled on pull; ignored on push — the server allocates the authoritative one.
deletedboolTombstone flag.
base_usnintPush only: the server USN this change is based on, used for conflict detection.
change_idstringPush only: a client-generated UUID for idempotency. Safe to retry a batch.
recordobjectThe full record payload for a live record; a minimal { "id", "usn", "deleted": true } stub for a tombstone.

On pull the server fills type, id, usn, deleted, and record. On push the client fills base_usn, change_id, deleted (for a delete), and record.

Pull changes

POST /api/v1/sync/pull · scope: sync

Return every syncable record with usn > after_usn, in ascending USN order, tombstones included, merged into one stream and capped at limit. Loop until has_more is false.

FieldTypeRequiredDescription
scope_idstringyesMust equal the caller’s user id (v1).
after_usnintnoReturn records with usn > this. 0 = full sync. Must be >= 0.
limitintnoDefault 100, hard cap 500 (clamped).
device_idstringnoWhen set, advances this device’s cursor — an implicit ack: sending after_usn=N proves everything <= N was applied.
curl https://app.harbor.my/api/v1/sync/pull \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scope_id": "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45",
    "after_usn": 80,
    "limit": 200,
    "device_id": "ios-9F3A"
  }'

Response:

{
  "scope_id": "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45",
  "scope_max_usn": 95,
  "has_more": true,
  "chunk": [
    {
      "type": "note",
      "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70",
      "usn": 88,
      "deleted": false,
      "record": { "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70", "title": "Ship checklist", "content": "<p>…</p>", "usn": 88, "deleted": false }
    },
    {
      "type": "note_tag",
      "id": "b4d9e2c1-8f3a-4b6d-a5e7-9c0f1d2a3b4c",
      "usn": 91,
      "deleted": true,
      "record": { "id": "b4d9e2c1-8f3a-4b6d-a5e7-9c0f1d2a3b4c", "usn": 91, "deleted": true }
    }
  ]
}

An ocr_text record in the chunk carries an attachment’s server-produced OCR text so your client can search inside attachments offline — even attachments whose bytes it never downloaded. Its record carries resource_hash (sha256 of the attachment it belongs to), engine, source, page_count, plaintext (never truncated), word_count, created_at, and updated_at. Index plaintext locally, keyed by resource_hash; an ocr_text tombstone means the attachment was permanently deleted, so drop its text from your index. Encrypted attachments are never OCR’d and never produce an ocr_text record.

Errors:

  • 422 validation_failedscope_id missing, after_usn < 0, or invalid JSON.
  • 403 scope_forbiddenscope_id is not the caller’s user id.
  • 409 resync_required — your cursor is below the GC floor. Reset the local store and restart from after_usn=0.

Implicit ack on pull is controlled by the server setting SYNC_ACK_IMPLICIT_ON_PULL (default on) and only advances a device that already exists — it never creates one.

Push changes

POST /api/v1/sync/push · scope: sync

Upload a batch of local changes. Each change commits in its own transaction and gets a per-change result — individual changes never fail the batch; they come back as rejected. The whole request fails (422) only on batch-level validation.

FieldTypeRequiredDescription
scope_idstringyesMust equal the caller’s user id (v1).
device_idstringyesThe pushing device.
changesEnvelope[]yes1–500 changes; each needs a change_id, id, and known type.
curl https://app.harbor.my/api/v1/sync/push \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scope_id": "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45",
    "device_id": "ios-9F3A",
    "changes": [
      {
        "type": "note",
        "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70",
        "base_usn": 80,
        "change_id": "c-7f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b",
        "record": { "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70", "title": "Edited", "content": "<p>hi</p>", "notebook_id": "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45" }
      }
    ]
  }'

Response:

{
  "scope_id": "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45",
  "scope_max_usn": 97,
  "results": [
    { "change_id": "c-7f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b", "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70", "type": "note", "status": "applied", "new_usn": 96 }
  ]
}

Per-change result fields:

FieldDescription
statusapplied, conflict, or rejected.
new_usnThe assigned USN (present when applied).
server_recordThe authoritative record (present when conflict).
dedupedtrue on a replayed change_id — the cached outcome is returned; nothing is re-applied.
errorReason string (present when rejected).

Conflicts — sync_conflict handling

A conflict means the server moved on since your client based its change (server usn > base_usn):

  • Note-body conflicts — a change that touches a note’s title or content is not applied. The result comes back status: "conflict" with the server’s authoritative version in server_record. Your client should keep the server version and turn its local edit into a conflict copy so nothing is lost.
  • Structural records (notebook, tag, note_tag, keystore, shortcut, note_template, resource, note_resource) and structural-only note changes (only non-body fields like notebook_id) are last-write-wins — applied normally, no conflict.

A note-body conflict result looks like:

{
  "change_id": "c-7f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b",
  "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70",
  "type": "note",
  "status": "conflict",
  "server_record": { "id": "9c2e4f1b-7a3d-4e8c-b2f6-1d5a9c8e3b70", "title": "Ship checklist", "content": "<p>…</p>", "usn": 93 }
}

Rejections and edge cases

  • blob_missing — a live resource was pushed before its bytes were uploaded. Presign-upload the bytes first (see the Files API); a resource tombstone needs no bytes.
  • plan_limit_reached — a create of a note, notebook, tag, task, or resource while the account is read-only or over its plan cap. Deletes/tombstones are never blocked (so a frozen account can get back under its cap), and pull is never blocked.
  • A malformed record or a too-large note body is also returned as status: "rejected" with an error — not as an HTTP error.
  • ocr_text is not a known push type — a change with type: "ocr_text" fails batch validation.
  • Pushing a note tombstone also reclaims its orphaned attachment blobs — the server tombstones the note’s note_resources junctions itself, and those tombstones flow back to every device via pull.
  • Push refreshes the device’s last_seen and last_pushed_usn, creating a minimal device row if the client pushed before registering.

Batch-level errors: 422 validation_failed (scope_id/device_id missing, empty changes, batch over 500, a change missing change_id/id or with an unknown type, invalid JSON), 403 scope_forbidden.

Acknowledge applied changes

POST /api/v1/sync/ack · scope: sync

Explicitly advance a device’s last_acked_usn after fully applying a pull chain. The cursor only moves forward, and last_seen is refreshed. Acks feed tombstone GC — a device that never acks pins tombstones forever.

FieldTypeRequiredDescription
device_idstringyesThe device.
acked_usnintyesHighest USN fully applied; must not exceed scope_max_usn.
curl https://app.harbor.my/api/v1/sync/ack \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "device_id": "ios-9F3A", "acked_usn": 95 }'

Response — the updated device object, bare:

{ "device_id": "ios-9F3A", "name": "Jane's iPhone", "platform": "ios", "last_seen": 1750003600000, "last_pushed_usn": 96, "last_acked_usn": 95, "created_at": 1750000000000 }

Errors: 404 not_found (unknown device), 422 validation_failed (acked_usn exceeds scope_max_usn, invalid JSON).

Register a device

POST /api/v1/sync/devices · scope: sync

Register (upsert) a device by its client UUID — sets created_at on first sight, refreshes name/platform/last_seen otherwise. Idempotent; call it on every app start if you like.

FieldTypeRequiredDescription
device_idstringyesClient UUID.
namestringnoDisplay name.
platformstringnoios | android | macos | windows | web | cli.
curl https://app.harbor.my/api/v1/sync/devices \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "device_id": "ios-9F3A", "name": "Jane'\''s iPhone", "platform": "ios" }'

Response — the device object, bare:

{ "device_id": "ios-9F3A", "name": "Jane's iPhone", "platform": "ios", "last_seen": 1750000000000, "last_pushed_usn": 0, "last_acked_usn": 0, "created_at": 1750000000000 }

Errors: 422 validation_failed (device_id required, invalid JSON).

List devices

GET /api/v1/sync/devices · scope: sync

List the user’s devices with their sync status, plus scope_max_usn and the tombstone gc_floor (the minimum last_acked_usn across non-stale devices — tombstones at or below it can be collected). A device is stale once its last_seen is older than DEVICE_STALE_DAYS (default 90); stale devices are excluded from the floor. With no active devices the floor is 0 and GC does nothing.

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

Response:

{
  "data": [
    { "device_id": "ios-9F3A", "name": "Jane's iPhone", "platform": "ios", "last_seen": 1750003600000, "last_acked_usn": 95, "stale": false }
  ],
  "scope_max_usn": 97,
  "gc_floor": 95
}

Note: this response is a bare object with a top-level data array plus scope_max_usn and gc_floor — it is not the standard {data, paging} collection envelope (there is no paging block).

Remove a device

DELETE /api/v1/sync/devices/:device_id · scope: sync

Remove a device so a decommissioned client stops pinning the GC floor. If you revoke a session, deregister its device too.

curl -X DELETE https://app.harbor.my/api/v1/sync/devices/ios-9F3A \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response: 204 No Content. Errors: 404 not_found (no such device).

Register a push token

POST /api/v1/sync/push-tokens · scope: sync

Optional, off by default. Register (or refresh) an APNs push token for silent sync-wake pushes: when the user’s scope advances, the server sends a content-available background push so the app pulls instead of polling. The push is a wake signal only — it carries no note content or user data. Idempotent, keyed by (user, device_id).

FieldTypeRequiredDescription
tokenstringyesThe opaque provider device token (APNs hex).
device_idstringyesClient-stable device UUID (the upsert key).
platformstringnoOnly "apns" is supported (default apns).
environmentstringno"production" or "sandbox" (default: the server’s APNS_ENVIRONMENT).
curl https://app.harbor.my/api/v1/sync/push-tokens \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "platform": "apns", "token": "a1b2c3d4e5f6", "device_id": "ios-9F3A", "environment": "production" }'

Response: 201 Created on first registration, 200 OK on refresh — the push token object, bare.

  • The whole feature is gated by the server flag SYNC_PUSH_ENABLED (default off). When disabled, both push-token endpoints return 404 not_found and clients simply keep polling scope_max_usn — no client change required.
  • 422 validation_failed — missing token/device_id, unsupported platform, or bad environment.
  • Tokens APNs reports as 410 Unregistered are removed automatically; revoking a session also unregisters that device’s token.
  • Send the header X-Harbor-Device-ID: <device_id> on your API requests so your own writes don’t wake your own device.

Remove a push token

DELETE /api/v1/sync/push-tokens/:device_id · scope: sync

Unregister a device’s push token — call it on logout or when the user turns notifications off. You can only delete your own device’s token.

curl -X DELETE https://app.harbor.my/api/v1/sync/push-tokens/ios-9F3A \
  -H "Authorization: Bearer $HARBOR_TOKEN"

Response: 204 No Content. Errors: 404 not_found (feature disabled via SYNC_PUSH_ENABLED, or no such device token).

The keystore

For users with encryption enabled, the wrapped master key lives in a single per-user, syncable keystore record. The GET/PUT pair below is a convenience surface over that one row, so a client can unlock (derive the KEK from the passphrase, unwrap the master key) or run first-time setup without paging a full sync/pull from after_usn=0 just to find it.

The server is zero-knowledge: the blob is fully opaque ciphertext, never inspected beyond being a non-empty string. The server never holds, derives, validates, or transmits the passphrase or any key. And the keystore remains a normal sync record — a PUT allocates a USN like any sync write and propagates to every device via sync/pull (last-write-wins).

The keystore object, returned bare (not data-wrapped) by both endpoints:

FieldTypeDescription
idstringUUID; stable across rotations — a rotation updates the same row.
blobstringThe opaque HRBK1 keystore (KEK salt, Argon2id params, wrapped master key). The server never reads it.
usnintServer-assigned; bumped on every write so the change propagates via sync/pull.
deletedboolA live keystore is always false.
updated_atintEpoch-ms of the last write.
created_atintEpoch-ms of first setup; preserved across rotations.

Get the keystore

GET /api/v1/keystore · scope: sync

Return the current user’s single live keystore record.

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

Response:

{ "id": "k1a2b3c4-5d6e-4f70-8a9b-0c1d2e3f4a5b", "blob": "<opaque HRBK1 keystore>", "usn": 7, "deleted": false, "updated_at": 1750000000000, "created_at": 1749000000000 }

Errors:

  • 404 not_found — the user has never set up a keystore (or it was tombstoned). This is the normal first-run signal: run first-time setup and PUT a new keystore.

Write the keystore

PUT /api/v1/keystore · scope: sync

Upsert the opaque keystore blob. First-time setup creates the single row (server-generated id); a later write — e.g. a passphrase rotation that re-wraps the same master key under a new KEK — updates the same row in place, preserving id and created_at and bumping usn/updated_at. The new keystore reaches every device on its next sync/pull (last-write-wins).

FieldTypeRequiredDescription
blobstringyesThe opaque HRBK1 keystore. Never inspected beyond requiring it to be non-empty.
curl -X PUT https://app.harbor.my/api/v1/keystore \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "blob": "<opaque HRBK1 keystore>" }'

Response — the upserted keystore object with its freshly allocated usn:

{ "id": "k1a2b3c4-5d6e-4f70-8a9b-0c1d2e3f4a5b", "blob": "<opaque HRBK1 keystore>", "usn": 8, "deleted": false, "updated_at": 1750000000000, "created_at": 1749000000000 }

Errors: 400 bad_request (body is not valid JSON), 422 validation_failed (blob missing or empty — details.blob; presence is the only check).

A complete pull loop

The core of every client: pull in chunks from your saved cursor until has_more is false, apply each envelope locally (upsert live records, drop tombstoned ones), persist the cursor as you go, then ack. On 409 resync_required, reset the local store and restart from after_usn=0.

curl

# One chunk; loop in your shell/tooling until has_more is false.
curl https://app.harbor.my/api/v1/sync/pull \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "scope_id": "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45", "after_usn": 0, "limit": 500, "device_id": "cli-7c41" }'

# When you're done applying, ack the highest USN you applied:
curl https://app.harbor.my/api/v1/sync/ack \
  -H "Authorization: Bearer $HARBOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "device_id": "cli-7c41", "acked_usn": 95 }'

Python

import os
import requests

BASE = "https://app.harbor.my/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['HARBOR_TOKEN']}"}
SCOPE_ID = "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45"  # your user id
DEVICE_ID = "cli-7c41"

cursor = load_cursor()  # 0 on first run

while True:
    r = requests.post(f"{BASE}/sync/pull", headers=HEADERS, json={
        "scope_id": SCOPE_ID,
        "after_usn": cursor,
        "limit": 500,
        "device_id": DEVICE_ID,
    })
    if r.status_code == 409:  # resync_required: history GC'd past our cursor
        reset_local_store()
        cursor = 0
        continue
    r.raise_for_status()
    page = r.json()

    for env in page["chunk"]:
        apply_change(env)      # upsert env["record"], or drop it if env["deleted"]
        cursor = env["usn"]
    save_cursor(cursor)

    if not page["has_more"]:
        break

# Tell the server how far we got — this feeds tombstone GC.
requests.post(f"{BASE}/sync/ack", headers=HEADERS,
              json={"device_id": DEVICE_ID, "acked_usn": cursor}).raise_for_status()

JavaScript

const BASE = "https://app.harbor.my/api/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.HARBOR_TOKEN}`,
  "Content-Type": "application/json",
};
const SCOPE_ID = "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45"; // your user id
const DEVICE_ID = "cli-7c41";

async function pullLoop() {
  let cursor = await loadCursor(); // 0 on first run

  while (true) {
    const res = await fetch(`${BASE}/sync/pull`, {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify({
        scope_id: SCOPE_ID,
        after_usn: cursor,
        limit: 500,
        device_id: DEVICE_ID,
      }),
    });
    if (res.status === 409) {
      // resync_required: history GC'd past our cursor
      await resetLocalStore();
      cursor = 0;
      continue;
    }
    if (!res.ok) throw new Error(`pull failed: ${res.status}`);
    const page = await res.json();

    for (const env of page.chunk) {
      await applyChange(env); // upsert env.record, or drop it if env.deleted
      cursor = env.usn;
    }
    await saveCursor(cursor);

    if (!page.has_more) break;
  }

  // Ack the highest USN we fully applied — this feeds tombstone GC.
  await fetch(`${BASE}/sync/ack`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ device_id: DEVICE_ID, acked_usn: cursor }),
  });
}

Go

package main

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

const (
	base     = "https://app.harbor.my/api/v1"
	scopeID  = "5b1f2c9a-4e2d-4f7a-9c3b-2d8e6f1a7b45" // your user id
	deviceID = "cli-7c41"
)

// Envelope is the typed change record carried by pull and push.
type Envelope struct {
	Type    string          `json:"type"`
	ID      string          `json:"id"`
	USN     int64           `json:"usn"`
	Deleted bool            `json:"deleted"`
	Record  json.RawMessage `json:"record"`
}

// post sends an authenticated JSON POST to the Harbor API.
func post(path string, body any) (*http.Response, error) {
	buf, _ := json.Marshal(body)
	req, _ := http.NewRequest("POST", base+path, bytes.NewReader(buf))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("HARBOR_TOKEN"))
	req.Header.Set("Content-Type", "application/json")
	return http.DefaultClient.Do(req)
}

// pullLoop pulls chunks from the saved cursor until caught up, then acks.
func pullLoop() error {
	cursor := loadCursor() // 0 on first run

	for {
		res, err := post("/sync/pull", map[string]any{
			"scope_id": scopeID, "after_usn": cursor, "limit": 500, "device_id": deviceID,
		})
		if err != nil {
			return err
		}
		if res.StatusCode == http.StatusConflict { // 409 resync_required
			res.Body.Close()
			resetLocalStore()
			cursor = 0
			continue
		}
		if res.StatusCode != http.StatusOK {
			res.Body.Close()
			return fmt.Errorf("pull failed: %d", res.StatusCode)
		}

		var page struct {
			HasMore bool       `json:"has_more"`
			Chunk   []Envelope `json:"chunk"`
		}
		err = json.NewDecoder(res.Body).Decode(&page)
		res.Body.Close()
		if err != nil {
			return err
		}

		for _, env := range page.Chunk {
			applyChange(env) // upsert env.Record, or drop it if env.Deleted
			cursor = env.USN
		}
		saveCursor(cursor)

		if !page.HasMore {
			break
		}
	}

	// Ack the highest USN we fully applied — this feeds tombstone GC.
	res, err := post("/sync/ack", map[string]any{"device_id": deviceID, "acked_usn": cursor})
	if err != nil {
		return err
	}
	res.Body.Close()
	return nil
}
  • Notes API — the simple REST surface most integrations should use instead of raw sync.
  • Files API — presigned upload/download for the attachment bytes that never ride the sync delta.
  • Errors — the error envelope and machine-readable codes used across the API.