| 1 |
# SyncKit Cloud Sync |
| 2 |
|
| 3 |
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 Makenotwork account. |
| 4 |
|
| 5 |
## Concepts |
| 6 |
|
| 7 |
- **Sync App**: A registered application on Makenotwork. Each app has its own API key, data namespace, and device list. |
| 8 |
- **Device**: A named installation of your app (e.g., "Alice's MacBook"). Each device syncs independently. |
| 9 |
- **Changelog**: An append-only log of changes. Each entry records a table name, operation, row ID, timestamp, and encrypted data blob. |
| 10 |
- **Cursor**: An opaque position in the changelog. Pull from cursor to get only new changes. |
| 11 |
- **Blob**: A content-addressed encrypted file stored in S3. Referenced by SHA-256 hash. |
| 12 |
|
| 13 |
## Creating a Sync App |
| 14 |
|
| 15 |
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. |
| 16 |
|
| 17 |
## Authentication |
| 18 |
|
| 19 |
### Direct Authentication |
| 20 |
|
| 21 |
For desktop apps where the user enters their Makenotwork credentials: |
| 22 |
|
| 23 |
``` |
| 24 |
POST /api/sync/auth |
| 25 |
Content-Type: application/json |
| 26 |
|
| 27 |
{ |
| 28 |
"email": "user@example.com", |
| 29 |
"password": "their-password", |
| 30 |
"api_key": "your-app-api-key" |
| 31 |
} |
| 32 |
``` |
| 33 |
|
| 34 |
Response: |
| 35 |
|
| 36 |
```json |
| 37 |
{ |
| 38 |
"token": "eyJ...", |
| 39 |
"user_id": "550e8400-...", |
| 40 |
"app_id": "660f9500-..." |
| 41 |
} |
| 42 |
``` |
| 43 |
|
| 44 |
Direct auth does not work for accounts with 2FA enabled. [OAuth2 PKCE](./oauth.md) is recommended for all apps (browser-based login, no credential handling). The resulting access token works with all SyncKit endpoints. |
| 45 |
|
| 46 |
## Device Registration |
| 47 |
|
| 48 |
Register a device before pushing or pulling data: |
| 49 |
|
| 50 |
``` |
| 51 |
POST /api/sync/devices |
| 52 |
Authorization: Bearer <token> |
| 53 |
Content-Type: application/json |
| 54 |
|
| 55 |
{ |
| 56 |
"device_name": "Alice's MacBook", |
| 57 |
"platform": "macos" |
| 58 |
} |
| 59 |
``` |
| 60 |
|
| 61 |
Platform values: `macos`, `windows`, `linux`, `ios`, `android`, `web`. |
| 62 |
|
| 63 |
Response: |
| 64 |
|
| 65 |
```json |
| 66 |
{ |
| 67 |
"id": "770a0600-...", |
| 68 |
"device_name": "Alice's MacBook", |
| 69 |
"created_at": "2026-03-13T10:00:00Z" |
| 70 |
} |
| 71 |
``` |
| 72 |
|
| 73 |
If the same app + user + device_name combination already exists, the existing device is returned (upsert behavior). |
| 74 |
|
| 75 |
### Listing and Removing Devices |
| 76 |
|
| 77 |
``` |
| 78 |
GET /api/sync/devices |
| 79 |
Authorization: Bearer <token> |
| 80 |
``` |
| 81 |
|
| 82 |
``` |
| 83 |
DELETE /api/sync/devices/{device_id} |
| 84 |
Authorization: Bearer <token> |
| 85 |
``` |
| 86 |
|
| 87 |
## Push/Pull Sync |
| 88 |
|
| 89 |
### Pushing Changes |
| 90 |
|
| 91 |
Send local changes to the server: |
| 92 |
|
| 93 |
``` |
| 94 |
POST /api/sync/push |
| 95 |
Authorization: Bearer <token> |
| 96 |
Content-Type: application/json |
| 97 |
|
| 98 |
{ |
| 99 |
"device_id": "770a0600-...", |
| 100 |
"batch_id": "a1b2c3d4-...", |
| 101 |
"changes": [ |
| 102 |
{ |
| 103 |
"table": "tasks", |
| 104 |
"op": "insert", |
| 105 |
"row_id": "task-001", |
| 106 |
"timestamp": "2026-03-13T10:05:00Z", |
| 107 |
"data": "<encrypted-blob>" |
| 108 |
} |
| 109 |
] |
| 110 |
} |
| 111 |
``` |
| 112 |
|
| 113 |
Response: |
| 114 |
|
| 115 |
```json |
| 116 |
{ |
| 117 |
"cursor": "abc123..." |
| 118 |
} |
| 119 |
``` |
| 120 |
|
| 121 |
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. |
| 122 |
|
| 123 |
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. |
| 124 |
|
| 125 |
### Pulling Changes |
| 126 |
|
| 127 |
Fetch changes from other devices: |
| 128 |
|
| 129 |
``` |
| 130 |
POST /api/sync/pull |
| 131 |
Authorization: Bearer <token> |
| 132 |
Content-Type: application/json |
| 133 |
|
| 134 |
{ |
| 135 |
"device_id": "770a0600-...", |
| 136 |
"cursor": "abc123..." |
| 137 |
} |
| 138 |
``` |
| 139 |
|
| 140 |
Response: |
| 141 |
|
| 142 |
```json |
| 143 |
{ |
| 144 |
"changes": [ |
| 145 |
{ |
| 146 |
"table": "tasks", |
| 147 |
"op": "update", |
| 148 |
"row_id": "task-001", |
| 149 |
"timestamp": "2026-03-13T10:10:00Z", |
| 150 |
"data": "<encrypted-blob>" |
| 151 |
} |
| 152 |
], |
| 153 |
"cursor": "def456...", |
| 154 |
"has_more": false |
| 155 |
} |
| 156 |
``` |
| 157 |
|
| 158 |
Results are paginated. Keep pulling while `has_more` is `true`. |
| 159 |
|
| 160 |
### Checking Sync Status |
| 161 |
|
| 162 |
``` |
| 163 |
GET /api/sync/status |
| 164 |
Authorization: Bearer <token> |
| 165 |
``` |
| 166 |
|
| 167 |
Response: |
| 168 |
|
| 169 |
```json |
| 170 |
{ |
| 171 |
"total_changes": 1523, |
| 172 |
"latest_cursor": "ghi789..." |
| 173 |
} |
| 174 |
``` |
| 175 |
|
| 176 |
## End-to-End Encryption |
| 177 |
|
| 178 |
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. |
| 179 |
|
| 180 |
### Key Storage |
| 181 |
|
| 182 |
Store and retrieve the encrypted master key: |
| 183 |
|
| 184 |
``` |
| 185 |
PUT /api/sync/keys |
| 186 |
Authorization: Bearer <token> |
| 187 |
Content-Type: application/json |
| 188 |
|
| 189 |
{ |
| 190 |
"encrypted_key": "<base64-encoded-encrypted-master-key>" |
| 191 |
} |
| 192 |
``` |
| 193 |
|
| 194 |
``` |
| 195 |
GET /api/sync/keys |
| 196 |
Authorization: Bearer <token> |
| 197 |
``` |
| 198 |
|
| 199 |
Response: |
| 200 |
|
| 201 |
```json |
| 202 |
{ |
| 203 |
"encrypted_key": "<base64-encoded-encrypted-master-key>", |
| 204 |
"key_version": 1 |
| 205 |
} |
| 206 |
``` |
| 207 |
|
| 208 |
Maximum key size: 4KB. Returns 404 if no key has been stored yet. |
| 209 |
|
| 210 |
## Blob Storage |
| 211 |
|
| 212 |
Content-addressed blob storage in S3, deduplicated by hash. |
| 213 |
|
| 214 |
### Upload Flow |
| 215 |
|
| 216 |
1. Request a presigned upload URL: |
| 217 |
|
| 218 |
``` |
| 219 |
POST /api/sync/blobs/upload |
| 220 |
Authorization: Bearer <token> |
| 221 |
Content-Type: application/json |
| 222 |
|
| 223 |
{ |
| 224 |
"hash": "sha256-hex-string", |
| 225 |
"size_bytes": 1048576 |
| 226 |
} |
| 227 |
``` |
| 228 |
|
| 229 |
Response: |
| 230 |
|
| 231 |
```json |
| 232 |
{ |
| 233 |
"upload_url": "https://s3.example.com/...", |
| 234 |
"already_exists": false |
| 235 |
} |
| 236 |
``` |
| 237 |
|
| 238 |
If `already_exists` is `true`, the blob is already stored. Skip the upload. |
| 239 |
|
| 240 |
2. Upload the file directly to the presigned URL (PUT request to S3). |
| 241 |
|
| 242 |
3. Confirm the upload: |
| 243 |
|
| 244 |
``` |
| 245 |
POST /api/sync/blobs/confirm |
| 246 |
Authorization: Bearer <token> |
| 247 |
Content-Type: application/json |
| 248 |
|
| 249 |
{ |
| 250 |
"hash": "sha256-hex-string", |
| 251 |
"size_bytes": 1048576 |
| 252 |
} |
| 253 |
``` |
| 254 |
|
| 255 |
Blob size is capped per tier. The server rejects oversized uploads. |
| 256 |
|
| 257 |
### Downloading Blobs |
| 258 |
|
| 259 |
``` |
| 260 |
POST /api/sync/blobs/download |
| 261 |
Authorization: Bearer <token> |
| 262 |
Content-Type: application/json |
| 263 |
|
| 264 |
{ |
| 265 |
"hash": "sha256-hex-string" |
| 266 |
} |
| 267 |
``` |
| 268 |
|
| 269 |
Response: |
| 270 |
|
| 271 |
```json |
| 272 |
{ |
| 273 |
"download_url": "https://s3.example.com/..." |
| 274 |
} |
| 275 |
``` |
| 276 |
|
| 277 |
The download URL is a presigned S3 URL valid for a limited time. |
| 278 |
|
| 279 |
## Real-Time Notifications (SSE) |
| 280 |
|
| 281 |
Subscribe to Server-Sent Events instead of polling. The server pushes a notification when another device pushes changes. |
| 282 |
|
| 283 |
``` |
| 284 |
GET /api/sync/subscribe?app_id={app_id} |
| 285 |
Authorization: Bearer <token> |
| 286 |
``` |
| 287 |
|
| 288 |
This is a long-lived SSE connection. Events: |
| 289 |
|
| 290 |
|
| 291 |
|
| 292 |
| `changed` | `{}` | Another device pushed changes. Call pull to catch up. | |
| 293 |
|
| 294 |
Recommended pattern: |
| 295 |
|
| 296 |
1. Open SSE connection on app launch |
| 297 |
2. On `changed` event, call pull to fetch new changes |
| 298 |
3. Reconnect on connection drop (with exponential backoff) |
| 299 |
4. Fall back to periodic polling if SSE is unavailable |
| 300 |
|
| 301 |
## Key Rotation |
| 302 |
|
| 303 |
Multi-step key rotation (e.g., after a suspected compromise): |
| 304 |
|
| 305 |
1. **Begin rotation**: Store the new encrypted key alongside the old one: |
| 306 |
|
| 307 |
``` |
| 308 |
POST /api/sync/keys/rotate/begin |
| 309 |
Authorization: Bearer <token> |
| 310 |
Content-Type: application/json |
| 311 |
|
| 312 |
{ "new_encrypted_key": "<base64>" } |
| 313 |
``` |
| 314 |
|
| 315 |
2. **Fetch entries to re-encrypt**: Pull changelog entries encrypted with the old key: |
| 316 |
|
| 317 |
``` |
| 318 |
POST /api/sync/keys/rotate/entries |
| 319 |
Authorization: Bearer <token> |
| 320 |
Content-Type: application/json |
| 321 |
|
| 322 |
{ "cursor": "...", "limit": 100 } |
| 323 |
``` |
| 324 |
|
| 325 |
3. **Submit re-encrypted batch**: Upload entries re-encrypted with the new key: |
| 326 |
|
| 327 |
``` |
| 328 |
POST /api/sync/keys/rotate/batch |
| 329 |
Authorization: Bearer <token> |
| 330 |
Content-Type: application/json |
| 331 |
|
| 332 |
{ "entries": [...] } |
| 333 |
``` |
| 334 |
|
| 335 |
4. **Complete rotation**: Finalize once all entries are re-encrypted: |
| 336 |
|
| 337 |
``` |
| 338 |
POST /api/sync/keys/rotate/complete |
| 339 |
Authorization: Bearer <token> |
| 340 |
``` |
| 341 |
|
| 342 |
During rotation, clients may receive a mix of old-key and new-key entries. Be prepared to decrypt with both keys until rotation completes. |
| 343 |
|
| 344 |
## Membership Gating |
| 345 |
|
| 346 |
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. |
| 347 |
|
| 348 |
## Billing & Pricing |
| 349 |
|
| 350 |
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. |
| 351 |
|
| 352 |
One rate: **{{ synckit.storage_usd_per_gb_month | money }} per GB per month**. Ingress and egress are included (absorbed in the rate's margin). API requests are unmetered. Every invoice is floored at **{{ synckit.invoice_floor_usd | money }}/month**, the smallest charge that clears Stripe's per-transaction fee. |
| 353 |
|
| 354 |
Billing is configured from the app's dashboard panel, in one of two modes: |
| 355 |
|
| 356 |
- **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. |
| 357 |
- **Per-key**: set `key_cap` (max active SDK keys) and `gb_per_key` (allotment per key). Price = `key_cap × gb_per_key × $0.03`. A `claim_key` past the cap returns `402 key_limit_reached`; a key over its allotment returns `402 storage_limit_reached` with `dimension: "storage_per_key"`. |
| 358 |
|
| 359 |
Caps are bounded: each mode's priced total must be between {{ synckit.min_priced_gb }} GB and {{ synckit.max_priced_display }} ({{ synckit.max_priced_gb }} 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. |
| 360 |
|
| 361 |
First-party apps use a separate end-user subscription model and are not configured here. |
| 362 |
|
| 363 |
### Managing SDK keys |
| 364 |
|
| 365 |
`POST /api/sync/keys/claim`, `/release`, and `/list` are server-to-server calls. They authenticate with the app's **keys secret**, not its API key: |
| 366 |
|
| 367 |
``` |
| 368 |
POST /api/sync/keys/claim |
| 369 |
Content-Type: application/json |
| 370 |
|
| 371 |
{ |
| 372 |
"app_secret": "<keys secret>", |
| 373 |
"key": "<your SDK key>" |
| 374 |
} |
| 375 |
``` |
| 376 |
|
| 377 |
Generate the secret from the app's dashboard panel ("Keys Secret"). It is shown once. Regenerating it invalidates the previous one immediately. |
| 378 |
|
| 379 |
Keep it on a backend you control. The API key is a public client identifier: it ships inside your app binaries and can be read out of them, so it cannot be trusted to gate calls that spend your key cap. An app with no keys secret cannot call these three endpoints at all. |
| 380 |
|
| 381 |
## See Also |
| 382 |
|
| 383 |
- [OTA Updates](./ota.md): auto-update your app through SyncKit |
| 384 |
- [OAuth2 PKCE](./oauth.md): browser-based login for SyncKit apps |
| 385 |
|