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 push, record.id must match the envelope id (or be left out).

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.

Push the whole record, every time. The server writes every column from the record you send, so a field you leave out is cleared, not kept. “I only changed the name” isn’t something a partial record can say. There are two exceptions, workspace and metadata — see a partial record clears what it omits.

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.

Note, notebook and stack records carry metadata, the integration labels described in the Metadata API. It’s always present, {} when empty. A metadata change gives the record a new usn, so it arrives on pull like any other change. Store it; you never need to send it back.

Errors:

  • 422 validation_failed — scope_id missing, after_usn < 0, or invalid JSON.
  • 403 scope_forbidden — scope_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. A replayed rejection comes back with the same error, message and details as the first time — except a rejection recorded before the server started storing codes, which replays with no error.
errorReason code (present when rejected).
messageHuman-readable explanation. Present only on a plan_limit_reached rejection.
detailsString map, present only on a plan_limit_reached rejection — the same details a REST 403 carries, including gate and, for a read-only account, reason: account_read_only.

A partial record clears what it omits

This is the costliest mistake a client can make on push, and it never returns an error. The server writes the record whole. A field your record leaves out is stored as empty ("", [], false, 0), gets a fresh usn, and then pulls down over the good copy on every other device.

The server can’t tell “the user cleared this” from “this client has never heard of this field”, because the two requests are identical. So it doesn’t guess.

The classic case is a new field. Templates gained notebook_id and tag_ids; a client built before that which pushes a template rename sends neither, and the template comes back unfiled and untagged everywhere. Nothing is rejected.

What a client must do: read, modify and write the complete record. Start from the full record you pulled, change only what the user changed, and send every field back, including ones your client never shows. If you can’t round-trip fields you don’t model, make that edit through the REST PATCH endpoint instead, which only touches the fields you send.

Two fields are exceptions.

workspace is kept when it’s absent

On notebook, task and saved_search records, a record without a workspace key keeps the stored value. To clear it, send "workspace": "" explicitly. Always send the key; don’t rely on this for your own writes.

metadata is never written by push

On note, notebook and stack records, push never changes metadata. The server ignores the key — missing, different, or not even an object — keeps the stored value, and applies the rest of the record as normal. So a client that doesn’t know about metadata can’t wipe it.

  • A pushed create starts with {}.
  • A pushed stack tombstone discards the stack’s metadata, exactly as DELETE /stacks/:name does. Reviving that id later brings nothing back.
  • The only way to write metadata is REST: metadata on POST/PATCH, or the …/metadata routes.

A metadata write moves a note’s usn like any server-side change. A body edit pushed against the older usn then follows the normal conflict rule.

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, or a notebook update that moves the default on a read-only account (see the default notebook). Other edits still apply on a read-only account. Deletes/tombstones are never blocked (so a frozen account can get back under its cap), and pull is never blocked. A rejection is remembered against its change_id, so re-pushing the same change after an upgrade gets the same answer. Push it again under a fresh change_id once the account has room.
  • cannot_delete_default / cannot_unset_default — a change would leave the account with no default notebook. See the default notebook.
  • record_id_mismatch — the record’s id isn’t the envelope’s id. Harbor’s own clients never send this; fix the ids and push under a fresh change_id.
  • 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.

The default notebook

An account always has exactly one default notebook — see the Notebooks API. Push won’t break that, so a few notebook changes are rejected. Each rejection is per-change: it writes nothing, spends no USN, and the rest of the batch still applies. The HTTP status is still 200.

You push, for the notebook that’s currently the defaultResult
a tombstonecannot_delete_default — always.
a record with "is_default": falsecannot_unset_default, unless another change in the batch promotes a different notebook.
a record that leaves is_default outSame as false — the missing field clears it.
"record": nullcannot_unset_default.

Always send is_default on a notebook record. Leaving it out is the easy way to hit this by accident.

To move the default, promote the new one. A record with "is_default": true makes that notebook the default and demotes the old one for you. You never need to send the demotion. If you do send both, order matters for deletes:

BatchResult
promote B, demote A✅ Works. The best order — by the time the demotion arrives, A isn’t the default.
demote A, promote B✅ Works. The demotion’s other fields (a rename, say) apply, but the flag stays on A until B’s promotion moves it.
promote B, delete A✅ Works.
delete A, promote B❌ The delete is refused cannot_delete_default; the promotion still lands. Push the promotion first.

If the promotion in a pair is itself refused — say the account is read-only — the default simply stays where it was. The account never ends up with none.

On a read-only account, the default can’t move. A notebook record with "is_default": true for a notebook that isn’t already the default is refused plan_limit_reached with details.reason: account_read_only — the same answer PATCH /notebooks/:id and PUT /settings give. A few things still go through:

  • a rename or restack that leaves is_default unchanged, on any notebook;
  • re-pushing the current default with "is_default": true;
  • promoting a notebook on an account that somehow has no default at all.

A single record that renames and moves the default is refused whole — the rename doesn’t land either. Push the rename again with is_default unchanged.

The default can’t encrypt by default. REST refuses that pair with default_notebook_cannot_encrypt; push doesn’t. A notebook record that ends up both is_default and default_encrypt is applied with default_encrypt switched off, and the corrected record comes back to every device on its next pull.

When a default move is refused, put your local default back to the one the server has. Don’t park the refused change and re-send it later under a new change_id — it would move the default days after the user asked.

When you pull, clear your local default before you apply an incoming "is_default": true. Pull sends each row’s current state, not a change log, so the promotion can arrive before the demotion — or on a different page.

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.