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 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.
| 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.
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_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. 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. |
error | Reason code (present when rejected). |
message | Human-readable explanation. Present only on a plan_limit_reached rejection. |
details | String 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/:namedoes. Reviving that id later brings nothing back. - The only way to write metadata is REST:
metadataonPOST/PATCH, or the…/metadataroutes.
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
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, 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 itschange_id, so re-pushing the same change after an upgrade gets the same answer. Push it again under a freshchange_idonce 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’sidisn’t the envelope’sid. Harbor’s own clients never send this; fix the ids and push under a freshchange_id.- 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.
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 default | Result |
|---|---|
| a tombstone | cannot_delete_default — always. |
a record with "is_default": false | cannot_unset_default, unless another change in the batch promotes a different notebook. |
a record that leaves is_default out | Same as false — the missing field clears it. |
"record": null | cannot_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:
| Batch | Result |
|---|---|
| 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_defaultunchanged, 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.
| 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
}