# Location Service API

**Author**: Josh S. Sakweli, Backend Lead Team
**Last Updated**: 2026-10-24
**Version**: v1.0

**Base URL**: `https://api.nexgate.co/api/v1`

**Short Description**: The location service turns map pins into Tanzanian places (ward, district, region), keeps
every shop's location and every user's saved addresses, and hands checkout and shipping a frozen copy of a place.
Everything in shipping — who can deliver, at what price, which campaign applies — starts from what this service
knows about a place.

**Hints**:
- **Pins only.** Nobody types a street, city or region. The app sends `lat` / `lng`; the backend works out the ward.
  Show our `areaLabel` once a pin is confirmed (Google's text is only for the live map).
- **Places are frozen on orders.** Editing or deleting a saved address or moving a shop never changes an order
  already placed — the order keeps its own copy (a *place snapshot*, §1.5).
- All endpoints need a logged-in user, except reading a shop's location.
- Coded refusals come back as HTTP **409** with `data.code` (and sometimes `data.details`) — switch on the code, not
  the message.

---

## Standard Response Format

### Success
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Location resolved",
  "action_time": "2026-10-24T10:30:45",
  "data": { }
}
```

### Error (coded refusal)
```json
{
  "success": false,
  "httpStatus": "CONFLICT",
  "message": "You can save up to 10 addresses",
  "action_time": "2026-10-24T10:30:45",
  "data": { "code": "ADDRESS_LIMIT_REACHED", "reason": "You can save up to 10 addresses" }
}
```
Some refusals add `data.details` (e.g. `NEAR_DUPLICATE` returns the address it collides with).

| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | `true` for success, `false` for errors |
| `httpStatus` | string | HTTP status name |
| `message` | string | Human-readable result, safe to show |
| `action_time` | string | When the response was made |
| `data` | object/string | Payload, or error details |

---

## HTTP Method Badge Standards

- **GET** - <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> - read only
- **POST** - <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> - create / act
- **PUT** - <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> - replace
- **DELETE** - <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> - remove

---

# Part 1 — How the location service works

## 1.1 The map of Tanzania inside the backend

The backend holds the official **NBS 2022 ward boundaries**: about 4,300 wards, each inside a **district**, each
district inside a **region**.

```
Region (e.g. 07 Dar es Salaam)
 └─ District (07-01 Kinondoni)      ← a district is regionCode + districtCode: district codes repeat across regions
     └─ Ward (1131 Kijitonyama)     ← the smallest area; every pin lands in exactly one
```

The ward data is a file loaded by the backend at start-up (only when the file changes). If it is not loaded yet,
calls that need it answer `WARD_DATA_UNAVAILABLE`.

## 1.2 A pin becomes a place — "resolve"

```
 App map stops moving ──► GET /locations/resolve?lat&lng ──► covered?
                                                            ├─ yes: ward + district + region + areaLabel
                                                            └─ no:  covered:false (sea, lake, outside Tanzania)
```

- **CONTAINED** — the pin is inside a ward.
- **NEAREST** — the pin is just outside every ward (a beach, a river bank, a border line) but within **500 m** of one:
  that ward is used and `distanceM` says how far.
- Further than that → `covered: false`. The app disables *Confirm* and says "We don't deliver here yet".

Resolve stores nothing; it is safe to call on every map stop (debounce ~400 ms).

## 1.3 Areas — the picker for coverage and campaigns

Shippers tick **where they work** and campaigns choose **where they apply** with an *area*: a whole region, a
district, or a single ward. The area endpoints give the lists (regions → districts → wards) and a search box
(`/areas/search?q=sin` → "Sinza, Ubungo, Dar es Salaam"). An area is always one of:

| level | identified by |
|---|---|
| `REGION` | `regionCode` |
| `DISTRICT` | `regionCode` + `districtCode` |
| `WARD` | `wardId` |

## 1.4 Two kinds of places people own

| | Shop location | Saved address |
|---|---|---|
| Who | the shop (owner sets it) | any user (buyer, seller, anyone) |
| How many | exactly one per shop | up to 10 per user, plus up to 5 recent pins |
| Who can see it | **public** — buyers need to find the shop | **private** — only the owner |
| Used for | pickup point, where riders collect, product page "Directions" | where to deliver |
| Extra | landmark, building, official address, the shop's phone | label (Home / Work / named), directions |

**Recent pins:** when an order is delivered to a new pin that the buyer did not save, the pin becomes a *recent*
(up to 5, kept 60 days; a pin within 50 m of an existing recent just refreshes it). A recent can later be saved as an
address.

## 1.5 Place snapshots — frozen copies on orders

Every order, quotation and shipping leg keeps its own copy of the places it uses:

```json
{
  "source": "SAVED_ADDRESS",              // SHOP_LOCATION | SAVED_ADDRESS | RECENT | NEW_PIN | STATION
  "sourceId": "9b1d…",                    // the address / recent / shop / station it came from
  "label": "Home",
  "lat": -6.7735, "lng": 39.2187,
  "ward": { "wardId": 1131, "wardName": "Kijitonyama", "districtCode": "01", "districtName": "Kinondoni",
            "regionCode": "07", "regionName": "Dar es Salaam", "nbsCode": "070113062", "councilName": "…" },
  "areaLabel": "Kijitonyama, Kinondoni, Dar es Salaam",
  "directions": "Black gate, blue house",
  "building": null,                       // shops only
  "phone": "+255744742944",               // shops and stations only
  "capturedAt": "2026-10-24T10:30:45"
}
```

Because of this, editing or deleting an address, or moving a shop, **never changes an order already placed**.

## 1.6 Choosing where to deliver — the drop-off request

Checkout, the cart and shipping all accept the same small object to say *where*:

```json
{ "addressId": "…" }                          // a saved address
{ "recentId": "…" }                           // a recent pin
{ "lat": -6.7735, "lng": 39.2187,             // a new pin
  "directions": "Opposite the petrol station",
  "saveAs": "OTHER", "customLabel": "Office" } // optional: also save it to the address book
