SyncKit Cloud Sync
SyncKit provides cloud sync and encrypted data storage for desktop applications: device registration, changelog-based sync, E2E encryption, and content-addressed blob storage, all through a REST API backed by your Makenot.work account.
For the Rust client SDK, see the API reference.
Concepts
- Sync App: A registered application on Makenot.work. Each app has its own API key, data namespace, and device list.
- Device: A named installation of your app (e.g., “Alice’s MacBook”). Each device syncs independently.
- Changelog: An append-only log of changes. Each entry records a table name, operation, row ID, timestamp, and encrypted data blob.
- Cursor: An opaque position in the changelog. Pull from cursor to get only new changes.
- Blob: A content-addressed encrypted file stored in S3. Referenced by SHA-256 hash.
Creating a Sync App
From the dashboard, go to Settings and create a new SyncKit app. You receive an API key (shown only once; regenerating it invalidates existing clients). Optionally link the app to a project or item.
Authentication
Direct Authentication
For desktop apps where the user enters their Makenot.work credentials:
POST /api/sync/auth
Content-Type: application/json
{
"email": "user@example.com",
"password": "their-password",
"api_key": "your-app-api-key"
}
Response:
{
"token": "eyJ...",
"user_id": "550e8400-...",
"app_id": "660f9500-..."
}
Direct auth does not work for accounts with 2FA enabled. OAuth2 PKCE is recommended for all apps (browser-based login, no credential handling). The resulting access token works with all SyncKit endpoints.
Device Registration
Register a device before pushing or pulling data:
POST /api/sync/devices
Authorization: Bearer <token>
Content-Type: application/json
{
"device_name": "Alice's MacBook",
"platform": "macos"
}
Platform values: macos, windows, linux, ios, android, web.
Response:
{
"id": "770a0600-...",
"device_name": "Alice's MacBook",
"created_at": "2026-03-13T10:00:00Z"
}
If the same app + user + device_name combination already exists, the existing device is returned (upsert behavior).
Listing and Removing Devices
GET /api/sync/devices
Authorization: Bearer <token>
DELETE /api/sync/devices/{device_id}
Authorization: Bearer <token>
Push/Pull Sync
Pushing Changes
Send local changes to the server:
POST /api/sync/push
Authorization: Bearer <token>
Content-Type: application/json
{
"device_id": "770a0600-...",
"batch_id": "a1b2c3d4-...",
"changes": [
{
"table": "tasks",
"op": "insert",
"row_id": "task-001",
"timestamp": "2026-03-13T10:05:00Z",
"data": "<encrypted-blob>"
}
]
}
Response:
{
"cursor": "abc123..."
}
The batch_id ensures idempotent pushes. If the same ID is submitted twice, the server returns the existing cursor without re-inserting. Generate a unique batch_id per push and retry with the same ID on network failure.
Changes per push are capped at 500 (the server returns an error if exceeded). Table names are limited to 100 characters (alphanumeric and underscores only). Row IDs are limited to 255 characters. The server validates device ownership.
Pulling Changes
Fetch changes from other devices:
POST /api/sync/pull
Authorization: Bearer <token>
Content-Type: application/json
{
"device_id": "770a0600-...",
"cursor": "abc123..."
}
Response:
{
"changes": [
{
"table": "tasks",
"op": "update",
"row_id": "task-001",
"timestamp": "2026-03-13T10:10:00Z",
"data": "<encrypted-blob>"
}
],
"cursor": "def456...",
"has_more": false
}
Results are paginated. Keep pulling while has_more is true.
Checking Sync Status
GET /api/sync/status
Authorization: Bearer <token>
Response:
{
"total_changes": 1523,
"latest_cursor": "ghi789..."
}
End-to-End Encryption
The server stores only encrypted blobs in the data field; it never sees plaintext user data. The client SDK uses ChaCha20-Poly1305 for encryption and Argon2 for key derivation. The encrypted master key envelope is stored server-side so users can set up new devices without re-entering a passphrase.
Key Storage
Store and retrieve the encrypted master key:
PUT /api/sync/keys
Authorization: Bearer <token>
Content-Type: application/json
{
"encrypted_key": "<base64-encoded-encrypted-master-key>"
}
GET /api/sync/keys
Authorization: Bearer <token>
Response:
{
"encrypted_key": "<base64-encoded-encrypted-master-key>",
"key_version": 1
}
Maximum key size: 4KB. Returns 404 if no key has been stored yet.
Blob Storage
Content-addressed blob storage in S3, deduplicated by hash.
Upload Flow
- Request a presigned upload URL:
POST /api/sync/blobs/upload
Authorization: Bearer <token>
Content-Type: application/json
{
"hash": "sha256-hex-string",
"size_bytes": 1048576
}
Response:
{
"upload_url": "https://s3.example.com/...",
"already_exists": false
}
If already_exists is true, the blob is already stored. Skip the upload.
-
Upload the file directly to the presigned URL (PUT request to S3).
-
Confirm the upload:
POST /api/sync/blobs/confirm
Authorization: Bearer <token>
Content-Type: application/json
{
"hash": "sha256-hex-string",
"size_bytes": 1048576
}
Blob size is capped per tier. The server rejects oversized uploads.
Downloading Blobs
POST /api/sync/blobs/download
Authorization: Bearer <token>
Content-Type: application/json
{
"hash": "sha256-hex-string"
}
Response:
{
"download_url": "https://s3.example.com/..."
}
The download URL is a presigned S3 URL valid for a limited time.
Real-Time Notifications (SSE)
Subscribe to Server-Sent Events instead of polling. The server pushes a notification when another device pushes changes.
GET /api/sync/subscribe?app_id={app_id}
Authorization: Bearer <token>
This is a long-lived SSE connection. Events:
| Event | Data | Meaning |
|---|---|---|
changed | {} | Another device pushed changes. Call pull to catch up. |
Recommended pattern:
- Open SSE connection on app launch
- On
changedevent, call pull to fetch new changes - Reconnect on connection drop (with exponential backoff)
- Fall back to periodic polling if SSE is unavailable
Key Rotation
Multi-step key rotation (e.g., after a suspected compromise):
- Begin rotation: Store the new encrypted key alongside the old one:
POST /api/sync/keys/rotate/begin
Authorization: Bearer <token>
Content-Type: application/json
{ "new_encrypted_key": "<base64>" }
- Fetch entries to re-encrypt: Pull changelog entries encrypted with the old key:
POST /api/sync/keys/rotate/entries
Authorization: Bearer <token>
Content-Type: application/json
{ "cursor": "...", "limit": 100 }
- Submit re-encrypted batch: Upload entries re-encrypted with the new key:
POST /api/sync/keys/rotate/batch
Authorization: Bearer <token>
Content-Type: application/json
{ "entries": [...] }
- Complete rotation: Finalize once all entries are re-encrypted:
POST /api/sync/keys/rotate/complete
Authorization: Bearer <token>
During rotation, clients may receive a mix of old-key and new-key entries. Be prepared to decrypt with both keys until rotation completes.
Membership Gating
Configured per app. Apps linked to a free item (or no item) have unrestricted sync access. Apps linked to a paid item or membership tier require an active purchase or membership. If the membership lapses, push/pull return 403. Device registration and key storage remain accessible.
Billing & Pricing
SyncKit bills the developer, not your end users. You buy a storage budget for the whole app and allocate it however you like; how you charge your own users (flat, freemium, usage-based, nothing) is your concern.
One rate: $0.03 per GB per month. Ingress and egress are included (absorbed in the rate’s margin). API requests are unmetered. Every invoice is floored at $0.31/month, the smallest charge that clears Stripe’s per-transaction fee.
Billing is configured from the app’s dashboard panel, in one of two modes:
- Bulk: set one knob,
storage_gb_cap(total GB). Price =storage_gb_cap × $0.03. At the cap, uploads are refused (402,reason: "storage_limit_reached"); existing data still reads. - Per-key: set
key_cap(max active SDK keys) andgb_per_key(allotment per key). Price =key_cap × gb_per_key × $0.03. Aclaim_keypast the cap returns402 key_limit_reached; a key over its allotment returns402 storage_limit_reachedwithdimension: "storage_per_key".
Caps are bounded: each mode’s priced total must be between 1 GB and 10 TB (10240 GB). The bill is fixed for the period. There are no overages. Increasing a cap takes effect immediately; decreasing it applies at the next billing cycle. Cancellation takes effect at period end, and a standard export is available at any time.
First-party apps use a separate end-user subscription model and are not configured here.
See Also
- OTA Updates: auto-update your app through SyncKit
- OAuth2 PKCE: browser-based login for SyncKit apps
- SyncKit Client SDK: Rust client library documentation