26 KiB
REST API Contract: Privacy-First Maps Application
Version: 1.0.0
Date: 2026-03-29
Base URL: Configured by user at first launch (e.g., https://maps.example.com)
General Conventions
Authentication
None. All endpoints are unauthenticated. No API keys, tokens, cookies, or sessions.
Content Types
- JSON responses:
Content-Type: application/json; charset=utf-8 - Protobuf tile responses:
Content-Type: application/x-protobuf - Binary download responses:
Content-Type: application/octet-stream - Style JSON:
Content-Type: application/json; charset=utf-8
Standard Error Response
All error responses use the following JSON format:
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description of the error."
}
}
Error Codes Reference
| HTTP Status | Error Code | Description |
|---|---|---|
| 400 | MISSING_PARAMETER |
A required query parameter is missing |
| 400 | INVALID_PARAMETER |
A parameter value is malformed or out of range |
| 400 | INVALID_BBOX |
Bounding box coordinates are invalid |
| 400 | INVALID_COORDINATES |
Route coordinates are invalid or out of range |
| 404 | NOT_FOUND |
The requested resource does not exist |
| 429 | RATE_LIMITED |
Request rate limit exceeded |
| 502 | UPSTREAM_ERROR |
An upstream service (Martin/Photon/OSRM) returned an error |
| 503 | SERVICE_UNAVAILABLE |
An upstream service is unreachable |
| 500 | INTERNAL_ERROR |
An unexpected server error occurred |
Common Response Headers
| Header | Value | Applies To |
|---|---|---|
Content-Type |
varies (see above) | All responses |
Cache-Control |
varies per endpoint | Cacheable responses |
X-Request-Id |
UUID v4 | All responses (for correlation, no PII) |
Access-Control-Allow-Origin |
* |
All responses |
Strict-Transport-Security |
max-age=63072000; includeSubDomains |
All responses |
1. Tile Serving
1.1 Get Vector Tile
Proxied to Martin tile server.
Request:
GET /tiles/{layer}/{z}/{x}/{y}.pbf
Path Parameters:
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
layer |
string | Yes | One of: openmaptiles, terrain, hillshade |
Tile layer name |
z |
integer | Yes | [0, 18] | Zoom level |
x |
integer | Yes | [0, 2^z - 1] | Tile column |
y |
integer | Yes | [0, 2^z - 1] | Tile row |
Success Response:
HTTP/1.1 200 OK
Content-Type: application/x-protobuf
Content-Encoding: gzip
Cache-Control: public, max-age=86400
ETag: "abc123"
Body: Gzip-compressed Mapbox Vector Tile (protobuf binary).
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | INVALID_PARAMETER |
z out of [0,18], or x/y out of range for zoom level |
| 404 | NOT_FOUND |
No tile data exists for these coordinates |
| 502 | UPSTREAM_ERROR |
Martin returned an error |
| 503 | SERVICE_UNAVAILABLE |
Martin is unreachable |
Caching behavior: Tiles are immutable between OSM data imports. The ETag changes when tile data is regenerated. Clients should use If-None-Match for conditional requests; the server returns 304 Not Modified when the tile has not changed.
1.2 Get Map Style
Request:
GET /tiles/style.json
No parameters.
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=3600
{
"version": 8,
"name": "Privacy Maps Default",
"sources": {
"openmaptiles": {
"type": "vector",
"tiles": ["/tiles/openmaptiles/{z}/{x}/{y}.pbf"],
"minzoom": 0,
"maxzoom": 14
},
"terrain": {
"type": "vector",
"tiles": ["/tiles/terrain/{z}/{x}/{y}.pbf"],
"minzoom": 0,
"maxzoom": 14
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#f8f4f0"
}
}
]
}
The full style document includes all layer definitions for roads, buildings, water, land use, labels, etc. The above is an abbreviated example. The mobile app uses this to configure MapLibre GL Native.
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 502 | UPSTREAM_ERROR |
Martin returned an error |
| 503 | SERVICE_UNAVAILABLE |
Martin is unreachable |
2. Search / Geocoding
2.1 Forward Search
Proxied to Photon.
Request:
GET /api/search?q={query}&lat={lat}&lon={lon}&limit={limit}&lang={lang}&bbox={bbox}
Query Parameters:
| Parameter | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
q |
string | Yes | — | Max 500 characters, control chars stripped | Search query text |
lat |
float | No | — | [-90.0, 90.0] | Latitude for proximity bias |
lon |
float | No | — | [-180.0, 180.0] | Longitude for proximity bias |
limit |
integer | No | 10 | [1, 20] | Maximum number of results |
lang |
string | No | en |
ISO 639-1 two-letter code | Preferred result language |
bbox |
string | No | — | minLon,minLat,maxLon,maxLat (valid coordinates, min < max) |
Bounding box filter |
Note: lat and lon must both be provided or both omitted. Providing only one returns 400 INVALID_PARAMETER.
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=300
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.9041, 52.3676]
},
"properties": {
"osm_id": 12345678,
"osm_type": "N",
"name": "Vondelpark",
"street": "Vondelpark",
"housenumber": null,
"postcode": "1071 AA",
"city": "Amsterdam",
"state": "North Holland",
"country": "Netherlands",
"country_code": "nl",
"type": "park",
"extent": [4.8580, 52.3585, 4.8820, 52.3620]
}
}
]
}
Feature properties schema:
| Field | Type | Nullable | Description |
|---|---|---|---|
osm_id |
integer | No | OpenStreetMap element ID |
osm_type |
string | No | "N" (node), "W" (way), or "R" (relation) |
name |
string | No | Place name |
street |
string | Yes | Street name |
housenumber |
string | Yes | House number |
postcode |
string | Yes | Postal code |
city |
string | Yes | City name |
state |
string | Yes | State or province |
country |
string | Yes | Country name |
country_code |
string | Yes | ISO 3166-1 alpha-2 country code |
type |
string | No | Place type (e.g., "park", "house", "street", "city") |
extent |
array | Yes | Bounding box [minLon, minLat, maxLon, maxLat] for area features |
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | MISSING_PARAMETER |
q parameter is missing |
| 400 | INVALID_PARAMETER |
lat/lon out of range, limit out of range, lang not a valid ISO 639-1 code, bbox malformed, only one of lat/lon provided |
| 502 | UPSTREAM_ERROR |
Photon returned an error |
| 503 | SERVICE_UNAVAILABLE |
Photon is unreachable |
2.2 Reverse Geocoding
Proxied to Photon.
Request:
GET /api/reverse?lat={lat}&lon={lon}&limit={limit}&lang={lang}
Query Parameters:
| Parameter | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
lat |
float | Yes | — | [-90.0, 90.0] | Latitude |
lon |
float | Yes | — | [-180.0, 180.0] | Longitude |
limit |
integer | No | 1 | [1, 5] | Maximum number of results |
lang |
string | No | en |
ISO 639-1 two-letter code | Preferred result language |
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=3600
Response body: same GeoJSON FeatureCollection format as forward search (Section 2.1).
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | MISSING_PARAMETER |
lat or lon missing |
| 400 | INVALID_PARAMETER |
lat/lon out of range, limit out of range |
| 502 | UPSTREAM_ERROR |
Photon returned an error |
| 503 | SERVICE_UNAVAILABLE |
Photon is unreachable |
3. Routing
3.1 Calculate Route
Proxied to OSRM.
Request:
GET /api/route/{profile}/{coordinates}?alternatives={n}&steps={bool}&geometries={fmt}&overview={detail}&language={lang}
Path Parameters:
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
profile |
string | Yes | One of: driving, walking, cycling |
Routing profile |
coordinates |
string | Yes | 2-7 coordinate pairs, semicolon-separated: {lon},{lat};{lon},{lat}[;...] |
Route waypoints (first = origin, last = destination, middle = intermediate stops) |
Coordinate constraints: Each lon in [-180, 180], each lat in [-90, 90]. Minimum 2 pairs, maximum 7 (origin + 5 waypoints + destination).
Query Parameters:
| Parameter | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
alternatives |
integer | No | 0 | [0, 3] | Number of alternative routes to return |
steps |
boolean | No | true |
true or false |
Include turn-by-turn steps |
geometries |
string | No | geojson |
One of: polyline, polyline6, geojson |
Geometry encoding format |
overview |
string | No | full |
One of: full, simplified, false |
Route geometry detail level |
language |
string | No | en |
ISO 639-1 two-letter code | Language for turn instructions |
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"code": "Ok",
"routes": [
{
"distance": 12456.7,
"duration": 1823.4,
"weight": 1823.4,
"weight_name": "routability",
"geometry": {
"type": "LineString",
"coordinates": [
[4.9041, 52.3676],
[4.9060, 52.3680],
[4.9100, 52.3700]
]
},
"legs": [
{
"distance": 12456.7,
"duration": 1823.4,
"summary": "Keizersgracht, Damrak",
"steps": [
{
"distance": 234.5,
"duration": 32.1,
"geometry": {
"type": "LineString",
"coordinates": [
[4.9041, 52.3676],
[4.9060, 52.3680]
]
},
"maneuver": {
"type": "turn",
"modifier": "left",
"location": [4.9041, 52.3676],
"bearing_before": 90,
"bearing_after": 0,
"instruction": "Turn left onto Keizersgracht"
},
"name": "Keizersgracht",
"mode": "driving",
"driving_side": "right",
"ref": "",
"intersections": [
{
"location": [4.9041, 52.3676],
"bearings": [0, 90, 180, 270],
"entry": [true, false, true, true],
"out": 0,
"in": 1
}
]
},
{
"distance": 0,
"duration": 0,
"geometry": {
"type": "LineString",
"coordinates": [[4.9100, 52.3700]]
},
"maneuver": {
"type": "arrive",
"modifier": null,
"location": [4.9100, 52.3700],
"bearing_before": 45,
"bearing_after": 0,
"instruction": "You have arrived at your destination"
},
"name": "Dam Square",
"mode": "driving"
}
]
}
]
}
],
"waypoints": [
{
"name": "Vondelstraat",
"location": [4.9041, 52.3676],
"hint": "..."
},
{
"name": "Dam Square",
"location": [4.8952, 52.3732],
"hint": "..."
}
]
}
Route object schema:
| Field | Type | Description |
|---|---|---|
distance |
float | Total route distance in meters |
duration |
float | Estimated travel time in seconds |
weight |
float | OSRM routing weight |
weight_name |
string | Weight metric name |
geometry |
GeoJSON LineString | Full route geometry |
legs |
array of Leg | One leg per pair of consecutive waypoints |
Leg object schema:
| Field | Type | Description |
|---|---|---|
distance |
float | Leg distance in meters |
duration |
float | Leg duration in seconds |
summary |
string | Human-readable summary of major roads |
steps |
array of Step | Turn-by-turn steps (if steps=true) |
Step object schema:
| Field | Type | Description |
|---|---|---|
distance |
float | Step distance in meters |
duration |
float | Step duration in seconds |
geometry |
GeoJSON LineString | Step geometry |
maneuver |
Maneuver | Maneuver details |
name |
string | Road name |
mode |
string | Travel mode for this step |
ref |
string | Road reference number (if applicable) |
driving_side |
string | "left" or "right" |
intersections |
array | Intersection details |
Maneuver object schema:
| Field | Type | Nullable | Description |
|---|---|---|---|
type |
string | No | One of: depart, arrive, turn, new name, merge, on ramp, off ramp, fork, end of road, continue, roundabout, rotary, roundabout turn, notification |
modifier |
string | Yes | One of: uturn, sharp right, right, slight right, straight, slight left, left, sharp left |
location |
[lon, lat] | No | Coordinate of the maneuver |
bearing_before |
integer | No | Bearing before the maneuver (0-359) |
bearing_after |
integer | No | Bearing after the maneuver (0-359) |
instruction |
string | No | Human-readable instruction text |
Waypoint object schema:
| Field | Type | Description |
|---|---|---|
name |
string | Nearest road name to the snapped waypoint |
location |
[lon, lat] | Snapped coordinate |
hint |
string | OSRM hint for faster subsequent queries |
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | INVALID_PARAMETER |
Invalid profile, alternatives out of range, invalid geometries/overview value |
| 400 | INVALID_COORDINATES |
Coordinates out of range, fewer than 2 pairs, more than 7 pairs, malformed format |
| 404 | NOT_FOUND |
OSRM could not find a route (e.g., coordinates on unreachable islands). OSRM code: "NoRoute" |
| 502 | UPSTREAM_ERROR |
OSRM returned an unexpected error |
| 503 | SERVICE_UNAVAILABLE |
OSRM instance for the requested profile is unreachable |
OSRM error code mapping:
OSRM code |
HTTP Status | API Error Code |
|---|---|---|
"Ok" |
200 | — |
"NoRoute" |
404 | NOT_FOUND |
"NoSegment" |
400 | INVALID_COORDINATES |
"TooBig" |
400 | INVALID_PARAMETER |
| Other | 502 | UPSTREAM_ERROR |
Caching: Route responses are NOT cached (Cache-Control: no-store). Routes depend on real-time road graph state, and caching by coordinates would have an extremely low hit rate.
4. Points of Interest
4.1 List POIs in Bounding Box
Served directly by the Rust gateway from PostGIS.
Request:
GET /api/pois?bbox={bbox}&category={categories}&limit={limit}&offset={offset}
Query Parameters:
| Parameter | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
bbox |
string | Yes | — | minLon,minLat,maxLon,maxLat. Valid coordinate ranges. maxLon > minLon, maxLat > minLat. Max area: 0.25 square degrees (~25km x 25km at equator) |
Bounding box to query |
category |
string | No | — (all categories) | Comma-separated. Valid values: restaurant, cafe, shop, supermarket, pharmacy, hospital, fuel, parking, atm, public_transport, hotel, tourist_attraction, park |
Category filter |
limit |
integer | No | 100 | [1, 500] | Maximum results |
offset |
integer | No | 0 | >= 0 | Pagination offset |
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=300
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.9041, 52.3676]
},
"properties": {
"osm_id": 987654321,
"osm_type": "N",
"name": "Cafe de Jaren",
"category": "cafe",
"address": {
"street": "Nieuwe Doelenstraat",
"housenumber": "20",
"postcode": "1012 CP",
"city": "Amsterdam"
},
"opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00",
"opening_hours_parsed": {
"is_open": true,
"today": "09:30 - 01:00",
"next_change": "Closes at 01:00"
},
"phone": "+31 20 625 5771",
"website": "https://www.cafedejaren.nl",
"wheelchair": "yes",
"tags": {
"cuisine": "dutch",
"outdoor_seating": "yes",
"internet_access": "wlan"
}
}
}
],
"metadata": {
"total": 247,
"limit": 100,
"offset": 0
}
}
Feature properties schema:
| Field | Type | Nullable | Description |
|---|---|---|---|
osm_id |
integer | No | OpenStreetMap element ID |
osm_type |
string | No | "N", "W", or "R" |
name |
string | No | POI name |
category |
string | No | Normalized category (one of the valid category values) |
address |
object | Yes | Address details |
address.street |
string | Yes | Street name |
address.housenumber |
string | Yes | House number |
address.postcode |
string | Yes | Postal code |
address.city |
string | Yes | City name |
opening_hours |
string | Yes | Raw OSM opening_hours value |
opening_hours_parsed |
object | Yes | Parsed opening hours (null if opening_hours is null or unparseable) |
opening_hours_parsed.is_open |
boolean | No | Whether the POI is currently open |
opening_hours_parsed.today |
string | Yes | Today's hours in human-readable format |
opening_hours_parsed.next_change |
string | Yes | When the open/closed status next changes |
phone |
string | Yes | Phone number |
website |
string | Yes | Website URL |
wheelchair |
string | Yes | "yes", "no", "limited", or null |
tags |
object | Yes | Additional OSM tags as key-value pairs |
Pagination metadata:
| Field | Type | Description |
|---|---|---|
total |
integer | Total number of POIs matching the query in this bbox |
limit |
integer | Limit used for this request |
offset |
integer | Offset used for this request |
To paginate, increment offset by limit until offset >= total.
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | MISSING_PARAMETER |
bbox not provided |
| 400 | INVALID_BBOX |
Malformed bbox, coordinates out of range, min >= max, area exceeds maximum |
| 400 | INVALID_PARAMETER |
Unknown category, limit out of range |
| 503 | SERVICE_UNAVAILABLE |
PostGIS is unreachable |
4.2 Get Single POI
Request:
GET /api/pois/{osm_type}/{osm_id}
Path Parameters:
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
osm_type |
string | Yes | One of: N, W, R |
OSM element type |
osm_id |
integer | Yes | Positive integer | OSM element ID |
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=3600
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.9041, 52.3676]
},
"properties": {
"osm_id": 987654321,
"osm_type": "N",
"name": "Cafe de Jaren",
"category": "cafe",
"address": {
"street": "Nieuwe Doelenstraat",
"housenumber": "20",
"postcode": "1012 CP",
"city": "Amsterdam"
},
"opening_hours": "Mo-Th 09:30-01:00; Fr-Sa 09:30-02:00; Su 10:00-01:00",
"opening_hours_parsed": {
"is_open": true,
"today": "09:30 - 01:00",
"next_change": "Closes at 01:00"
},
"phone": "+31 20 625 5771",
"website": "https://www.cafedejaren.nl",
"wheelchair": "yes",
"tags": {
"cuisine": "dutch",
"outdoor_seating": "yes",
"internet_access": "wlan"
}
}
}
Same property schema as Section 4.1 features.
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | INVALID_PARAMETER |
osm_type not one of N/W/R, osm_id not a positive integer |
| 404 | NOT_FOUND |
No POI found with the given type and ID |
| 503 | SERVICE_UNAVAILABLE |
PostGIS is unreachable |
5. Offline Data Packages
5.1 List Available Regions
Request:
GET /api/offline/regions
No parameters.
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=3600
{
"regions": [
{
"id": "amsterdam",
"name": "Amsterdam",
"description": "Amsterdam metropolitan area",
"bbox": [4.7288, 52.2783, 5.0796, 52.4311],
"size_mb": 95,
"last_updated": "2026-03-25T00:00:00Z",
"components": {
"tiles_mb": 55,
"routing_driving_mb": 12,
"routing_walking_mb": 10,
"routing_cycling_mb": 8,
"pois_mb": 10
}
},
{
"id": "netherlands",
"name": "Netherlands",
"description": "Full country coverage",
"bbox": [3.3316, 50.7504, 7.2275, 53.4720],
"size_mb": 1250,
"last_updated": "2026-03-25T00:00:00Z",
"components": {
"tiles_mb": 650,
"routing_driving_mb": 200,
"routing_walking_mb": 150,
"routing_cycling_mb": 150,
"pois_mb": 100
}
}
]
}
Region object schema:
| Field | Type | Description |
|---|---|---|
id |
string | Unique region identifier (URL-safe slug) |
name |
string | Human-readable region name |
description |
string | Region description |
bbox |
array | [minLon, minLat, maxLon, maxLat] |
size_mb |
integer | Total download size in megabytes |
last_updated |
string | ISO 8601 timestamp of last data update |
components |
object | Size breakdown per component |
components.tiles_mb |
integer | Tile package size (MB) |
components.routing_driving_mb |
integer | Driving OSRM data size (MB) |
components.routing_walking_mb |
integer | Walking OSRM data size (MB) |
components.routing_cycling_mb |
integer | Cycling OSRM data size (MB) |
components.pois_mb |
integer | POI database size (MB) |
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 500 | INTERNAL_ERROR |
Failed to read region metadata |
5.2 Download Region Component
Request:
GET /api/offline/regions/{region_id}/{component}
Path Parameters:
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
region_id |
string | Yes | Must match an id from the regions list |
Region identifier |
component |
string | Yes | One of: tiles, routing-driving, routing-walking, routing-cycling, pois |
Component to download |
Request Headers (optional, for resume):
| Header | Value | Description |
|---|---|---|
Range |
bytes={start}-{end} |
Request a byte range for pause/resume |
Success Response (full download):
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 57671680
Accept-Ranges: bytes
Content-Disposition: attachment; filename="amsterdam-tiles.mbtiles"
Cache-Control: public, max-age=86400
ETag: "region-amsterdam-tiles-20260325"
Body: binary file data.
Success Response (partial / resumed download):
HTTP/1.1 206 Partial Content
Content-Type: application/octet-stream
Content-Range: bytes 10485760-57671679/57671680
Content-Length: 47185920
Accept-Ranges: bytes
Body: requested byte range.
Downloaded file formats:
| Component | File Format | Description |
|---|---|---|
tiles |
MBTiles (SQLite) | Vector tiles for zoom levels 0-16 |
routing-driving |
Tar archive | OSRM driving profile data files (.osrm.*) |
routing-walking |
Tar archive | OSRM walking profile data files |
routing-cycling |
Tar archive | OSRM cycling profile data files |
pois |
SQLite database | POI data with FTS5 search index |
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 400 | INVALID_PARAMETER |
Invalid component name |
| 404 | NOT_FOUND |
Region ID not found or component not available |
| 416 | INVALID_PARAMETER |
Range not satisfiable |
| 500 | INTERNAL_ERROR |
Failed to read package file |
6. Health Check
6.1 System Health
Request:
GET /api/health
No parameters.
Success Response:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{
"status": "ok",
"version": "1.0.0",
"uptime_seconds": 86421,
"services": {
"martin": {
"status": "ok",
"latency_ms": 12
},
"photon": {
"status": "ok",
"latency_ms": 45
},
"osrm_driving": {
"status": "ok",
"latency_ms": 8
},
"osrm_walking": {
"status": "ok",
"latency_ms": 7
},
"osrm_cycling": {
"status": "ok",
"latency_ms": 9
},
"postgres": {
"status": "ok",
"latency_ms": 3
},
"redis": {
"status": "ok",
"latency_ms": 1
}
}
}
Top-level status values:
"ok"— all services are healthy."degraded"— one or more services are unhealthy but the system is partially functional."down"— critical services (postgres, martin) are unreachable.
Per-service status values:
"ok"— service responded within timeout."degraded"— service responded but slowly (> 2x typical latency)."down"— service did not respond within timeout.
Note: The health endpoint always returns 200 OK regardless of service status. The client should inspect the status field to determine overall health. This design allows load balancers to distinguish between "the gateway is running" (HTTP 200) and "upstream services are down" (status: degraded/down).
Error Responses:
| Status | Code | Condition |
|---|---|---|
| 500 | INTERNAL_ERROR |
The health check itself failed (should not happen in normal operation) |