```

Give **exactly one** of `addressId`, `recentId` or `lat`+`lng` → otherwise `DROPOFF_INVALID`; none at all where one is
needed → `DROPOFF_REQUIRED`. Who receives the parcel and the note for the rider belong to the **order**, not to the
place.

## 1.7 Event venues

An in-person or hybrid event has one venue: name, optional typed address, and a **pin**. The pin is resolved like a
shop's (`OUTSIDE_COVERAGE` when not covered) and the event's `venue` shows `ward`, `areaLabel` and `matchType`.
A pin is required to publish (or reveal the location of) an in-person / hybrid event. The venue is part of the event
(no separate endpoint): set it with `PATCH /e-events/drafts/{draftId}/location` or
`PATCH /e-events/{eventId}/published/reveal-location`.

## 1.8 How shipping uses all this

- **Who can deliver:** a shipper covers a trip only when both the shop's ward and the buyer's ward are inside its
  ticked areas.
- **Price:** road distance = straight line between the two pins × 1.3.
- **Campaigns:** "free delivery in Dar" means the buyer's ward is in region 07.
- **Reports** are by region / district / ward, never per person.

---

# Part 2 — Endpoints

## Pins and areas

## 1. Resolve a pin
**Purpose**: Turn a map pin into ward / district / region, or say it is not covered.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/resolve`

**Access Level**: 🔒 Protected (any logged-in user)

**Authentication**: Bearer token

**Query Parameters**:
| Parameter | Type | Required | Description | Validation | Default |
|-----------|------|----------|-------------|------------|---------|
| lat | number | Yes | Latitude of the pin | -90 … 90, not 0,0 | — |
| lng | number | Yes | Longitude of the pin | -180 … 180 | — |

**Success Response JSON Sample** (covered):
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Location resolved",
  "action_time": "2026-10-24T10:30:45",
  "data": {
    "covered": true,
    "latitude": -6.819, "longitude": 39.2768,
    "matchType": "CONTAINED",
    "distanceM": 0,
    "ward": {
      "wardId": 1146, "nbsCode": "070214112", "wardName": "Kariakoo",
      "districtCode": "02", "districtName": "Ilala",
      "councilName": "Halmashauri ya Jiji la Dar es Salaam",
      "regionCode": "07", "regionName": "Dar es Salaam"
    },
    "areaLabel": "Kariakoo, Ilala, Dar es Salaam"
  }
}
```

Not covered (sea, lake, outside Tanzania): `data` = `{ "covered": false, "latitude": -6.8, "longitude": 39.4, "reason": "OUTSIDE_COVERAGE" }`.

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `covered` | `false` = no ward within 500 m: disable *Confirm* |
| `matchType` | `CONTAINED` inside a ward, `NEAREST` just outside one (≤ 500 m) |
| `distanceM` | metres to the ward when `NEAREST` |
| `ward` | the ward with its district and region |
| `areaLabel` | the name to show: "Ward, District, Region" |
| `reason` | only when not covered |

**Errors**: `INVALID_COORDINATES`, `WARD_DATA_UNAVAILABLE`.

---

## 2. List regions
**Purpose**: First level of the area picker.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/regions`

**Access Level**: 🔒 Protected

**Success Response JSON Sample**:
```json
{
  "success": true, "httpStatus": "OK", "message": "Regions", "action_time": "2026-10-24T10:30:45",
  "data": [
    { "regionCode": "07", "regionName": "Dar es Salaam", "districtCount": 5, "wardCount": 102 },
    { "regionCode": "12", "regionName": "Mbeya", "districtCount": 7, "wardCount": 199 }
  ]
}
```

