# GrowKit API reference

**Flutter team:** start with **[app-contract.md](app-contract.md)** (request/response contract). This file is the full endpoint reference.

JSON API for the GrowKit Flutter app. Routes are **flat** (no `/api/v1` prefix).

**Base URLs**

| Environment | URL |
|-------------|-----|
| Local (Docker) | `http://localhost:3001` |
| Production (Render) | `https://farm-backend-kiei.onrender.com` |

Public HTML (open in the app WebView or browser): `/privacy-policy`, `/terms-of-service`, `/about-growkit`.

**Defaults**

- `Content-Type: application/json`
- Responses are JSON unless noted.
- Many request bodies accept **camelCase** or **snake_case** keys.

**Authentication**

- After OTP verify or profile save, use: `Authorization: Bearer <jwt>`
- JWT lifetime: **7 days**
- Optional response header: `X-Access-Token` (same token) on `POST /verify-otp` and `POST /users/profile`
- Missing/invalid token: `401` with `{ "success": false, "message": "Unauthorized" }` or `"Permission denied"`

**Common error shape**

```json
{ "success": false, "message": "Human-readable message" }
```

**Listing prices:** `priceInr` is included only if the viewer is **Premium** or the **listing owner** (otherwise the key is omitted).

**Profile updates:** `PATCH /users/me` returns a **User object at the root**. `POST /users/profile` returns `{ "user": { ... } }`.

---

## Health

### `GET /` · `GET /health` · `GET /status`

No auth.

**200**

```json
{
  "status": "ok",
  "service": "farm_backend"
}
```

---

## Auth (OTP)

### `POST /request-otp`

No auth. Creates user on first request for a valid 10-digit Indian mobile number.

**Body**

```json
{ "phone": "9123456789" }
```

**200** — user summary (+ OTP only outside production)

```json
{
  "id": 1,
  "phone": 9123456789,
  "signedUp": false,
  "internal": true,
  "otp": "123456"
}
```

| Field | Notes |
|-------|--------|
| `otp` | Present when `OTP_INCLUDE_IN_RESPONSE=true` (interim) or non-production. Omitted when SMS-only production. |
| `internal` | **3-series** — use **`totpSetup`** + Google Authenticator (see [totp.md](totp.md)). |
| `totpSetup` / `totpRequired` | Present when TOTP login applies. |

**Errors**

| Status | `message` |
|--------|-----------|
| 400 | Invalid phone number |
| 400 | Account suspended |

---

### `POST /verify-otp`

No auth.

**Body**

```json
{ "phone": "9123456789", "otp": "123456" }
```

**200**

```json
{
  "token": "<jwt>",
  "user": {
    "id": 1,
    "phone": 9123456789,
    "name": "Ravi",
    "signedUp": true,
    "disableScreenshot": false
  }
}
```

