let client = SyncKitClient::new(SyncKitConfig { server_url: "https://makenot.work".into(), api_key: "your-api-key".into(), }); let (user_id, app_id) = client.authenticate("user@example.com", "pass").await?; client.setup_encryption_new("pass").await?; // generates + wraps master key let device = client.register_device("MacBook", "macos").await?; let cursor = client.push(device.id, changes).await?; // encrypted, retried, done
Second device? Call setup_encryption_existing(password). The SDK downloads the encrypted envelope from the server, derives the wrapping key from the password, unwraps the master key, and caches it in the OS keychain. One password prompt, then it's automatic.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/sync/auth |
Public | Authenticate with MNW credentials + API key. Returns JWT (7-day expiry). |
POST |
/api/sync/push |
JWT | Push up to 500 encrypted changelog entries. Returns new cursor. |
POST |
/api/sync/pull |
JWT | Pull changes since cursor. Returns entries + new cursor + has_more flag. |
GET |
/api/sync/status |
JWT | Total change count and latest cursor for this user/app. |
POST |
/api/sync/devices |
JWT | Register or update a device. Upsert by (app, user, name). |
GET |
/api/sync/devices |
JWT | List all devices for the authenticated user. |
PUT |
/api/sync/keys |
JWT | Store encrypted master key envelope (max 4 KB). |
GET |
/api/sync/keys |
JWT | Retrieve encrypted key envelope + version number. |
POST |
/api/sync/blobs/upload |
JWT | Request presigned S3 upload URL. Returns already_exists flag. |
POST |
/api/sync/blobs/confirm |
JWT | Confirm blob upload. Idempotent. |
POST |
/api/sync/blobs/download |
JWT | Request presigned S3 download URL by content hash. |
SyncKitClient — thread-safe (Send + Sync), share via Arc. All methods take &self. Internal parking_lot::RwLock for session and master key. Single reqwest::Client with connection pooling.
ChangeEntry { table, op, row_id, timestamp, data } — your app defines table names and row IDs. ChangeOp: Insert, Update, Delete. Data is Option<serde_json::Value>, automatically encrypted before push.
// Pull remote changes (auto-decrypted) let (changes, new_cursor, has_more) = client.pull(device.id, cursor).await?; for change in &changes { match change.op { ChangeOp::Insert => db.upsert(&change.table, &change.row_id, &change.data), ChangeOp::Update => db.upsert(&change.table, &change.row_id, &change.data), ChangeOp::Delete => db.delete(&change.table, &change.row_id), } } // Push local changes (auto-encrypted) let local_changes = db.pending_changes_since(last_push); let cursor = client.push(device.id, local_changes).await?;
// Upload: hash locally, request URL, upload encrypted, confirm let resp = client.blob_upload_url(&hash, size).await?; if !resp.already_exists { client.blob_upload(&resp.upload_url, data).await?; // encrypted on wire } client.blob_confirm(&hash, size).await?; // Download: request URL, download + decrypt let url = client.blob_download_url(&hash).await?; let plaintext = client.blob_download(&url).await?;
Blobs are content-addressed by SHA-256 hash. If already_exists returns true, the file is already on the server — skip the upload. Binary encryption uses raw bytes (no base64 wrapping), so overhead is exactly 40 bytes per file regardless of size.
// Try keychain first (no password prompt needed) if client.try_load_key_from_keychain().is_ok() { // Master key cached from previous session — ready to sync } else if client.has_server_key().await? { // Existing account on new device — ask for password once client.setup_encryption_existing(&password).await?; } else { // First device ever — generate master key, wrap with password client.setup_encryption_new(&password).await?; } // OAuth2 PKCE also supported (browser-based auth) let url = client.build_authorize_url(port, state, challenge); // ... redirect user, receive code ... let (user_id, app_id) = client.authenticate_with_code(&code, &verifier, port).await?;
| Capability | SyncKit | Firebase | Supabase | iCloud | Custom |
|---|---|---|---|---|---|
| E2E encrypted | Yes | — | — | Partial | You build it |
| Zero-knowledge server | Yes | — | — | — | You build it |
| Schema-agnostic | Yes | doc-store schema (Firestore enforces document model) | Postgres | CloudKit | You build it |
| Blob storage | S3 | Yes | Yes | Yes | You build it |
| Rust SDK | Yes | — | — | — | You build it |
| Cross-platform | Yes | Mobile focus | Yes | Apple only | You build it |
| No vendor lock-in | Yes | — | Yes | — | Yes |
| Source-available | Yes | — | Yes | — | Yours |
| No per-user pricing | Yes | Pay per use | Pay per use | Free (Apple) | Your infra |
| Time to integrate | Hours | Hours | Hours | Days | Weeks |
SyncKit is a B2B service: customers are app developers, not end users. Pricing is usage-based on storage and transfer — no per-user fees, no per-seat licenses. Your end users authenticate with their own MNW accounts at no cost to you.
Monthly cost = (GB stored × $0.15) + (burst multiplier × GB stored × $0.03)
Burst is the transfer budget relative to storage. Simple mode uses a default burst of 5×; Builder mode lets you choose any multiplier. Same billing engine for both.
No per-user fees. No per-request fees. No egress charges. Your end users authenticate with their own MNW accounts. Pricing scales with what you actually store and transfer — not with how many users or devices sync through your app. No overages: the bill is known before the billing period starts; at limits, sync degrades but the bill does not change. Source-available under PolyForm Noncommercial 1.0.0.