> ## Documentation Index
> Fetch the complete documentation index at: https://docs.synheart.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross-device sync

> Pair a user's devices into an end-to-end encrypted sync space so their personal baselines follow them from phone to phone.

## Overview

Cross-device sync (SyniSync) lets one user's devices share their personal baselines. A user who replaces a phone, or uses two, keeps "their normal" instead of warming up from zero on each device.

Sync is **device-first**. Devices join a **sync space**: a private group of devices that share one encryption key. Every snapshot is encrypted on the device before upload, and the service stores and relays ciphertext between members of the space. No user account is required.

Only **baseline snapshots** sync: the [SRM](/synheart-core/srm.md) state behind "your normal". HSI windows, session summaries, raw wearable samples, and behavior events stay on the device that produced them.

> [!NOTE]
>
> Sync runs in the native runtime. The Flutter, Kotlin, and Swift SDKs call the same runtime functions, so the behavior described on this page is the same on every platform.

## How it works

**1. The first device creates a space**

The runtime generates a 256-bit **Space Root Key (SRK)** on the device and registers a new space. The create call returns a **recovery key**, which the user must keep.

**2. It issues a pairing code**

The first device asks for a short-lived, single-use **pairing token**, encrypts the SRK under a key derived from that token, and uploads the encrypted bundle.

**3. The second device joins**

The user enters the token on the second device. That device downloads the bundle, derives the same key from the token, decrypts the SRK, and becomes a member of the space.

**4. Devices push and pull**

Each sync cycle pushes new baseline snapshots and pulls the ones the other members pushed. The next cycle on a newly joined device pulls every baseline the other devices have pushed.

### Encryption

* Each baseline snapshot is encrypted with **XChaCha20-Poly1305** under its own key, derived from the SRK with HKDF-SHA256 and salted with the snapshot's ID.
* The envelope header (artifact ID, type, subject, session, time range, sequence number, schema) is bound into the cipher as associated data, so it cannot be swapped onto a different payload.
* On pull, the runtime checks a SHA-256 of the decrypted payload and rejects any snapshot whose sequence number goes backwards.
* At rest, the SRK is stored wrapped. On mobile the wrapping key lives in the platform secure store (Keychain or Android Keystore); on desktop it is derived from the HMAC signing secret.

The service sees ciphertext and the routing metadata it needs to relay snapshots: space and device IDs, device names, timestamps, and the envelope header fields listed above.

### Conflicts

A snapshot is never overwritten once a device has it: the runtime skips an incoming snapshot whose ID is already stored, and the sequence-number check drops replayed or older versions. The runtime reports those rejections as `conflicts_resolved` in the sync result (`conflictsResolved` in Swift).

## Prerequisites

Every sync operation needs all of the following:

