Offline Sync Built
The offline delta-sync API is how the Flutter Sales-Executive (SE) field app works without a live connection: a push endpoint queues locally-created records (orders, order lines, payments) back to the server, and a pull endpoint downloads incremental changes (customers, products, orders, payments…) into the app's local cache. This page is for developers integrating against /api/v1, not for console users.
Who this is for
An SE works a territory with unreliable connectivity. The field app keeps a local database of everything it needs to take an order or record a payment offline, then reconciles with the server when a connection is available. Two endpoints make that possible:
| Endpoint | Direction | Purpose |
|---|---|---|
POST /api/v1/sync | App → server | Push a batch of locally-created mutations (sale orders, order lines, payment vouchers) and get back per-item results, idempotent by client_uuid. |
GET /api/v1/sync/pull | Server → app | Cursor-driven delta download — everything the app's local cache needs to hydrate, then keep current: customers, addresses, products, product categories, sale orders, payment vouchers. |
Both endpoints sit inside the standard auth:sanctum + password.changed route group — a first-login token restricted to the password.change ability cannot reach either one.
Identity & territory bootstrap
Before the sync loop starts, the app makes two calls to establish who the user is and what territory they're allowed to operate in: GET /api/v1/me (enriched with role and territory data) and the new GET /api/v1/regions/tree.
GET /me — territory fields
In addition to the existing user.permissions, GET /me now returns three fields the field app uses to bootstrap identity and territory in one call:
| Field | Shape | Notes |
|---|---|---|
user.roles | {id, name, display_name}[] | The user's active, non-expired role assignments only — an expired or not-yet-active assignment is excluded. |
user.geography_scopes | {id, user_id, region_id, scope_type, assigned_at, region}[] | The SE's assigned territory rows, with region: {id, name, region_type} embedded so the app doesn't need a follow-up lookup to display them. |
user.effective_region_ids | int[] | Flat list of the scoped region ids plus all of their descendants (via RegionService::effectiveRegionIdsFor) — the operational allow-list to filter order capture and customer visibility by. Empty array when the user has no geography scopes. |
{
"user": {
"id": 42,
"name": "Field SE",
"permissions": [ "sale_order.create", "..." ],
"roles": [
{ "id": 3, "name": "sales_executive", "display_name": "Sales Executive" }
],
"geography_scopes": [
{
"id": 7, "user_id": 42, "region_id": 101, "scope_type": "owns", "assigned_at": "2026-06-01T00:00:00Z",
"region": { "id": 101, "name": "Pune", "region_type": "district" }
}
],
"effective_region_ids": [101, 205, 206, 207]
}
}
GET /regions/tree
Returns the region hierarchy as a nested tree rather than the flat, paginated GET /regions index — useful for territory pickers and displaying an SE's assigned regions in context. Gated region.view. The response is returned whole, not paginated, and — like the flat /regions index — is not geo-scoped: any holder of region.view gets the full tree, not just their own territory.
| Param | Required | Notes |
|---|---|---|
zone_id | No | Integer. Restricts the tree to regions belonging to one zone. |
root_id | No | Integer. Returns only the subtree rooted at that region (the region itself plus its descendants), instead of the whole forest. |
include_inactive | No | Accepts true, false, 1, or 0. Default is false — inactive regions are excluded unless explicitly requested. |
GET /api/v1/regions/tree?zone_id=<id>&root_id=<id>&include_inactive=false
Each node nests its children recursively under children:
{
"data": [
{
"id": 5, "name": "Maharashtra", "short_name": "MH", "report_name": "Maharashtra",
"region_type": "state", "parent_id": null, "zone_id": 1, "depth": 0,
"is_active": true, "is_selectable": false, "sort_order_1": 1,
"children": [
{
"id": 101, "name": "Pune", "short_name": "PUN", "report_name": "Pune",
"region_type": "district", "parent_id": 5, "zone_id": 1, "depth": 1,
"is_active": true, "is_selectable": true, "sort_order_1": 1,
"children": []
}
]
}
]
}
Push — POST /api/v1/sync
Send a batch of up to 100 operations in one call:
{
"operations": [
{ "resource": "sale_order", "client_uuid": "<uuid>", "data": { ... } },
{ "resource": "sale_order_line", "client_uuid": "<uuid>", "data": { ... } },
{ "resource": "payment_voucher", "client_uuid": "<uuid>", "data": { ... } }
]
}
The route itself requires sale_order.create (the baseline permission to use sync at all); individual operation types layer their own permission and scope checks on top, described below.
Supported resources
| Resource | Notes |
|---|---|
sale_order | Creates a draft sale order for the operation's client_uuid. |
sale_order_line | Adds a line to a sale order created earlier in the same batch (resolved via the batch's own client_uuid map) or to an existing order. |
payment_voucher | Creates a draft payment voucher. Requires payment.create — checked per-operation, so a user without it gets an error item on that op, not a batch-wide 403. The paying customer must be within the actor's visibility scope (same rule as POST /payment-vouchers and the sale-order scope check); an out-of-scope customer degrades to a validation error item rather than failing the whole batch. |
Idempotency & per-item results
Every operation carries a client-generated client_uuid. Replaying the same operation (e.g. after a dropped response) is safe — the server recognizes the client_uuid it already processed and returns replayed instead of creating a duplicate row. A malformed or rejected operation degrades to an error item; it never sinks the rest of the batch.
| Status | Meaning |
|---|---|
created | New row inserted for this client_uuid. |
replayed | A row for this exact client_uuid already existed (the operation was already applied); no duplicate is created. |
error | Validation failure, permission/scope failure, or a data error on this item only. Comes with errors describing what failed. |
{
"data": {
"results": [
{ "client_uuid": "<uuid>", "resource": "sale_order", "status": "created", "id": 501 },
{ "client_uuid": "<uuid>", "resource": "sale_order_line", "status": "replayed", "id": 9001, "sale_order_id": 501 },
{ "client_uuid": "<uuid>", "resource": "payment_voucher", "status": "error", "errors": { "customer_id": ["You can only create orders for customers within your scope."] } }
]
}
}
client_uuid from a different user is not the same recordReplay is scoped to the row that owns that client_uuid. If another actor's request happens to collide (which shouldn't occur in practice with app-generated UUIDs), the mismatch surfaces as a not-found error rather than silently returning someone else's data.
Pull — GET /api/v1/sync/pull
Downloads everything the app's local cache needs, incrementally. On the very first call, omit cursor to get a full snapshot; on every later call, pass back the cursor the previous response returned to get only what changed since.
GET /api/v1/sync/pull?cursor=<opaque>&limit=<n>
| Param | Required | Notes |
|---|---|---|
cursor | No | Opaque base64 token returned by the previous call. Omitted (or empty) on the first call → full snapshot from zero. A cursor that fails to decode returns HTTP 422 — the endpoint never silently falls back to a full re-hydrate on a corrupt token, since that would mask a client bug. |
limit | No | Rows per resource per call. Default 100, clamped to a maximum of 500. |
Unlike the push endpoint, GET /sync/pull carries no permission: gate — the same convention as GET /me. Access control is entirely per-resource: each resource's query is individually scoped (via visibleTo where applicable), so an endpoint-level permission would be both redundant and wrong — an SE legitimately needs the product master, for example, without holding any "product master" permission.
Response envelope
{
"data": {
"customers": { "upserts": [ /* CustomerResource */ ], "tombstones": [ { "id": 12, "deleted_at": "2026-07-17T09:00:00Z" } ] },
"addresses": { "upserts": [ /* AddressResource */ ], "tombstones": [] },
"products": { "upserts": [ /* ProductResource (+prices) */ ],"tombstones": [] },
"product_categories": { "upserts": [ /* ProductCategoryResource */ ], "tombstones": [] },
"sale_orders": { "upserts": [ /* SaleOrderResource (+lines) */ ],"tombstones": [] },
"payment_vouchers": { "upserts": [ /* PaymentVoucherResource */ ], "tombstones": [] }
},
"cursor": "<opaque base64 — the new high-water mark map>",
"has_more": true
}
upserts are serialized with the same API Resources the single-record GET endpoints already use (CustomerResource, AddressResource, ProductResource, ProductCategoryResource, SaleOrderResource, PaymentVoucherResource) — the pull payload matches what the Flutter app already codes against for single fetches. tombstones are minimal {id, deleted_at} pairs the client uses to delete the row locally.
has_more is true if any resource returned a full page (i.e. there may be more rows waiting for that resource). Keep following the returned cursor while has_more is true; once it's false, every resource is caught up.
The six pull resources
| Resource | Scope | Notes |
|---|---|---|
customers | Customer::visibleTo($user) — the SE's territory | Embeds the customer's user profile and contacts (phone/email). |
addresses | Owning customer is visibleTo($user) | Carries user_id so the client can link an address back to its customer. Only customer-owned addresses are pulled (addresses are polymorphic; other owners are out of scope for v1). |
products | Global master (no scoping) | Embeds the product's full current prices array and its category. |
product_categories | Global master (no scoping) | |
sale_orders | SaleOrder::visibleTo($user) | Embeds the order's full current lines array. |
payment_vouchers | Created by the user OR the voucher's customer is visibleTo($user) |
Embedded children
products.prices and sale_orders.lines are not independent top-level resources with their own tombstones — they're hard-deleted, not soft-deleted, and ride inside their parent instead. Whenever a price changes, the owning product's updated_at is touched, so the product re-appears in upserts carrying its full current price set; whenever an order's lines change, the order is touched the same way and re-appears with its full current line set. The client always replaces a parent's embedded children wholesale on each re-appearance — a line or price removed from the list has simply been deleted, with no separate tombstone to process.
Tombstones
A soft-deleted row (its deleted_at gets set) surfaces in that resource's tombstones array as {id, deleted_at} instead of in upserts — soft-delete bumps updated_at too, so it's picked up by the same delta window. The client should remove the matching local row when it sees a tombstone.
The client loop
- Initial hydrate
Call
GET /sync/pullwith nocursor. Store the returnedcursor, apply theupserts/tombstonesfor every resource, and repeat the call with the storedcursorwhilehas_moreis true. - Steady-state polling
Once
has_moreis false, the local cache is caught up. Poll periodically (app-defined interval) with the last storedcursor; each response only contains what changed. - Periodic full re-hydrate
Occasionally reset the stored cursor (drop it) and repeat step 1. This is the mitigation for both documented v1 limitations below — a full re-hydrate always converges the local cache to exactly what the server currently returns.
Limitations (v1)
These edge cases are documented and deliberately deferred rather than solved with additional machinery. All are self-healing via the periodic full re-hydrate above.
If a customer (or their orders) is reassigned out of the SE's region, visibleTo simply stops returning that row — there is no delete signal for "this is no longer yours," only for soft-deletes. The app's local copy goes stale until the client resets its cursor and does a full re-hydrate, at which point anything no longer returned is dropped.
The delta window keys on (updated_at, id) with second-precision timestamps. A row with a lower id that gets updated in the exact same wall-clock second as the row that produced the current cursor's high-water mark can be skipped by that page and only picked up on a later change (or the next full re-hydrate). The window is narrow — it needs concurrent same-second writes with a poll landing in between — and always resolves itself on the next full re-hydrate.
CustomerService::update now bumps customers.updated_at on any edit, including a profile-only change. Edits made through other paths — customer merge, bulk import, the advocate-profile controller — may still update the underlying profiles/users row without touching the customers row, so the change may not re-surface until the client's periodic full re-hydrate.
Related pages
See Payment Vouchers for the payment capture workflow the offline payment_voucher push mirrors, and Products and Customers & Categories for the console-side views of the masters this endpoint pulls.