---
title: Sync Protocol
description: Register devices, push idempotent offline sales, pull visibility-filtered deltas, and bootstrap with snapshots.
---

You keep offline registers in sync through the endpoints in `apps/server/src/features/sync/route.ts`, all mounted under the `/api/v1` prefix in `apps/server/src/app.ts`. Visibility rules live in `apps/server/src/core/common/visibility.ts` and writers emit changes with `emitChange` from `apps/server/src/features/sync/protocol/emitter.ts`. Every endpoint requires authentication.

### Registering a device

You register once per device with `POST /sync/devices/register`. Registration is idempotent: sending a known `device_id` returns the existing row instead of failing, and you get 200 for a retry or 201 for a new device. Non-admin callers are pinned to their own branch, while admins may pass a `branch_id` to register for another branch.

```bash
curl -X POST http://localhost:8000/sync/devices/register \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_id":"register-01","label":"Counter tablet"}'
```

You get back `device_id`, `branch_id`, and `label`. The server also emits a `devices` sync change so other branches learn the device exists.

### Pushing offline sales

You replay queued mutations with `POST /sync/push`. The `admin`, `branch_manager`, `pharmacist`, and `cashier` roles pass the route guard in `apps/server/src/features/sync/route.ts`, and the device must belong to your branch unless you are the admin, with a revoked device refused outright by `requireDeviceBranchAccess` in `apps/server/src/features/sync/protocol/branch-scope.ts`. Version one supports `sales` as `UPSERT`, plus `customers`, `suppliers`, and `medicines` as `UPSERT` or `DELETE`; anything else lands in the `rejected` array with code `UNSUPPORTED_ENTITY`. Each change needs a non-empty `client_mutation_id` or it is rejected with `VALIDATION`, and each `sales` payload must carry immutable `tax_policy` metadata parsed by `parseQueuedSaleTaxPolicy` in `apps/server/src/features/settings/tax.ts`.

```bash
curl -X POST http://localhost:8000/sync/push \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "register-01",
    "changes": [{
      "client_mutation_id": "offline-0001",
      "entity": "sales",
      "op": "UPSERT",
      "payload": {
        "items": [{"medicine_id":"MED-ID","quantity":1,"unit_price":20}],
        "payment_method": "cash",
        "tax_policy": {
          "enabled": true,
          "standard_rate": 0.15,
          "medicine_categories": {"MED-ID": "standard"}
        }
      }
    }]
  }'
```

Dedupe is by `branch_id` plus `client_mutation_id` on the `sales` table, backed by the `UNIQUE (branch_id, client_mutation_id)` constraint in `packages/database/schema.sql`. When you push the same mutation twice, the server skips the `createSale` core in `apps/server/src/features/sales/creation/service.ts` (re-exported from `apps/server/src/features/sales/service.ts`) and returns the original `id`, `icv`, and `invoice_number` in `accepted`. Stock failures come back as `INSUFFICIENT_STOCK`.

Rejections are classified by `deriveRejectionCode` in `apps/server/src/features/sync/protocol/rejection.ts`. A 403 becomes `BRANCH_MISMATCH`, a 409 becomes `CONFLICT`, other client errors become `VALIDATION`, and a server fault becomes `SERVER_ERROR`. The terminal codes a till stops retrying are `VALIDATION`, `UNSUPPORTED_ENTITY`, `INSUFFICIENT_STOCK`, and `BRANCH_MISMATCH`; `CONFLICT` and `SERVER_ERROR` stay retryable.

### Settling conflicts

A mirrored `UPSERT` that arrived with a `base_rev` older than the server revision is refused instead of clobbered, which is where the `CONFLICT` code comes from. The race is recorded and an admin settles it with `GET /sync/conflicts` and `POST /sync/conflicts/:id/resolve` using the `last_write_wins`, `client`, `server`, or `merge` strategy, as listed in `apps/server/src/features/sync/protocol/conflict.ts`.

### Pulling deltas

You poll `GET /sync/pull` with `device_id`, `since_rev`, and `limit`. The limit defaults to 500 and caps at 1000, and both parameters must be non-negative integers with the limit at least 1, which `apps/server/src/features/sync/route.ts` enforces. The device must exist, and a caller whose device belongs to another branch, or whose device is revoked, gets a 403 from `requireDeviceBranchAccess` in `apps/server/src/features/sync/protocol/branch-scope.ts`. The server filters rows by the device branch: you see all `global` rows, your own branch rows, and `directed` rows where your branch is origin or target.

```bash
curl "http://localhost:8000/sync/pull?device_id=register-01&since_rev=0&limit=500" \
  -H "Authorization: Bearer $TOKEN"
```

You get `changes` in ascending `rev` order plus `max_rev`. The server upserts `sync_state` for your branch and device pair, so your next call passes `since_rev` equal to the last `max_rev` you stored.

### Understanding visibility

You classify every entity in `apps/server/src/core/common/visibility.ts`, and an entity the file has not classified is refused at emit time. Branches, devices, medicines, suppliers, coupons, insurance providers, patient insurance policies, customers, customer ledgers, and users are `global`. Sales, sale items, returns with their items, stock movements, batches, cash sessions, session breaks, tender declarations, parked transactions, prescriptions with their items, coupon usage, the controlled dispense log, purchase orders with their items, credit notes, and the audit log are `branch`. Transfers are `directed` with an explicit `to_branch_id`.

### Bootstrapping with a snapshot

You bootstrap a fresh device with `GET /sync/snapshot`. It returns the current `max_rev` plus full rows for active branches, active medicines, active suppliers, active coupons, all customers, active users, your branch batches, and the categories. An admin snapshot covers every branch and every user, while a branch caller only receives its own branch and its own users, as scoped in `apps/server/src/features/sync/queries.ts`. User rows never include password hashes. Pair the snapshot with `since_rev` set to `max_rev` on your first pull so you only fetch what changed after bootstrap.

```bash
curl http://localhost:8000/sync/snapshot \
  -H "Authorization: Bearer $TOKEN"
```

### Next steps

Follow the device lifecycle hands-on in /guides/offline-first-flow, learn the branch model in /platform/branches, and see the sales replay internals in /sales/selling-at-the-counter.