1. **Sync enabled in config** with a platform origin (see [Enable sync](#enable-sync)).
2. **The sync feature activated:** `SynheartFeature.synsync` in Flutter, `.synsync` in Swift, `SynheartFeature.CLOUD` in Kotlin.
3. **`cloudUpload` consent** granted by the user. Sync shares the [consent](/synheart-core/consent-system.md) and [capability](/synheart-core/capability-system.md) tier of cloud upload.
4. **A registered device.** On mobile, sync requests are signed with the device key from [device registration](/setup/authenticate.md). Desktop hosts can sign with HMAC credentials instead (see [Runtime configuration](/guides/runtime-config.md)).

The native runtime does not check consent. Your app owns that gate, and `checkSyncReadiness` includes it for you.

Sync is not available on the Wear OS or watchOS device role.

## Enable sync

**Flutter**

```dart theme={null}
await Synheart.initialize(
  config: SynheartConfig(
    // ...app, subject, and collection settings
    sync: const SyncConfig(
      enabled: true,
      baseUrl: 'https://api.synheart.ai', // Empty uses SYNHEART_BASE_URL.
    ),
  ),
);

Synheart.activate(SynheartFeature.synsync);
```

**Kotlin**

```kotlin theme={null}
Synheart.initialize(
    context = applicationContext,
    config = SynheartConfig(
        // ...app, subject, and collection settings
        sync = SyncConfig(
            enabled = true,
            baseUrl = "https://api.synheart.ai", // Empty uses SYNHEART_BASE_URL.
        ),
    ),
)

Synheart.activate(SynheartFeature.CLOUD)
```

**Swift**

```swift theme={null}
let config = SynheartConfig(
    // ...app, subject, and collection settings
    sync: SyncConfig(enabled: true, baseUrl: "https://api.synheart.ai")
)
try await Synheart.initialize(config: config, userId: subjectId)

Synheart.activate(.synsync)
```

`baseUrl` is the platform origin, not a sync-specific path. In Swift, an empty `baseUrl` falls back to `cloudConfig.baseUrl`. Self-hosted deployments can point sync at its own host with `enterprise.sync_url` in [`synheart.json`](/guides/runtime-config#enterprise--self-hosted-overrides).

`setSyncEnabled(false)` turns sync off at runtime without leaving the space. Turning it back on restores the same membership.

## Check readiness

Call `checkSyncReadiness` before showing a sync action. It combines the SDK gates (activation, consent, capability) with the native snapshot and tells you the first prerequisite that is missing for that specific operation.

```dart theme={null}
final readiness = await Synheart.checkSyncReadiness(
  operation: SyncOperation.generatePairing,
);

if (!readiness.isReady) {
  switch (readiness.code) {
    case SyncReadinessCode.cloudConsentRequired:
      // Ask for cloudUpload consent.
    case SyncReadinessCode.activeSpaceRequired:
      // Offer "Create a space" or "Join a space".
    default:
      // Show readiness.code; readiness.nativeState has the runtime's view.
  }
}
```

| Code | Meaning |
| - | - |
| `ready` | The operation can run |
| `featureNotActivated` | Activate the sync feature |
| `cloudConsentRequired` | The user has not granted `cloudUpload` |
| `capabilityNotAllowed` | The app's capability policy or device role does not allow sync |
| `nativeRuntimeUnavailable` | The linked runtime cannot report sync readiness |
| `configurationMissing` | Sync is disabled or has no base URL |
| `storageUnavailable` | Local storage is not open |
| `deviceRevoked` | Another member revoked this device |
| `deviceRegistrationRequired` | Register the device before syncing |
| `activeSpaceRequired` | This device is not in a space |
| `srkUnavailable` | The device is in a space but cannot unlock its key; recover the space |

Create, join, and recover do not need an existing space. Only `generatePairing` and `syncNow` need the space key. `clearLocalSpace` stays available even after consent is withdrawn or the device is revoked, so users always have a way to start over.

Kotlin uses the same names in upper snake case (`SyncOperation.GENERATE_PAIRING`, `SyncReadinessCode.ACTIVE_SPACE_REQUIRED`).

## Create a space

```dart theme={null}
final space = await Synheart.syncCreateSpace(deviceName: 'Pixel 9');
final spaceId = space?['sync_space_id'] as String?;
final recoveryKey = space?['recovery_key'] as String?;
```

> [!WARNING]
>
> Show the recovery key to the user **once** and ask them to store it somewhere safe, such as a password manager. Together with the space ID, it is the only way back into the space if every device is lost, and it cannot be retrieved later. Keep it out of logs, analytics, and crash reports.

Store the space ID where the user can find it later. Recovery needs both values.

## Pair another device

On the device that is already in the space:

```dart theme={null}
final pairing = await Synheart.syncGeneratePairing();
final token = pairing?['token'] as String?;
final expiresIn = pairing?['expires_in'] as int?; // seconds
```

Show the token to the user and count down from `expires_in`. Tokens are single-use and short-lived, and the service sets the lifetime, so rely on `expires_in` rather than a fixed duration. If a token expires, generate a new one.

On the new device:

```dart theme={null}
final joined = await Synheart.syncJoinSpace(
  pairingToken: tokenEnteredByUser,
  deviceName: 'iPad',
);
// {sync_space_id, status}

await Synheart.syncNow(); // Pull everything the space already has.
```

## Run a sync cycle

```dart theme={null}
final result = await Synheart.syncNow();
print('pushed ${result.pushed}, pulled ${result.pulled}');
```

The SDKs do not schedule sync cycles for you. Call `syncNow()` when it suits your app, for example when the app comes to the foreground or after a session ends, since that is when baselines update. A cycle pushes in batches of 50 and pulls in pages of 100 until the space has nothing new.

`syncNow()` does network I/O. Flutter and Swift run it off the UI thread for you. In Kotlin every sync call is blocking, so call it from a background dispatcher:

```kotlin theme={null}
val result = withContext(Dispatchers.IO) { Synheart.syncNow() }
```

## Manage devices

```dart theme={null}
final roster = await Synheart.syncListDevices();
for (final device in (roster?['devices'] as List? ?? [])) {
  print('${device['device_name']} primary=${device['is_primary']} '
      'last seen ${device['last_seen_at']} revoked=${device['revoked']}');
}

await Synheart.syncRevokeDevice(deviceId: lostDeviceId);
```

Use `syncListDevices()` for a "Synced with N devices" line. Each entry has `device_id`, `device_name`, `is_primary`, `trusted_at`, `last_seen_at`, and `revoked`.

Revoking a device removes it from the space on the server, so it can no longer push or pull. It does not rotate the space key, and data the device already pulled stays on that device.

## Leave, delete, or start over

| Call | Effect on this device | Effect on the server |
| - | - | - |
| `syncLeaveSpace()` | Forgets the space and its key | Removes this device; the space continues for the others |
| `syncDeleteSpace()` | Forgets the space and its key | Deletes the space for every device. Owner only. |
| `syncClearLocalSpace()` | Forgets the space and its key | Nothing; the device is still listed as a member |
| `syncRevokeDevice(deviceId:)` | Nothing | Removes another device from the space |

Confirm with the user before `syncDeleteSpace()`. It cannot be undone, and the recovery key stops working.

`syncClearLocalSpace()` is the local escape hatch: it works without network and without consent, for a device that is stuck in a space it can no longer reach. To remove the device from the server as well, revoke it from another member.

## Recover a space

When every device is lost, a new device can rejoin with the recovery key and the space ID:

```dart theme={null}
final recovered = await Synheart.syncRecoverSpace(
  recoveryKey: keyEnteredByUser,
  spaceId: spaceIdEnteredByUser,
);
// {sync_space_id, owner_user_id, status}

await Synheart.syncNow();
```

Use the same call when readiness reports `srkUnavailable`: the device is still a member but has lost its local copy of the key, for example after the app's secure storage was cleared.

## Errors

In Flutter and Kotlin, a failed sync call throws `SyncNativeException`. In Swift, the space and device calls do not throw: a failed call returns the error fields in the result dictionary, with the message under `"error"`. All three expose the same fields: `code`, `message` (safe to show), `retryable`, `retryAfterMs`, `owner`, and `recovery`.

```dart theme={null}
try {
  await Synheart.syncJoinSpace(pairingToken: token);
} on SyncNativeException catch (error) {
  switch (error.recoveryKind) {
    case NativeErrorRecovery.generateNewPairingCode:
      // Ask the user for a fresh code from the other device.
    case NativeErrorRecovery.rejoinSpace:
      // Re-read readiness, then offer join or create.
    default:
      if (error.retryable) {
        // Retry after error.retryAfterMs.
      }
  }
}
```

| Code | Typical cause | What to do |
| - | - | - |
| `PAIRING_EXPIRED` | The token timed out | Generate a new token on the other device |
| `PAIRING_INVALID` | The token was mistyped or already used | Generate a new token |
| `RECOVERY_KEY_INVALID` | The recovery key does not match the space | Ask the user to re-enter it |
| `SPACE_NOT_FOUND` | Wrong space ID, or the space was deleted | Check the ID, or create a new space |
| `SYNC_MEMBERSHIP_REVOKED` | Another device removed this one, or it left | Re-read readiness, then join again |
| `SYNC_SPACE_DELETED` | The owner deleted the space | Create or join another space |
| `DEVICE_REGISTRATION_REQUIRED` | The device is not registered | Register the device, then retry |
| `CONFIGURATION_MISSING` | Sync is disabled or has no base URL | Fix the configuration |
| `NETWORK` / `SERVER` | Connectivity or service error | Retry if `retryable` |

Do not re-register the device in response to a membership error. Membership codes mean the device is registered but no longer in the space; re-read readiness and offer to join or create a space instead.

## Platform notes

| | Flutter | Kotlin | Swift |
| - | - | - | - |
| Feature to activate | `SynheartFeature.synsync` | `SynheartFeature.CLOUD` | `.synsync` |
| Threading | Futures on a background isolate, one operation at a time | Blocking; call off the main thread | `async` |
| Space and device results | `Map<String, dynamic>?` | `JSONObject?` | `[String: Any]?` |
| Failures | Throws `SyncNativeException` | Throws `SyncNativeException` | `"error"` key in the result |
| `null` result | SDK not initialized | SDK not initialized, or the runtime lacks the call | SDK not initialized |

Method names are the same in all three SDKs: `syncCreateSpace`, `syncGeneratePairing`, `syncJoinSpace`, `syncRecoverSpace`, `syncListDevices`, `syncRevokeDevice`, `syncLeaveSpace`, `syncDeleteSpace`, `syncClearLocalSpace`, `syncNow`, `setSyncEnabled`, and `checkSyncReadiness`. Hosts that call the runtime directly can use the [sync FFI functions](/synheart-core/runtime-ffi.md#9-sync).

## Without the cloud

If a user cannot or will not use sync, `baselineExportOffline(passphrase:)` and `baselineImportOffline` move [baselines](/synheart-core/baselines.md) between devices as a passphrase-encrypted file instead. Nothing passes through the service.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.