# App data sync and bundled calls — mobile app contract

For **new app versions**: the launch and home screen in one request that answers only what
changed (`POST app/data`), the checkout page in one request (`POST checkout/overview`), and a
few new keys on existing endpoints that make other calls unnecessary. **Nothing changed for old
app versions**: every old endpoint keeps its URL and its answer; existing answers only gained new
keys (listed in §3), which an old app does not read.

Every part of these answers **is an old endpoint's answer, byte for byte** (the server runs that
endpoint inside the request). Parse each part with the parser the app already has for that
endpoint. Server side: `App\Services\AppData\` (`AppDataSync`, `CheckoutOverview`,
`LegacyEndpoint`).

Headers as on every call: `System-Key`, `App-Language`, `Authorization: Bearer <token>` when signed
in, and `app: new`. Base: `/api/v2`. Bodies are JSON.

**An older server** does not know these routes: it answers HTTP 200 with
`{"data": [], "success": false, "status": 404, "message": "Invalid Route"}`. Whenever an answer is
not `success: true` (or the call fails), use the old calls as today.

## 1. Launch and home — `POST app/data`

### Request

```json
{
  "groups": ["business_settings", "currencies", "languages", "app_version", "cart_count", "sliders", "featured_products"],
  "temp_user_id": "c0a8f1e2-...",
  "business_settings_last_updated": "2026-10-22T09:14:03Z~3f2a9c1b7e44",
  "sliders_last_updated": "2026-10-21T18:02:40Z~9b1d04c2aa17",
  "cart_count_last_updated": "2026-10-22T08:55:12Z~5e0f3b2c91d8"
}
```

| field | meaning |
|---|---|
| `groups` | the groups to answer, in the order you want them built (most important first). A list, or one comma-separated string. Absent = every group. Unknown names are ignored. |
| `<group>_last_updated` | the stamp you got for that group last time — **only if you still have that group's `data` stored**. |
| `temp_user_id` | the guest cart id, as you send it to the cart calls (also used by `business_settings`). |

### Answer

```json
{
  "success": true,
  "status": 200,
  "data": {
    "business_settings": null,
    "currencies": { "last_updated": "2026-10-20T07:41:09Z~c3e85d10b2f4", "data": { "data": [ ... ], "success": true, "status": 200 } },
    "app_version": { "last_updated": "2026-10-01T12:00:41Z~a81c3f07d2e9", "data": { "success": true, "android": { ... }, "ios": { ... } } },
    "cart_count": { "last_updated": "2026-10-22T09:20:05Z~77b2e91c04fa", "data": { "count": 3, "status": true } }
  },
  "_deferred": ["featured_products"]
}
```

Each requested group comes back in one of three ways:

| value | meaning | what the app does |
|---|---|---|
| `null` | **unchanged**: the content is exactly (byte for byte) the one your stamp names | use the `data` you stored |
| `{"last_updated": s, "data": d}` | new or changed content. `d` = the old endpoint's answer, `s` = its stamp (never empty) | store `d` and `s` together |
| **key missing** | not answered: unknown group, a signed-in group for a guest, the old endpoint failed, or the time ran out (then the group is also listed in `_deferred`) | call the group's old endpoint as today |

A failure is **never** answered `null`.

### Stamps

**Every group answered carries a stamp, never empty**: `<UTC time>~<hash>`, e.g.
`2026-10-22T09:14:03Z~3f2a9c1b7e44`. Store it as received and send it back unchanged; never
parse it, compare it as a date or use the phone's clock.

- The **hash** is of the content, so `null` is always safe: after a language switch, a product
  saved, a price window, or another account signing in on the phone, the bytes differ and you get
  the new `data`.
- The **time** is when that content was first served (for a catalogue group) or when the
  account's / guest cart's own content last changed (`cart_count`, `unread_notifications`,
  `order_rating_pending`, `profile_counters`, `addresses`). It is there to read, not to compare.
- If the server's cache was emptied, the times restart: every group comes back once with a new
  stamp, then `null` again. Nothing to do.

`app_version` and `event_logo` carry a stamp too but **always** come with their data (never
`null`): the force-update check must never depend on what the app kept.

Send a stamp **only** together with the stored data it belongs to. If you lose the stored data
(cache cleared, new install), send no stamp for that group. If an older server ever sends an
empty stamp, store the data without one.

### Groups

Every group is page 1 of its old endpoint with its default parameters (no search, no filter).
The next pages, searches and filters still use the old endpoints.

| group | old call it replaces | signed in only |
|---|---|---|
| `business_settings` | `GET business-settings?temp_user_id=` | |
| `currencies` | `GET currencies` | |
| `languages` | `GET languages` | |
| `app_version` | `GET app/version` (always with data) | |
| `event_logo` | `GET event-logo` (always with data) | |
| `splash_screen` | `GET splash-screen` | the default and the scheduled splash screens (`docs/api/splash-screen.md`) |
| `cart_count` | `POST cart-count` (the token's account, else `temp_user_id`) | |
| `unread_notifications` | `GET unread-notifications` | yes |
| `order_rating_pending` | `GET order-ratings/pending` | yes |
| `profile_counters` | `GET profile/counters` | yes |
| `sliders` | `GET sliders` | |
| `banners_one` | `GET banners-one` | |
| `banners_two` | `GET banners-two` | |
| `flash_deal_banners` | `GET flash-deals-banners` | |
| `featured_categories` | `GET categories/featured` | |
| `top_categories` | `GET categories/top` | |
| `shops` | `GET shops?page=1` | |
| `brands` | `GET brands?page=1` | |
| `featured_products` | `GET products/featured?page=1` | |
| `todays_deal_products` | `GET products/todays-deal?page=1` | |
| `best_selling_products` | `GET products/best-seller?page=1` | |
| `all_products` | `GET products/search?page=1` | |
| `flash_deals` | `GET flash-deals` | |
| `home_category_products` | `GET categories/home/products` | |
| `custom_home_sections` | `GET home-sections` | every home section (name in `App-Language`, type, products, links) and the homepage `layout` (`docs/api/custom-home-sections.md`) |
| `addresses` | `GET user/shipping/address` | yes |

The server builds the groups in the order asked. After about 1.5 s it stops starting new ones
and lists the rest in `_deferred`: call their old endpoints (in parallel). The catalogue groups
come from the same server cache as the old endpoints, so this happens only after a cache was
emptied.

### Suggested use

- **App launch**: `business_settings`, `currencies`, `languages`, `app_version`, `event_logo`, `splash_screen`,
  `cart_count`, plus `order_rating_pending` and `unread_notifications` when signed in.
- **Home screen**: the home groups, plus `addresses` when signed in. One request may hold the
  launch and the home groups together.
- Refresh (pull to refresh, back from background): the same request with the stamps. Unchanged
  groups cost a few bytes each.

Measured on a copy of the live data (warm caches): a guest's 20 launch + home calls (97 KB) are one
call of 98 KB, and **1 KB** when the stamps are sent back; a customer's 24 calls (154 KB) are one
call, and 1.1 KB with the stamps.

How to store the groups and the images on the phone, and every edge case (cache cleared, a group
missing, a lost stamp): `mobile-performance-guide.md`.

## 2. Checkout page — `POST checkout/overview`

One request instead of the checkout page's seven. The server runs the page's old calls **in the
page's order**, each with the body you send, and answers each call's answer as it is:

| part | old call | |
|---|---|---|
| `carts` | `POST carts` | read before the writes below (its per-row `shipping_cost` is the one stored before this request) |
| `addresses` | `GET user/shipping/address` | signed in only |
| `update_address_in_cart` | `POST update-address-in-cart` | write: the address on the cart rows |
| `delivery_info` | `POST delivery-info` | |
| `shipping_cost` | `POST shipping_cost` | write: the shipping priced and stored per row |
| `payment_types` | `GET payment-types?mode=&list=` | |
| `cart_summary` | `POST cart-summary` | the totals after the writes |

### Request

Everything the page sends to those calls, in one body:

```json
{
  "temp_user_id": null,
  "address_id": 412,
  "seller_list": [{ "seller_id": 10, "shipping_type": "home_delivery", "shipping_id": 0 }],
  "mode": "order",
  "list": "both"
}
```

- The cart is the **token's account's** (a `user_id` in the body is ignored), else the guest's
  `temp_user_id`.
- A guest sends their area and address fields as to the old calls (`country_id`, `city_id`, ...).
- `mode` / `list` go to `payment-types` only (default `order` / `both`).

### Answer

```json
{
  "success": true,
  "status": 200,
  "data": {
    "carts": { "grand_total": "...", "data": [ ... ] },
    "addresses": { "data": [ ... ], "success": true, "status": 200 },
    "update_address_in_cart": { "result": true, "data": { "address_id": 412 }, "message": "..." },
    "delivery_info": [ ... ],
    "shipping_cost": { "result": true, "value": 12500, "value_string": "..." },
    "payment_types": [ ... ],
    "cart_summary": { "sub_total": "...", "grand_total": "...", "...": "..." }
  }
}
```

A part whose call did not answer 200 JSON is **missing**, and the later parts still ran (as the old
calls did one after the other): call that part's old endpoint. A `result: false` inside a part
(e.g. "Please add shipping address") is that call's own answer: handle it as today.

Call it when the page opens and when the address changes. A change of shipping type alone can
still use `shipping_cost` + `cart-summary`.

Measured on a copy of the live data: the customer's 7 calls are one call with the same bytes.

## 3. New keys on existing endpoints

### Cart list — `POST carts`

Each item of `data[].cart_items[]` gains the product page's four discount fields, with the same
values `GET products/{slug}/{user_id}` gives for that product:

```json
{ "id": 9123, "product_slug": "...", "...": "unchanged",
  "has_discount": true, "discount": "-10%", "stroked_price": "...", "main_price": "..." }
