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
usntotally 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_idis the user’s id — every request must send the caller’s own user id asscope_id; a mismatch is403 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), andnote_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: trueso every device learns to drop it. Tombstones are retained until they fall below the device GC floor — the minimumlast_acked_usnacross 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 returns409 resync_requiredand the client must reset its local store and restart fromafter_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" }
}
| Field | Type | Description |
|---|---|---|
type | string | One of the syncable types above. |
id | string | The record’s client UUID. |
usn | int | Server-assigned. Filled on pull; ignored on push — the server allocates the authoritative one. |
deleted | bool | Tombstone flag. |
base_usn | int | Push only: the server USN this change is based on, used for conflict detection. |
change_id | string | Push only: a client-generated UUID for idempotency. Safe to retry a batch. |
record | object | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
scope_id | string | yes | Must equal the caller’s user id (v1). |
after_usn | int | no | Return records with usn > this. 0 = full sync. Must be >= 0. |
limit | int | no | Default 100, hard cap 500 (clamped). |
device_id | string | no | When 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_failed—scope_idmissing,after_usn < 0, or invalid JSON.403 scope_forbidden—scope_idis not the caller’s user id.409 resync_required— your cursor is below the GC floor. Reset the local store and restart fromafter_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.
| Field | Type | Required | Description |
|---|---|---|---|
scope_id | string | yes | Must equal the caller’s user id (v1). |
device_id | string | yes | The pushing device. |
changes | Envelope[] | yes | 1–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:
| Field | Description |
|---|---|
status | applied, conflict, or rejected. |
new_usn | The assigned USN (present when applied). |
server_record | The authoritative record (present when conflict). |
deduped | true on a replayed change_id — the cached outcome is returned; nothing is re-applied. |
error | Reason 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
titleorcontentis not applied. The result comes backstatus: "conflict"with the server’s authoritative version inserver_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 likenotebook_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 liveresourcewas pushed before its bytes were uploaded. Presign-upload the bytes first (see the Files API); aresourcetombstone 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 anerror— not as an HTTP error. ocr_textis not a known push type — a change withtype: "ocr_text"fails batch validation.- Pushing a note tombstone also reclaims its orphaned attachment blobs —
the server tombstones the note’s
note_resourcesjunctions itself, and those tombstones flow back to every device via pull. - Push refreshes the device’s
last_seenandlast_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.
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | yes | The device. |
acked_usn | int | yes | Highest 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.
| Field | Type | Required | Description |
|---|---|---|---|
device_id | string | yes | Client UUID. |
name | string | no | Display name. |
platform | string | no | ios | 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
dataarray plusscope_max_usnandgc_floor— it is not the standard{data, paging}collection envelope (there is nopagingblock).
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).
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | The opaque provider device token (APNs hex). |
device_id | string | yes | Client-stable device UUID (the upsert key). |
platform | string | no | Only "apns" is supported (default apns). |
environment | string | no | "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 return404 not_foundand clients simply keep pollingscope_max_usn— no client change required. 422 validation_failed— missingtoken/device_id, unsupportedplatform, or badenvironment.- Tokens APNs reports as
410 Unregisteredare 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:
| Field | Type | Description |
|---|---|---|
id | string | UUID; stable across rotations — a rotation updates the same row. |
blob | string | The opaque HRBK1 keystore (KEK salt, Argon2id params, wrapped master key). The server never reads it. |
usn | int | Server-assigned; bumped on every write so the change propagates via sync/pull. |
deleted | bool | A live keystore is always false. |
updated_at | int | Epoch-ms of the last write. |
created_at | int | Epoch-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 andPUTa 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).
| Field | Type | Required | Description |
|---|---|---|---|
blob | string | yes | The 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
}