`user` uses the [User object](#user-object). Header `X-Access-Token: <jwt>` may also be set.

**Errors**

| Status | `message` |
|--------|-----------|
| 404 | Invalid phone number |
| 400 | Account suspended |
| 422 | OTP is required |
| 422 | Invalid OTP |

---

## Users

Auth required except where noted.

### `POST /users/profile`

Complete signup (name + role). Returns refreshed JWT in header `X-Access-Token`.

**Body**

```json
{
  "name": "Ravi",
  "role": "farmer",
  "photo_url": "https://example.com/photo.jpg"
}
```

`role`: `farmer` (default), `agent`, `merchant`

**200**

```json
{ "user": { } }
```

**422** — validation / profile errors (`success: false`, `message`)

---

### `GET /users/me`

**200** — [User object](#user-object) at the root. Wrapped `{ "user": { ... } }` is also accepted by the app.

```json
{
  "id": 1,
  "phone": 9123456789,
  "name": "Ravi",
  "premium": false,
  "disableScreenshot": false
}
```

---

### `PATCH /users/me`

**Body** (partial)

```json
{ "name": "Ravi Kumar", "language": "te", "photo_url": "https://..." }
```

`language`: `en`, `hi`, `te`, `ta`

**200** — [User object](#user-object)

---

### `POST /users/blocks`

Block another user.

**Body**

```json
{ "userId": "2" }
```

Also accepts `user_id`, `sellerUserId`.

**201**

```json
{ "success": true }
```

---

## User locations

Auth required.

### `GET /user-locations`

**200** — array of [Location object](#location-object), active first.

---

### `POST /user-locations`

Creates a location and sets it **active** (deactivates others).

**Body**

```json
{
  "village": "Nandyal",
  "latitude": 15.47,
  "longitude": 78.48
}
```

**201** — [Location object](#location-object)

**422** — validation errors

---

## Photos

### `POST /photos`

Auth required. **App uploads:** multipart only (bytes stored in DB — survives redeploy). JSON `{ "url" }` is for seed/admin external links.

**Multipart** — field `file` or `image` (JPEG/PNG/WebP, max 10 MB).

**201**

```json
{ "id": 42, "url": "https://farm-backend-kiei.onrender.com/photos/42/file", "uploaded": true }
```

Set **`APP_HOST`** on Render so `url` is correct.

### `GET /photos/:id/file`

No auth. Serves uploaded bytes (used as `Photo.url`).

---

## Dashboard

### `GET /dashboard`

Auth **optional**. With token, excludes blocked sellers and includes buyer interests.

**200**

```json
{
  "promos": [],
  "mallForYou": [ ],
  "interests": [ ]
}
```

| Field | Type |
|-------|------|
| `promos` | Always `[]` for now |
| `mallForYou` | Up to 6 [Listing objects](#listing-object) |
| `interests` | Up to 5 [Interest objects](#interest-object) if logged in; else `[]` |

---

## Home boot

### `GET /get-initial-data`

Auth required. Single call for home open. App stays on the loading shimmer until this returns. Claim still uses `POST /feed/free_subscription/claim`.

**200**

```json
{
  "premium": false,
  "disableScreenshot": true,
  "unreadNotificationCount": 2,
  "feed": { "items": [ ] },
  "mallForYou": [ ]
}
```

| Field | Type |
|-------|------|
| `premium` | Same as `GET /subscriptions/me` |
| `disableScreenshot` | Same as User JSON |
| `unreadNotificationCount` | Same number as `GET /notifications/:user_id/count` → `unreadCount` |
| `feed.items` | Same array as `GET /feed` (welcome gift when pending) |
| `mallForYou` | Up to 6 listings, same as `GET /dashboard` |

**401** — missing / invalid Bearer token

---

## In-app feed (welcome gift)

Auth required.

Welcome gift: **3 months** of Premium (`plan` stays `mall_premium` for the app). Pending offers appear until claimed, expired, or the user is already premium. Claiming creates a `user_plan` only (no Razorpay mandate).

### `GET /feed`

**200**

```json
{
  "items": [
    {
      "id": "free_subscription",
      "type": "free_subscription",
      "status": "pending",
      "plan": "mall_premium",
      "durationMonths": 3,
      "ctaLabel": "Grab"
    }
  ]
}
```

Empty when there is nothing to show: `{ "items": [] }`.  
**401** — missing / invalid Bearer token

---

### `POST /feed/:id/claim`

Claim path uses the item `id` (`free_subscription`).

**200**

```json
{
  "success": true,
  "item": {
    "id": "free_subscription",
    "type": "free_subscription",
    "status": "claimed",
    "plan": "mall_premium",
    "durationMonths": 3,
    "ctaLabel": "Grab"
  },
  "premium": true,
  "userPlan": { }
}
```

**404** — unknown offer  
**422** — already claimed, expired, or user already has premium

---

## Farm Doctor

Auth required.

All active crop doctors from the **`doctors` table** (admin). Coordinates are optional and only used to fill `distanceKm` / sort nearest first. The API does not invent a mock list. **200** with `items: []` when the table is empty. Do not 404 the list (the app fail-opens to a mock list on 404).

`name` and `specialty` are stored strings (Telugu can be sent as-is). Call uses the `phone` number (`tel:` on device). Field visit is the consultation endpoint.

### `GET /doctors`

**Query** (optional): `latitude`, `longitude`. Used only for `distanceKm` and nearest-first sort. If omitted, the user’s active saved location is used when present.

**200**

```json
{
  "helpline": {
    "phone": "+918688259303",
    "hours": "10:00–17:00",
    "days": "Mon–Fri"
  },
  "items": [
    {
      "id": "dr_ravi",
      "name": "Dr. Ravi Kumar",
      "specialty": "Paddy & cotton",
      "clinic": "Guntur",
      "distanceKm": 2.3,
      "rating": 4.8,
      "reviewCount": 186,
      "experienceYears": 12,
      "phone": "+919876543210",
      "availableToday": true
    }
  ]
}
```

All active doctors are returned. When coords are present, items are sorted nearest first.

**401** — missing / invalid Bearer token

---

### `POST /doctors/:id/consultations`

`:id` is the doctor slug (`dr_ravi`).

**Body**

```json
{ "type": "visit" }
```

**200** — `{ "status": "requested" }`  
**404** — unknown doctor

---

## Plans (Premium catalog)

No auth.

### `GET /plans`

**200**

```json
{
  "plans": [
    {
      "id": "1",
      "code": "monthly",
      "name": "Monthly",
      "durationMonths": 1,
      "priceInr": 99,
      "discountPercent": 0,
      "active": true
    },
    {
      "id": "2",
      "code": "quarterly",
      "name": "Quarterly",
      "durationMonths": 3,
      "priceInr": 249,
      "discountPercent": 16,
      "active": true
    },
    {
      "id": "3",
      "code": "yearly",
      "name": "Yearly",
      "durationMonths": 12,
      "priceInr": 799,
      "discountPercent": 33,
      "active": true
    }
  ]
}
```

---

## Subscriptions (Premium)

Auth required.

Premium is an active `user_plan` whose `endsAt` is in the future. Cancelling the mandate does **not** remove access before that date.

### `GET /subscriptions/me`

**200**

```json
{
  "premium": true,
  "userPlan": { },
  "subscription": { }
}
```

`userPlan` is the current entitlement (or `null`). `subscription` is the current mandate (or `null`). See [User plan object](#user-plan-object) and [Mandate object](#mandate-object).

---

### `POST /subscriptions`

Mock purchase / activate premium for a catalog plan. Creates a `user_plan` and a mock mandate.

**Body** (optional)

```json
{
  "plan": "monthly",
  "source": "mock",
  "razorpaySubscriptionId": "sub_123"
}
```

`plan`: `monthly` (default), `quarterly`, or `yearly`.

**201** — [Purchase object](#purchase-object) (new)  
**200** — existing current plan if already premium  
**422** — unknown plan

---

## SSP paywall (Premium UI copy)

### `GET /ssp-paywall`

No auth.

**Query** (optional): `product` — defaults to `monthly`

**200**

```json
{
  "product_id": "monthly",
  "productId": "monthly",
  "title": "GrowKit Premium",
  "subtitle": "Unlock prices, call sellers, and sell your animals faster.",
  "price_inr": 99,
  "priceInr": 99,
  "currency": "INR",
  "cta_label": "Unlock for ₹99",
  "ctaLabel": "Unlock for ₹99",
  "plans": [],
  "benefits": [
    { "icon": "trending_up", "text": "..." }
  ]
}
```

`plans` is the same array as `GET /plans`. `price_inr` / `product_id` follow the selected `product` (or monthly).

---

## Device tokens (FCM)

Auth required. Register the app’s FCM token so inbox rows can send a push. iOS also needs an APNs key in the Firebase console.

### `POST /devices`

```json
{ "token": "...", "platform": "android" }
```

`platform`: `android` or `ios` (defaults to `android`). Re-registering the same token moves it to the current user.

**200** — `{ "success": true }`  
**401** — missing / invalid Bearer token

---

### `POST /devices/unregister`

```json
{ "token": "..." }
```

**200** — `{ "success": true }` (idempotent)

When `NotificationRecorder` writes an inbox row, FCM is sent to that user’s tokens (`FCM_SERVER_KEY` or `FIREBASE_SERVICE_ACCOUNT_JSON`). If neither is set, the inbox write still succeeds and push is skipped.

Call / share / boost **do not** send FCM anymore. They are counted into `POST /internal/alerts/mall` digests.

---

## Cron alerts (GitHub Actions)

No user JWT. Header **`X-Cron-Secret: <CRON_SECRET>`** (GrowKit-prefixed password on Render + GitHub Secrets). Missing/wrong secret → `401`.

| Cron | Method | Path |
|------|--------|------|
| Every 2 hours | `POST` | `/internal/alerts/weather` |
| Every 2 hours | `POST` | `/internal/alerts/mall` |
| 06:00 IST (`30 0 * * *` UTC) | `POST` | `/internal/alerts/crop-prices` |

**200**

```json
{ "ok": true, "sent": 2 }
```

FCM `data.screen` on tap: `weather` · `crop_prices` · `my_mall` · `inbox` · `listing` (plus `listingId`).

---

## Notifications inbox

Auth required. **`user_id`** in the path must match the JWT user (numeric id).

### `GET /notifications/:user_id/count`

Home badge — do not load the full list here.

**200**

```json
{ "unreadCount": 4 }
```

---

### `GET /notifications/:user_id`

Bell tap — full inbox, newest first.

**200**

```json
{
  "unreadCount": 4,
  "notifications": [
    {
      "id": "n_1",
      "type": "boost",
      "unread": true,
      "createdAt": "2026-09-14T10:00:00.000Z",
      "user": {
        "id": "2",
        "name": "Priya",
        "village": "Nandyal",
        "photo": { "id": 10, "url": "https://farm-backend-kiei.onrender.com/photos/10/file", "uploaded": true }
      },
      "listing": { "id": "1", "title": "Murrah buffalo" }
    },
    {
      "id": "n_2",
      "type": "report",
      "unread": true,
      "createdAt": "2026-09-14T11:00:00.000Z",
      "user": { "id": "3", "name": "Ramesh" },
      "listing": { "id": "1", "title": "Murrah buffalo" }
    },
    {
      "id": "n_3",
      "type": "negotiate",
      "unread": true,
      "createdAt": "2026-09-14T09:00:00.000Z",
      "user": { "id": "2", "name": "Priya" },
      "listing": { "id": "1", "title": "Murrah buffalo" },
      "offerId": "5",
      "offerPriceInr": 72000
    },
    {
      "id": "n_4",
      "type": "listing_views",
      "unread": false,
      "createdAt": "2026-09-12T08:00:00.000Z",
      "listing": { "id": "1", "title": "Murrah buffalo" },
      "count": 100
    },
    {
      "id": "n_5",
      "type": "profile_view",
      "unread": true,
      "createdAt": "2026-09-14T06:00:00.000Z",
      "user": { "id": "2", "name": "Priya" }
    }
  ]
}
```

Unused fields are **omitted** (not `null`). Unknown `type` → treat as generic row in the app.

| `type` | Typical payload |
|--------|------------------|
| `boost`, `call`, `share`, `report` | `user` + `listing` |
| `negotiate` | `user` + `listing` + `offerId` + `offerPriceInr` |
| `listing_views` | `listing` + `count` |
| `profile_view` | `user` only |

**403** / **404** — same as My Mall path auth.

---

### `POST /notifications/:user_id/read`

Call after the inbox list loads successfully. Marks all rows read.

**Body:** empty or `{}`

**200**

```json
{ "unreadCount": 0 }
```

---

## My Mall & earnings

Auth required. **`user_id`** must match the logged-in user (numeric user id, not phone).

### `GET /mall/my-mall/:user_id`

**200**

```json
{
  "active": [ /* Listing[] — status "active" */ ],
  "sold": [ /* Listing[] + sale: { salePriceInr, buyerName, buyerPhone, buyerUserId, soldAt } */ ],
  "reported": [ /* Listing[] — status "reported", reportCount */ ],
  "offers": {
    "onMyListings": [ /* Offer[] */ ],
    "sent": [ /* Offer[] */ ]
  }
}
```

Sold+reported listings are duplicated into **`sold`** and **`reported`**. See [app-contract §10a](app-contract.md#10a-my-mall--earnings). Same **Offer object** for both offer arrays.

---

### `GET /mall/earnings/:user_id`

**200**

```json
{
  "activeListingCount": 2,
  "soldCount": 5,
  "soldTotalInr": 420000,
  "purchaseCount": 1,
  "purchaseTotalInr": 12000,
  "offersSentCount": 3,
  "offersSentTotalInr": 95000,
  "interestCount": 8
}
```

| Field | Meaning |
|-------|---------|
| `activeListingCount` | Seller’s listings with `status: active` |
| `soldCount` / `soldTotalInr` | Seller’s sold listings |
| `purchaseCount` / `purchaseTotalInr` | Offers **you sent as buyer** with `status: accepted` |
| `offersSentCount` / `offersSentTotalInr` | All offers you sent as buyer |
| `interestCount` | Interests received as seller |

---

## Mall listings

### `GET /mall/listings`

Auth optional (blocks applied when logged in).

**Query**

| Param | Description |
|-------|-------------|
| `category` | `animals`, `used_machines`, `produce`, `other` |
| `q` | Search title or village |
| `sort` | `popular` (by interest count) or default (boost + newest) |
| `doctorVerified` / `doctor_verified` | `true` for vet-verified only |
| `loaded_listings` / `loadedListings` | Ids already shown (comma-separated, JSON array, or `loaded_listings[]`) — server returns the **next** rows excluding these |
| `per_page` / `perPage` / `limit` | Page size (default **3**, max 50; env `MALL_FEED_PER_PAGE`) |

**200** — feed envelope (matches Flutter `MallFeedPage` + pagination):

```json
{
  "listings": [ /* Listing object — default 3 per request */ ],
  "live_members_count": 186,
  "has_more": true,
  "per_page": 3
}
```

Example load-more: `GET /mall/listings?loaded_listings=1,2,3` → listings `4,5,6` (same sort/filter as first page).

**`live_members_count`:** random integer **1–10_000** on each call.

With a Bearer token, each listing includes **`boost: true|false`** for this viewer’s reel boost.

---

### `GET /mall/listings/:id`

Auth optional.

**200** — [Listing object](#listing-object)

---

### `GET /mall/listings/user/:user_id`

Auth optional. All listings for a seller.

**200** — array of [Listing object](#listing-object)

---

### `POST /mall/listings`

Auth required. Requires an active [location](#location-object) (`locationId` or user’s active location).

**Body**

```json
{
  "title": "Murrah buffalo",
  "priceInr": 85000,
  "category": "animals",
  "description": "Healthy, 2nd lactation",
  "animalType": "buffalo",
  "breed": "Murrah",
  "lactation": "second",
  "milkLitersPerDay": 12,
  "vetVerificationRequested": false,
  "locationId": "1",
  "imageUrls": ["https://example.com/1.jpg"],
  "photoIds": [],
  "spotlight": false
}
```

**201** — [Listing object](#listing-object)

**422** — `{ "success": false, "message": "Set your village location first" }`

---

### `PATCH /mall/listings/:id/sold`

Auth required. Owner only.

**Body (optional but recommended)**

```json
{
  "buyerPhone": "9876543210",
  "salePriceInr": 50000
}
```

Snake_case OK. **`salePriceInr`** is the actual sale amount (listing **`priceInr`** stays the original ask). **`buyerPhone`**: 10-digit mobile; if that user exists, **`buyerUserId`** / name are linked for My Mall.

**200** — [Listing object](#listing-object) with `status: "sold"` and **`sale`** when viewer is owner.

**403** — not owner  
**422** — invalid phone or sale price

---

### `POST /mall/listings/:id/report`

Auth required.

**Body**

```json
{
  "reason": "spam",
  "blockSeller": true
}
```

**200**

```json
{ "success": true }
```

---

## Mall interests

Auth required.

### `POST /mall/interests`

**Body**

```json
{
  "listingId": "1",
  "message": "Is it still available?"
}
```

**201** — [Interest object](#interest-object)

**422** — Interest already sent

---

### `GET /mall/interests/buyer`

**200** — array of [Interest object](#interest-object)

---

### `GET /mall/interests/seller`

**200** — array of [Interest object](#interest-object)

---

## Mall offers (Premium buyers)

Auth required. **Premium** required to create.

### `POST /mall/offers`

**Body**

```json
{
  "listingId": "1",
  "offerPriceInr": 80000
}
```

**201** — [Offer object](#offer-object)

**403** — GrowKit Premium is required  
**422** — Offer already sent

---

### `GET /mall/offers/buyer` · `GET /mall/offers/seller`

**200** — array of [Offer object](#offer-object)

---

### `PATCH /mall/offers/:id/respond`

**Auth:** Bearer. **Seller only** (offer’s listing owner).

**Body**

```json
{ "action": "accept" }
```

(or `"reject"`). Snake_case OK. If Rails swallows `action`, use `respond_action` / `respondAction`.

**200** — `{ "data": { ...Offer } }` including **`status`**: `accepted` or `rejected`. Parsers may also accept the same fields at the root or under `"offer"`.

**403** — not the seller  
**404** — unknown offer id  
**422** — invalid action, or offer not `pending`

---

## Mall engagements

Auth required. **Not on your own listing** (422). Persists share/call/boost on `mall_engagements` (`mall_listing_id`, `user_id`, `engagement_type`).

**POST share / call / boost** — **200:** `{ "success": true }` only.

---

### `POST /mall/engagements/share`

**Body:** `{ "listingId": "1" }`

**200** — `{ "success": true }`

---

### `POST /mall/engagements/call`

Premium required.

**Body:** `{ "listingId": "1" }`

**200** — `{ "success": true }`  
**403** — Premium required

---

### `POST /mall/engagements/boost`

Buyer reel boost (not owner [Spotlight](#mall-spotlight)). Idempotent per user + listing.

**Body:** `{ "listingId": "1" }`

**200** — `{ "success": true }`

---

### `POST /mall/engagements/boost/remove`

**Body:** `{ "listingId": "1" }`

**200** — `{ "success": true }`

---

### `GET /mall/engagements/boost/mine`

**200** — `{ "listingIds": ["1", "2"], "boostedListingIds": ["1", "2"] }` (replaces local prefs when wired)

---

### `GET /mall/favorites`

Auth required. Listings this buyer **called or shared** (not boost-only), unique by listing, **newest engagement first**, **limit 5**. Same [Listing object](#listing-object) as the mall feed.

**200**

```json
{ "favorites": [ { "...listing..." } ] }
```

Empty: `{ "favorites": [] }`  
**401** — missing / invalid Bearer token

---

## Mall spotlight

Auth required. Free tier: **3** spotlights per month, **24h** boost each (`Constants.mall_spotlight_*`).

### `GET /mall/spotlight/remaining`

**200**

```json
{ "remaining": 3 }
```

---

### `POST /mall/spotlight/use`

Owner only. Applies boost to listing.

**Body**

```json
{ "listingId": "1" }
```

**200** — [Listing object](#listing-object) (with updated `boostExpiresAt`)

**403** — Forbidden (not owner)  
**422** — No free spotlight remaining

---

## Shared object shapes

### User object

```json
{
  "id": 1,
  "phone": 9123456789,
  "name": "Ravi",
  "role": "farmer",
  "signedUp": true,
  "internal": false,
  "village": "Nandyal",
  "language": "en",
  "premium": false,
  "disableScreenshot": false,
  "location": { },
  "photo": { "id": 1, "url": "https://..." }
}
```

Omitted keys are `null` and stripped from JSON.

`disableScreenshot` is `true` when the user is **internal** (3-series phone or ActiveAdmin `internal`); otherwise `false`.

---

### Location object

```json
{
  "id": "1",
  "village": "Nandyal",
  "latitude": 15.47,
  "longitude": 78.48,
  "active": true
}
```

---

### Listing object

`priceInr` is omitted when the viewer is not Premium and not the seller.

`boost` is `true` when this signed-in viewer already posted `/mall/engagements/boost` for the listing; otherwise `false`. This is the buyer reel flag, not seller spotlight (`boostExpiresAt`).

```json
{
  "id": "1",
  "title": "Murrah buffalo",
  "priceInr": 85000,
  "category": "animals",
  "village": "Nandyal",
  "locationId": "1",
  "seller": { "id": 1, "name": "Ravi", "phone": 9123456789, "photo": { "id": 10, "url": "..." } },
  "photos": [{ "id": 42, "url": "https://.../photos/42/file", "uploaded": true }],
  "icon": 59677,
  "description": "...",
  "createdAt": "2026-09-13T09:00:00Z",
  "boostExpiresAt": null,
  "listingExpiresAt": "2026-09-20T09:00:00Z",
  "interestCount": 0,
  "status": "active",
  "boost": false,
  "imageUrl": "https://...",
  "imagePaths": ["https://..."],
  "imageUrls": ["https://..."],
  "animalType": "buffalo",
  "breed": "Murrah",
  "lactation": "second",
  "milkLitersPerDay": 12.0,
  "vetVerificationRequested": false,
  "vetVerified": false
}
```

| `category` | `animals`, `used_machines`, `produce`, `other` |
| `animalType` | `cow`, `buffalo`, `ox`, `padi`, `calf`, `goat`, `sheep` |
| `lactation` | `not_calved`, `first`, `second`, `other` |
| `status` | `active`, `sold` |

---

### Interest object

```json
{
  "id": "1",
  "listingId": "1",
  "listingTitle": "Murrah buffalo",
  "buyerUserId": "2",
  "buyerName": "Priya",
  "sellerUserId": "1",
  "village": "Nandyal",
  "priceInr": 85000,
  "message": "Still available?",
  "createdAt": "2026-09-13T10:00:00Z"
}
```

---

### Offer object

```json
{
  "id": "1",
  "listingId": "1",
  "listingTitle": "Murrah buffalo",
  "buyerName": "Priya",
  "buyerPhone": "9876543210",
  "village": "Nandyal",
  "listingPriceInr": 85000,
  "offerPriceInr": 80000,
  "status": "pending",
  "createdAt": "2026-09-13T10:00:00Z"
}
```

`status`: `pending`, `accepted`, `rejected`

---

### User plan object

```json
{
  "id": "1",
  "plan": {
    "id": "2",
    "code": "quarterly",
    "name": "Quarterly",
    "durationMonths": 3,
    "priceInr": 249,
    "discountPercent": 16,
    "active": true
  },
  "status": "active",
  "amountInr": 249,
  "startsAt": "2026-09-19T10:00:00Z",
  "endsAt": "2026-12-19T10:00:00Z"
}
```

---

### Mandate object

```json
{
  "id": "1",
  "planId": "2",
  "plan": "quarterly",
  "status": "active",
  "razorpaySubscriptionId": "sub_123",
  "nextChargeAt": "2026-12-19T10:00:00Z",
  "source": "mock"
}
```

`status`: `created`, `authorized`, `active`, `paused`, `cancelled`, `expired`

---

### Purchase object

Returned by `POST /subscriptions`.

```json
{
  "id": "1",
  "plan": "monthly",
  "status": "active",
  "amountInr": 99,
  "currency": "INR",
  "startsAt": "2026-09-19T10:00:00Z",
  "endsAt": "2026-10-19T10:00:00Z",
  "source": "mock",
  "nextChargeAt": "2026-10-19T10:00:00Z"
}
```

---

## Admin (browser, not mobile API)

| Path | Purpose |
|------|---------|
| `/admin` | ActiveAdmin (Devise admin users) |
| `/sidekiq` | Sidekiq Web UI (admin only) |

---

## Typical client flow

1. `POST /request-otp` → `{ phone }`
2. `POST /verify-otp` → `{ phone, otp }` → store `token`
3. If `user.signedUp` is false → `POST /users/profile` with Bearer token
4. `POST /user-locations` → village + coordinates
5. `GET /mall/listings` or `GET /dashboard` with Bearer token

Demo (seed): `3123456789` / `3876543210` (3-series). Real numbers (e.g. starting with 8/9) use random OTP unless SMS/`OTP_INCLUDE_IN_RESPONSE`.