---

## 3. List a region's districts
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/regions/{regionCode}/districts`

**Access Level**: 🔒 Protected

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| regionCode | string | Yes | e.g. `07` |

**Success Response** `data`: `[{ "regionCode": "07", "regionName": "Dar es Salaam", "districtCode": "01", "districtName": "Kinondoni", "wardCount": 20 }]`

---

## 4. List a district's wards
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/regions/{regionCode}/districts/{districtCode}/wards`

**Access Level**: 🔒 Protected

**Success Response** `data`: a list of ward objects, the same shape as `ward` in [Resolve](#1-resolve-a-pin).

---

## 5. Search areas
**Purpose**: Type-ahead for the area picker ("sin" → Sinza).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/areas/search?q=sin`

**Access Level**: 🔒 Protected

**Query Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| q | string | Yes | Part of a region, district or ward name | at least 2 letters |

**Success Response** `data` (up to 20, regions first, prefix matches first):
```json
[
  { "level": "WARD", "regionCode": "07", "districtCode": "04", "wardId": 1212, "name": "Sinza", "label": "Sinza, Ubungo, Dar es Salaam" }
]
```
The returned item is exactly what coverage and campaigns accept as an area (`level` + codes).

**Errors**: `SEARCH_TOO_SHORT`.

---

## Shop location

## 6. Set or replace the shop location
**Purpose**: The shop owner pins the shop. Used for pickups, riders and the product page.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/shops/{shopId}/location`

**Access Level**: 🔒 Protected (shop owner, or platform admin)

**Request JSON Sample**:
```json
{
  "lat": -6.8190, "lng": 39.2768,
  "landmark": "Kariakoo market, opposite the clock tower",
  "building": "Shop 12",
  "officialAddress": null,
  "deviceLat": -6.7700, "deviceLng": 39.2400
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| lat / lng | number | Yes | The pin | must be inside a ward (or ≤ 500 m) |
| landmark | string | Yes | How to find the shop | max 300 |
| building | string | No | Building / shop number | max 100 |
| officialAddress | string | No | Anwani za Makazi | max 100 |
| deviceLat / deviceLng | number | No | The phone's own position, only to warn when the pin is far | — |

**Success Response** (message "Shop location saved"): the shop location (as in [Get](#7-get-a-shop-location)), plus `warnings` when the pin is more than
1 km from the phone: `"warnings": [{ "code": "FAR_FROM_DEVICE", "distanceM": 6797 }]` → ask "Your pin is 6.8 km from
where you are. Is this your shop?" It is saved either way.

**Rules**: one location per shop; editing = the same `PUT` with the whole location; the ward is always worked out
from the pin. Orders already placed keep their pickup place. Buyers are not notified when a shop moves.

**Errors**: `OUTSIDE_COVERAGE`, `INVALID_COORDINATES`, 403 "Only the shop owner can set the shop location".

---

## 7. Get a shop location
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/shops/{shopId}/location`

**Access Level**: 🌐 Public (no login)

**Success Response JSON Sample**:
```json
{
  "success": true, "httpStatus": "OK", "message": "Shop location retrieved", "action_time": "2026-10-24T10:30:45",
  "data": {
    "shopId": "aaaa0000-0000-4000-8000-000000000001",
    "lat": -6.819, "lng": 39.2768,
    "ward": { "wardId": 1146, "wardName": "Kariakoo", "districtName": "Ilala", "regionName": "Dar es Salaam" },
    "matchType": "CONTAINED",
    "areaLabel": "Kariakoo, Ilala, Dar es Salaam",
    "landmark": "Kariakoo market, opposite the clock tower",
    "building": "Shop 12",
    "phone": "+255744742944",
    "officialAddress": null,
    "updatedAt": "2026-10-12T11:49:59"
  }
}
```
`phone` is the shop's own phone (one phone per shop). **App:** "Directions" opens maps with `lat,lng`; "Call" uses
`phone`. 404 when the shop has no location yet. A shop without a location cannot sell physical products:
checkout answers `SHOP_LOCATION_REQUIRED`.

---

## Saved addresses and recent pins

## 8. My addresses
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/addresses`

**Access Level**: 🔒 Protected (own addresses only)

**Success Response** `data`: default first, then most recently used:
```json
[
  {
    "id": "9b1d…", "labelType": "HOME", "customLabel": null,
    "lat": -6.7745, "lng": 39.2419,
    "ward": { "wardName": "Kijitonyama", "districtName": "Kinondoni", "regionName": "Dar es Salaam" },
    "matchType": "CONTAINED", "areaLabel": "Kijitonyama, Kinondoni, Dar es Salaam",
    "directions": "Black gate, blue house",
    "isDefault": true, "lastUsedAt": "2026-10-20T09:00:00", "createdAt": "…", "updatedAt": "…"
  }
]
```

---

## 9. Save an address
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/locations/addresses`

**Access Level**: 🔒 Protected

**Request JSON Sample**:
```json
{
  "lat": -6.7745, "lng": 39.2419,
  "labelType": "OTHER",
  "customLabel": "Mama's place",
  "directions": "Black gate, blue house",
  "makeDefault": false,
  "allowNearDuplicate": false
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| lat / lng | number | Yes | The pin | inside a ward |
| labelType | enum | Yes | `HOME`, `WORK`, `OTHER` | HOME / WORK once each |
| customLabel | string | for OTHER | The name for an OTHER address | max 40 |
| directions | string | No | How to find the place | max 300 |
| makeDefault | boolean | No | Make it the default | — |
| allowNearDuplicate | boolean | No | Save even if within 50 m of another address | — |

**Success Response**: the saved address (shape as in [My addresses](#8-my-addresses)).

**Rules**:
- Up to **10** addresses. The **first** one saved becomes the default; deleting the default leaves none (the app
  pre-selects the most recently used).
- Within **50 m** of another saved address → `NEAR_DUPLICATE` with that address in `data.details`. Ask "This looks
  like 🏠 Home. Use it?"; to save anyway send again with `allowNearDuplicate: true`.

**Errors**: `ADDRESS_LIMIT_REACHED`, `LABEL_ALREADY_USED`, `CUSTOM_LABEL_REQUIRED`, `NEAR_DUPLICATE`, `OUTSIDE_COVERAGE`.

---

## 10. Edit an address
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/locations/addresses/{addressId}`

**Access Level**: 🔒 Protected (own address)

Same body and rules as [Save](#9-save-an-address) — send the whole address. Orders already placed are not affected.

---

## 11. Delete an address
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/locations/addresses/{addressId}`

**Access Level**: 🔒 Protected (own address)

---

## 12. Make an address the default
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/locations/addresses/{addressId}/default`

**Access Level**: 🔒 Protected (own address)

**Success Response**: the address, now `isDefault: true` (the old default is cleared).

---

## 13. My recent pins
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/locations/recent`

**Access Level**: 🔒 Protected

**Success Response** `data`: `[{ "id": "…", "lat": -6.7735, "lng": 39.2187, "ward": {…}, "matchType": "CONTAINED", "areaLabel": "…", "lastUsedAt": "…" }]`
— up to 5, newest first. Added by checkout when an order is delivered to a new, unsaved pin.

---

## 14. Remove a recent pin / clear all
**Endpoints**:
- <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/locations/recent/{recentId}`
- <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/locations/recent`

**Access Level**: 🔒 Protected

---

# Part 3 — Codes and values

## Error codes (HTTP 409, `data.code`)

| Code | When |
|------|------|
| `INVALID_COORDINATES` | lat/lng out of range, or 0,0 |
| `OUTSIDE_COVERAGE` | saving a shop location or address whose pin is not in Tanzania's wards (resolve answers `covered:false` instead) |
| `WARD_DATA_UNAVAILABLE` | the ward data is not loaded on this server yet |
| `SEARCH_TOO_SHORT` | area search with fewer than 2 letters |
| `ADDRESS_LIMIT_REACHED` | an 11th saved address |
| `LABEL_ALREADY_USED` | a second HOME or WORK |
| `CUSTOM_LABEL_REQUIRED` | OTHER without a name |
| `NEAR_DUPLICATE` | within 50 m of a saved address (`data.details` = that address) |
| `DROPOFF_REQUIRED` | a drop-off is needed and none was given |
| `DROPOFF_INVALID` | not exactly one of address / recent / pin, or the address / recent is not yours |
| `SHOP_LOCATION_REQUIRED` | checkout of a physical product from a shop without a location |

## Values

| Value | Meaning |
|---|---|
| `MatchType` `CONTAINED` / `NEAREST` | pin inside a ward / within 500 m of one |
| `AddressLabel` `HOME` / `WORK` / `OTHER` | HOME and WORK once each; OTHER needs `customLabel` |
| `PlaceSource` | where a snapshot came from: `SHOP_LOCATION`, `SAVED_ADDRESS`, `RECENT`, `NEW_PIN`, `STATION` |
| `AreaLevel` `REGION` / `DISTRICT` / `WARD` | the three kinds of area |

## Limits

| Limit | Value |
|---|---|
| Nearest-ward distance | 500 m |
| Saved addresses per user | 10 |
| Recent pins per user / kept | 5 / 60 days |
| Same place (duplicate / recent refresh) | 50 m |
| Shop pin far from the phone (warning) | 1 km |
| Area search results | 20 |