```

→ the cart no longer needs a `products/{slug}/{user_id}` call per item.

### Cart changes — `cart_count`

`POST carts/add` (when `result` is `true`), `POST carts/process` (when `result` is `true`) and
`DELETE carts/{id}` gain `cart_count`: what `cart-count` would answer right after the change.

```json
{ "result": true, "message": "...", "cart_count": 4 }
```

→ no `cart-count` call after a change. `cart_count` is missing when the answer is a refusal
(`result: false`) or the row was already gone: keep the count you have or call `cart-count`.

### Product page — `GET products/{slug}/{user_id}?with=cart`

With `with=cart` (and `&temp_user_id=` for a guest) the answer gains, next to `data`:

```json
{
  "data": [ { "...": "unchanged" } ],
  "success": true,
  "status": 200,
  "cart_count": 4,
  "in_cart_quantity": 2,
  "in_cart": [ { "cart_id": 9123, "variant": "Black-XL", "quantity": 2 } ]
}
```

| key | meaning |
|---|---|
| `cart_count` | what `cart-count` answers |
| `in_cart_quantity` | this product's pieces in the cart, all variants together |
| `in_cart` | this product's cart rows: `cart_id` (for `carts/change-quantity` / `DELETE carts/{id}`), `variant` (as in `products/variant/price`), `quantity` |

Only the caller's own cart: the token's account, else `temp_user_id`. Computed only when
`with=cart` is sent → the product page no longer needs `POST carts`.
