Mobile API (v1)
Base path: /mobile-api/v1/
OpenAPI docs:
- Schema JSON: /mobile-api/schema/
- Swagger UI: /mobile-api/docs/
Authentication & Identity
The API uses a persistent client identity model — no passwords, no OAuth tokens.
- Every client is identified by a UUID (
client_id). - Send it via the
X-Client-IDheader or theoffmap_client_idcookie. - If no client ID is provided, the server issues one via a
Set-Cookieheader on the response. - Persist this value in secure storage and send it on every request.
For returning users, the PIN-based login flow allows restoring a session on a new device (see Login).
Endpoints
Health
GET /mobile-api/v1/health/
No authentication required.
Response 200:
{
"status": "ok",
"service": "offmap-mobile-api",
"version": "v1"
}
Connection
GET /mobile-api/v1/connection/
No authentication required. No client ID needed and no cookie is set. This is the API version of the web page at /my-connection/.
Returns the caller's IP address and connection metadata as the server sees it: the public IP, reverse DNS, ISP/ASN, approximate location and the request headers that matter for diagnostics.
Query parameters:
| Name | Type | Default | Description |
|---|---|---|---|
lookup |
bool | true |
Set to false (or 0/no) to skip the reverse DNS and ISP/geo lookups. reverse_dns, provider and location are then null, and the response returns faster. |
Response 200:
{
"ip": "203.0.113.42",
"ip_version": 4,
"ip_scope": "public",
"remote_addr": "127.0.0.1",
"reverse_dns": "cpe-203-0-113-42.example.net.au",
"provider": {
"isp": "Example Broadband",
"org": "Example Broadband Pty Ltd",
"asn": "AS64500 Example Broadband Pty Ltd",
"as_name": "EXAMPLE-AS",
"mobile": false,
"proxy": false,
"hosting": false
},
"location": {
"city": "Brisbane",
"region": "Queensland",
"postal_code": "4000",
"country": "Australia",
"country_code": "AU",
"lat": -27.4698,
"lon": 153.0251,
"timezone": "Australia/Brisbane"
},
"request": {
"scheme": "https",
"host": "mapgrid.com.au",
"server_protocol": "HTTP/1.1"
},
"headers": {
"User-Agent": "MapGrid/1.0 (iOS)",
"X-Forwarded-For": "203.0.113.42"
}
}
Fields:
| Field | Description |
|---|---|
ip |
Caller IP. The server checks CF-Connecting-IP, then X-Real-IP, then the first entry of X-Forwarded-For, and finally uses the socket address. |
ip_version |
4, 6, or null if the IP could not be parsed. |
ip_scope |
public, private, loopback, reserved or invalid. |
remote_addr |
The address of the direct socket peer (normally the reverse proxy). |
reverse_dns |
PTR hostname for ip, or null if there is none or the 2 s timeout is reached. |
provider |
ISP, organisation and ASN. mobile, proxy (VPN/proxy/Tor) and hosting (datacenter) are flags. null for non-public IPs, when lookup=false, or if the lookup fails. |
location |
Approximate location of the IP. This is usually the ISP's location, not the device's. null in the same cases as provider. |
request |
Scheme, host and HTTP protocol of the request as received by Django. |
headers |
The request headers present from this set: forwarding headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP, X-Forwarded-Proto, Via, Forwarded), User-Agent, Accept-Language, Accept-Encoding, DNT, Sec-GPC, Sec-CH-UA* and Save-Data. |
Notes:
- ISP and location data come from ip-api.com (free tier, about 45 lookups per minute per server). If the lookup fails, provider and location come back as null and the response is still 200.
- Because the IP is taken from forwarding headers, a client can spoof the reported value. Use this for diagnostics, not for access control.
- The server cannot see the device's LAN IP, its DNS resolver or its IPv6 address if the request arrived over IPv4. To get those on mobile, query them on the device (or call https://api6.ipify.org for IPv6).
- Responses are sent with Cache-Control: no-store.
Example:
curl https://mapgrid.com.au/mobile-api/v1/connection/
curl "https://mapgrid.com.au/mobile-api/v1/connection/?lookup=false"
Profile
Manage the user's display name and PIN. A profile is required before using tracking groups or location sharing.
Get Profile
GET /mobile-api/v1/profile/
Response 200:
{
"profile": {
"name": "Rob",
"pin": "48201735"
}
}
Returns {"profile": null} if no profile exists for the current client.
Set Name (Create or Update)
POST /mobile-api/v1/profile/
Request:
{
"name": "Rob"
}
- If no profile exists, one is created and a unique 8-digit PIN is generated.
- If a profile already exists, the name is updated; the PIN remains unchanged.
Response 200:
{
"profile": {
"name": "Rob",
"pin": "48201735"
}
}
Login
Restore a session on a new device by providing your 8-digit PIN. No authentication required.
POST /mobile-api/v1/login/
Request:
{
"pin": "48201735"
}
Response 200:
{
"profile": {
"name": "Rob",
"pin": "48201735"
},
"logged_in": true
}
The response sets the offmap_client_id cookie to the existing client ID associated with that PIN.
Error 404:
{
"error": "PIN not found."
}
Logout
Reset the current session to a fresh client ID. No authentication required.
POST /mobile-api/v1/logout/
Response 200:
{
"logged_out": true
}
The response sets the offmap_client_id cookie to a new UUID.
Home Location
Get Home
GET /mobile-api/v1/home/
Response 200:
{
"home": {
"lat": -33.8688,
"lon": 151.2093,
"zoom": 15,
"label": "Home"
}
}
Returns {"home": null} if not set.
Save / Update Home
PUT /mobile-api/v1/home/
Request:
{
"lat": -33.8688,
"lon": 151.2093,
"zoom": 15,
"label": "Home"
}
| Field | Type | Required | Notes |
|---|---|---|---|
| lat | float | yes | -90 to 90 |
| lon | float | yes | -180 to 180 |
| zoom | int | no | 0–18, default 15 |
| label | string | no | max 120 chars, default "Home" |
Delete Home
DELETE /mobile-api/v1/home/
Response 200:
{
"removed": true
}
Trip
A client has at most one active trip containing ordered stops (start, via, end).
Get Trip
GET /mobile-api/v1/trip/
Response 200:
{
"trip": {
"id": 1,
"name": "Trip",
"stops": [
{
"id": 10,
"stop_type": "start",
"order": 0,
"label": "Sydney CBD",
"lat": -33.8688,
"lon": 151.2093,
"source": "manual"
},
{
"id": 11,
"stop_type": "via",
"order": 1,
"label": "Grid 55HCU 1234 5678",
"lat": -23.697,
"lon": 133.8807,
"source": "grid"
}
]
}
}
Add Stop
POST /mobile-api/v1/trip/
Request:
{
"stop_type": "via",
"label": "Grid 55HCU 1234 5678",
"lat": -23.697,
"lon": 133.8807,
"source": "grid"
}
| Field | Type | Required | Notes |
|---|---|---|---|
| stop_type | string | yes | start, via, or end |
| label | string | yes | max 180 chars |
| lat | float | yes | -90 to 90 |
| lon | float | yes | -180 to 180 |
| source | string | no | max 16 chars, default "manual" |
Ordering rules:
- start replaces any existing start and is placed first.
- end replaces any existing end and is placed last.
- via is inserted before any existing end stop.
Response 201 returns the full trip object.
Clear All Stops
DELETE /mobile-api/v1/trip/
Response 200:
{
"trip": {
"name": "Trip",
"stops": []
}
}
Delete Single Stop
DELETE /mobile-api/v1/trip/stops/{stop_id}/
Removes the stop and re-indexes remaining stop order. Returns the updated trip object.
Calculate Route
GET /mobile-api/v1/trip/route/
Calculates a route through all stops in the current trip (in order). Requires at least 2 stops.
The server uses either OSRM or GraphHopper as the routing backend (configured server-side via ROUTING_PROVIDER).
Response 200 (route found):
{
"route": {
"coordinates": [[-33.8688, 151.2093], [-33.75, 151.1], ...],
"distance_km": 42.5,
"duration_min": 35.2,
"provider": "osrm"
}
}
Response 200 (no trip or fewer than 2 stops):
{
"route": null
}
Error 502 (routing backend unreachable or returned no result):
{
"error": "OSRM returned no route."
}
Notes:
- coordinates is an array of [lat, lon] pairs representing the full route polyline.
- distance_km is the total route distance in kilometres.
- duration_min is the estimated travel time in minutes.
- provider indicates which routing engine was used (osrm or graphhopper).
Search (Geocoding)
Forward geocoding — search for a place by name and get coordinates. Proxied to the self-hosted Nominatim instance. No authentication required.
Search Places
GET /mobile-api/v1/search/?q={query}
| Param | Type | Required | Notes |
|---|---|---|---|
| q | string | yes | Place name or address to search for |
| limit | int | no | Max results (default 8) |
| countrycodes | string | no | Comma-separated ISO 3166-1 codes, e.g. au |
Response 200:
[
{
"place_id": 4871348,
"osm_type": "relation",
"osm_id": 5750005,
"lat": "-33.8698439",
"lon": "151.2082848",
"category": "place",
"type": "city",
"place_rank": 16,
"importance": 0.187,
"addresstype": "city",
"name": "Sydney",
"display_name": "Sydney, Council of the City of Sydney, New South Wales, Australia",
"boundingbox": ["-34.26", "-33.36", "150.26", "151.34"],
"address": {
"city": "Sydney",
"municipality": "Council of the City of Sydney",
"state": "New South Wales",
"country": "Australia",
"country_code": "au"
}
}
]
Error 502 if the geocoding backend is unreachable.
Reverse Geocode
GET /mobile-api/v1/reverse-geocode/?lat={lat}&lon={lon}
| Param | Type | Required | Notes |
|---|---|---|---|
| lat | string | yes | Latitude |
| lon | string | yes | Longitude |
Response 200:
{
"place_id": 4936616,
"osm_type": "relation",
"osm_id": 5729534,
"lat": "-33.867952",
"lon": "151.210134",
"display_name": "Sydney, Council of the City of Sydney, New South Wales, 2000, Australia",
"address": {
"suburb": "Sydney",
"municipality": "Council of the City of Sydney",
"state": "New South Wales",
"postcode": "2000",
"country": "Australia",
"country_code": "au"
}
}
Error 502 if the geocoding backend is unreachable.
Share Location
Generate a shareable URL pointing to the web map at specific coordinates. No authentication required.
POST /mobile-api/v1/share-location/
Request:
{
"lat": -23.697,
"lon": 133.8807,
"label": "50JLK 8024 8868",
"zoom": 14
}
| Field | Type | Required | Notes |
|---|---|---|---|
| lat | float | yes | -90 to 90 |
| lon | float | yes | -180 to 180 |
| label | string | no | max 180 chars, default "Shared Location" |
| zoom | int | no | 0–18, default 14 |
Response 200:
{
"url": "https://offmap.example.com/?lat=-23.697&lon=133.8807&zoom=14&label=50JLK+8024+8868",
"lat": -23.697,
"lon": 133.8807,
"label": "50JLK 8024 8868",
"zoom": 14
}
Map Fragments (Offline Download)
Estimate
POST /mobile-api/v1/map-fragments/estimate/
Estimate tile count and approximate download size before committing.
Request:
{
"min_lat": -34.2,
"min_lon": 150.8,
"max_lat": -33.7,
"max_lon": 151.4,
"min_zoom": 12,
"max_zoom": 15
}
Response 200:
{
"tile_count": 1260,
"estimated_size_mb": 30.24,
"warning": "Estimate assumes average compressed tile size."
}
Download
POST /mobile-api/v1/map-fragments/download/
Generates and returns a ZIP file containing tiles for the given bounding box and zoom range.
Request body is identical to the estimate endpoint.
Response headers:
- Content-Type: application/zip
- Content-Disposition: attachment; filename="fragment_z12-15.zip"
- X-Included-Tiles: 1245
- X-Missing-Tiles: 15
The ZIP contains:
- Tiles at {zoom}/{x}/{y}.png
- manifest.json with download metadata:
{
"tile_url_template": "https://tiles.example.com/{z}/{x}/{y}.png",
"bbox": {
"min_lat": -34.2,
"min_lon": 150.8,
"max_lat": -33.7,
"max_lon": 151.4
},
"min_zoom": 12,
"max_zoom": 15,
"tile_count": 1260,
"included_tiles": 1245,
"missing_tiles": 15
}
| Field | Type | Required | Notes |
|---|---|---|---|
| min_lat | float | yes | -90 to 90 |
| min_lon | float | yes | -180 to 180 |
| max_lat | float | yes | must be > min_lat |
| max_lon | float | yes | must be > min_lon |
| min_zoom | int | yes | 0–18 |
| max_zoom | int | yes | must be >= min_zoom |
Error 413 if tile count exceeds MOBILE_API_MAX_FRAGMENT_TILES:
{
"error": "Requested fragment exceeds max tile count.",
"tile_count": 50000,
"max_allowed": 10000
}
Tracking Groups
Tracking groups allow multiple users to share real-time locations with each other. The group creator is the owner who approves or rejects join requests.
List My Groups
GET /mobile-api/v1/tracking-groups/
Returns groups created by the current client.
Response 200:
{
"groups": [
{
"id": 1,
"name": "Family Trip",
"pin": "91827364"
}
]
}
Create Group
POST /mobile-api/v1/tracking-groups/
Request:
{
"name": "Family Trip"
}
Response 200:
{
"group": {
"id": 1,
"name": "Family Trip",
"pin": "91827364"
}
}
A unique 8-digit PIN is generated for the group. Share this PIN with others so they can join.
Join Group
POST /mobile-api/v1/tracking-groups/join/
No authentication required (uses PINs for identity).
Request:
{
"user_pin": "48201735",
"group_pin": "91827364"
}
Response 200:
{
"membership": {
"id": 5,
"group_id": 1,
"group_name": "Family Trip",
"profile_name": "Rob",
"profile_pin": "48201735",
"status": "pending"
},
"message": "Join request submitted. Waiting for group owner approval."
}
Errors:
- 404 if user PIN or group PIN not found.
- Re-submitting after rejection resets status to pending.
List Group Members
GET /mobile-api/v1/tracking-groups/members/?group_id={id}
- Owner sees all memberships (pending, approved, rejected).
- Non-owner sees only approved members.
Response 200:
{
"group": {
"id": 1,
"name": "Family Trip",
"pin": "91827364"
},
"is_owner": true,
"members": [
{
"id": 5,
"group_id": 1,
"group_name": "Family Trip",
"profile_name": "Rob",
"profile_pin": "48201735",
"status": "approved"
}
]
}
Approve Membership
POST /mobile-api/v1/tracking-groups/approve/
Owner-only. Changes a membership status to approved.
Request:
{
"membership_id": 5
}
Response 200:
{
"membership": {
"id": 5,
"group_id": 1,
"group_name": "Family Trip",
"profile_name": "Rob",
"profile_pin": "48201735",
"status": "approved"
}
}
Error 403 if the caller is not the group owner.
Reject Membership
POST /mobile-api/v1/tracking-groups/reject/
Owner-only. Changes a membership status to rejected.
Request:
{
"membership_id": 5
}
Response and error behaviour identical to approve.
Location Updates
Post My Location
POST /mobile-api/v1/location/update/
Updates the current user's location (requires a profile).
Request:
{
"lat": -33.8688,
"lon": 151.2093
}
Response 200:
{
"updated": true
}
Error 400 if no profile exists:
{
"error": "Set your name first to enable location sharing."
}
Get Group Locations
GET /mobile-api/v1/tracking-groups/locations/?group_id={id}
Returns the latest location of all approved members and the group owner.
Requires the caller to be either the group owner or an approved member.
Response 200:
{
"group": {
"id": 1,
"name": "Family Trip",
"pin": "91827364"
},
"locations": [
{
"name": "Rob",
"pin": "48201735",
"lat": -33.8688,
"lon": 151.2093,
"updated_at": "2026-07-26T10:30:00+00:00"
}
]
}
Error 403 if the caller is not the owner or an approved member.
Error Format
All error responses use a consistent shape:
{
"error": "Human-readable error message."
}
Validation errors (from invalid request bodies) return 400 with field-level detail from Django REST Framework:
{
"lat": ["Ensure this value is greater than or equal to -90."],
"stop_type": ["\"invalid\" is not a valid choice."]
}
Mobile Integration Notes
- Persist
X-Client-ID— store the UUID in app secure storage and send it on every request. - Set up a profile early — call
POST /profile/with a display name. The returned PIN is the user's identity for login and group features. - PIN login for device transfer — use
POST /login/with the 8-digit PIN to restore a session on a new device. - Estimate before download — always call the estimate endpoint before triggering a tile download to show the user expected size.
- Parse
manifest.json— after unzipping a map fragment, read the manifest for included/missing tile counts. - Polling for locations — call
GET /tracking-groups/locations/?group_id=Xat a reasonable interval (e.g. every 30s) to update group member positions on the map. - Group join flow — user shares their user PIN + the group PIN. After calling join, the request is pending until the group owner approves via the approve endpoint.