Skip to main content

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

{
  "success": true,
  "httpStatus": "OK",
  "message": "Location resolved",
  "action_time": "2026-10-24T10:30:45",
  "data": { }
}

Error (coded refusal)

{
  "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 - GET - read only
  • POST - POST - create / act
  • PUT - PUT - replace
  • DELETE - DELETE - 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:

{
  "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:

{ "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: GET {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):

{
  "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: GET {base_url}/locations/regions

Access Level: 🔒 Protected

Success Response JSON Sample:

{
  "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: GET {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: GET {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.


5. Search areas

Purpose: Type-ahead for the area picker ("sin" → Sinza).

Endpoint: GET {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):

[
  { "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: PUT {base_url}/e-commerce/shops/{shopId}/location

Access Level: 🔒 Protected (shop owner, or platform admin)

Request JSON Sample:

{
  "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), 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: GET {base_url}/e-commerce/shops/{shopId}/location

Access Level: 🌐 Public (no login)

Success Response JSON Sample:

{
  "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: GET {base_url}/locations/addresses

Access Level: 🔒 Protected (own addresses only)

Success Response data: default first, then most recently used:

[
  {
    "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: POST {base_url}/locations/addresses

Access Level: 🔒 Protected

Request JSON Sample:

{
  "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).

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: PUT {base_url}/locations/addresses/{addressId}

Access Level: 🔒 Protected (own address)

Same body and rules as Save — send the whole address. Orders already placed are not affected.


11. Delete an address

Endpoint: DELETE {base_url}/locations/addresses/{addressId}

Access Level: 🔒 Protected (own address)


12. Make an address the default

Endpoint: POST {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: GET {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:

  • DELETE {base_url}/locations/recent/{recentId}
  • DELETE {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