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
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.
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