Qartas API Reference

Base URL: https://navigation.wslt.app — all endpoints below are relative to it. Verified against the service source (internal/api/handlers.go, internal/api/tiles.go, cmd/chafmaps-api/main.go, internal/engine/*) and against live responses on 2026-07-31.

Conventions

Auth — every endpoint requires authentication; there is no anonymous access. Two credential forms:

Endpoints Accepted credentials
JSON endpoints + staticmap (/autocomplete, /geocode, /reverse, /directions, /prefetch-directions, /place/{id}, /place-photos, /tile-token, /staticmap) X-API-Key: cmk_live_... header (or a platform JWT Authorization: Bearer ... — operator-internal)
Tile family (/tiles, /vector, /style, /glyphs, /sprites) X-API-Key header or ?t=<tile token> query parameter (token minted from your key via POST /maps/v1/tile-token)

See auth.md for details.

Envelope — JSON endpoints wrap results:

{"status":"success","data":{...},"message":"OK"}
{"status":"error","error":{"code":"CODE","message":"text","details":{...}?},"message":"text"}

Binary endpoints (tiles, glyphs, sprites .png, staticmap) return raw bytes on success; their parameter/upstream errors are a bare HTTP status with no body (auth-layer errors on those routes do use the JSON envelope).

Request correlation — every response carries X-Request-ID (either your well-formed UUID X-Request-ID request header echoed back, or a generated one). Quote it in support requests.

Limits — request bodies are capped at 1 MB; the whole request times out at 30 s (504 REQUEST_TIMEOUT) — both operator-configurable (CHAFMAPS_API_REQUEST_TIMEOUT_SECONDS, default 30). Per-minute rate limits and daily quotas: rate-limits-and-plans.md.

Zero coordinates — JSON body fields marked required below use binding validation that treats an absent field and a literal 0 identically: a coordinate of exactly 0 is rejected (400 VALIDATION_ERROR). Coordinates (0,0) ("null island") are never valid input anywhere on this API.

Service off-switch — if the engine is disabled on the server, every /maps/v1 route answers 503 MAPS_UNAVAILABLE.


GET /maps/v1/autocomplete

Search-as-you-type place suggestions.

Query parameters

Param Type Required Default Description
q string no* The search text. *Empty/whitespace q is not an error: instant 200 with empty predictions and source: "empty_query" (so per-keystroke callers can fire on deletes).
lat float no 0 Bias center latitude (user location).
lng float no 0 Bias center longitude. If lat and lng are both 0/absent, a per-country fallback city center is used from the caller's IP country (QA→Doha, SA→Riyadh, AE→Dubai, EG→Cairo, otherwise Amman).
country string no IP country, else "jo" ISO-3166 alpha-2 ranking bias (e.g. qa). A soft bias, never a hard filter — explicit other-country names still match. Invalid values fall back.
locale string no from Accept-Language (ar*ar, else en) Result-language hint: en or ar.
max_wait_ms int no 2000 (server env CHAFMAPS_API_AUTOCOMPLETE_MAX_WAIT_MS) Foreground wait budget, clamped to [200, 10000]. If the gather isn't done in time you get budget_expired: true and the gather keeps warming the cache for your next call.

Response — 200

{
  "predictions": [
    {
      "place_id": "g_0x151b5fb85d7981af:0x631c30c0f8dc65e8",
      "name": "Amman",
      "address": "Al-Matar Street, Amman",
      "distance_meters": 1200,
      "lat": 31.9543786,
      "lng": 35.9105776,
      "ownership_tier": "seed",
      "driver_verifications": 0
    }
  ],
  "source": "redis",
  "served_in_ms": 0,
  "budget_expired": false
}
Field Type Description
predictions[] array Suggested places, ranked.
predictions[].place_id string Source-prefixed place ID (g_ = Google-sourced, w_ = alternate source). Pass to /place/{id} unchanged — the server strips the prefix itself.
predictions[].name string Display name.
predictions[].address string Display address (may be empty).
predictions[].distance_meters int Distance from the bias center. Omitted when 0/unknown.
predictions[].lat / lng float Coordinates. Omitted when unknown.
predictions[].ownership_tier string Data-quality tier: seed | enriched | verified | owned.
predictions[].driver_verifications int Count of real-world confirmations.
source string Where the answer came from: a cache/gather tier (redis, postgres, ...), or empty_query (blank q), budget_expired (see below), empty (engine returned nothing).
served_in_ms int Server-side time to produce the answer.
budget_expired bool true = the wait budget elapsed before the gather finished — the empty result is not authoritative; retry shortly. false + empty predictions = a genuine no-match.

Errors

401 (auth), 429 RATE_LIMIT_EXCEEDED/MAPS_QUOTA_EXCEEDED, 503 AUTOCOMPLETE_BACKPRESSURE / AUTOCOMPLETE_UNAVAILABLE / AUTOCOMPLETE_DISABLED, 502 AUTOCOMPLETE_FAILED. See errors.md.


POST /maps/v1/geocode

Forward geocoding: free-text address → coordinates. (Implemented as autocomplete → top hit → place-details enrichment; if enrichment fails you still get the autocomplete hit's coordinates.)

Body

Field Type Required Description
address string yes (non-empty) The address/free text to resolve.

Response — 200

{
  "matched": true,
  "formatted_address": "Desert Highway, Amman",
  "place_id": "g_0x151b5402e149d31b:0x1b38522aafb395fc",
  "name": "Queen Alia International Airport (AMM)",
  "lat": 31.7216982,
  "lng": 35.9964563,
  "source": "chafmaps_autocomplete"
}
Field Type Description
matched bool false = no match found; every other field is then omitted.
formatted_address string Best-known address.
place_id string Source-prefixed place ID (may be absent).
name string Place display name.
lat / lng float Resolved coordinates.
source string chafmaps_place_details (details-enriched, most precise) or chafmaps_autocomplete (top autocomplete hit's own coordinates).

Errors

400 VALIDATION_ERROR (missing/empty address), 401, 429, 503 GEOCODE_BACKPRESSURE / GEOCODE_UNAVAILABLE / GEOCODE_DISABLED, 502 GEOCODE_FAILED.


POST /maps/v1/reverse

Reverse geocoding: coordinates → address.

Body

Field Type Required Description
lat float yes (non-zero) Latitude.
lng float yes (non-zero) Longitude.

Response — 200

{
  "lat": 31.9539,
  "lng": 35.9106,
  "formatted_address": "CAC - المركز الزراعي الشامل، عمّان",
  "place_id": "g_...",
  "source": "fresh"
}
Field Type Description
lat / lng float Echo of the request coordinates.
formatted_address string One flat display address (no street/city/postal component breakdown exists on this API). Empty when source is no_address.
place_id string Rarely present on reverse results; omitted when unknown.
source string redis | postgres | fresh (live gather) | approximate | no_address.
raw object Optional raw provider payload; usually omitted.

source: "no_address" is a real answer ("nothing is here" — open water, unmapped area), served 200. Do not treat it as a failure or retry it.

Errors

400 VALIDATION_ERROR, 401, 429, 503 REVERSE_BACKPRESSURE (Retry-After: 2) — momentary saturation, retry; 503 REVERSE_UNAVAILABLE (Retry-After: 5) — all sources failed, retry later; 502 REVERSE_FAILED.


POST /maps/v1/directions

Driving directions between two points, optionally via up to 4 waypoints.

Body

Field Type Required Default Description
origin_lat float yes (non-zero) Origin latitude.
origin_lng float yes (non-zero) Origin longitude.
dest_lat float yes (non-zero) Destination latitude.
dest_lng float yes (non-zero) Destination longitude.
coarse_origin bool no false Use a coarser (~110 m vs ~11 m) cache cell for the origin — improves cache hits when the origin is a moving vehicle. Destination stays full-resolution.
enrich_maneuvers bool no false Must stay false. true is rejected with 400 ENRICH_MANEUVERS_UNSUPPORTED (the OSRM enrichment pass doesn't exist on this service). maneuvers[] still appear whenever the routing tier natively returns them.
max_wait_ms int no none (server's 30 s request timeout applies) Wait budget. Honored when 0 < max_wait_ms < 30000. On expiry you get the degraded straight-line 200, not an error.
waypoints array no [] Up to 4 intermediate stops, each {"lat": .., "lng": ..}. Each must be in-range and not (0,0). Presence of any waypoint switches to the multi-leg response shape below.

Origin ≈ destination (and no waypoints) is rejected: 400 SAME_POINT.

Response — 200, three shapes

1. Normal route (no waypoints, routing succeeded):

{
  "routes": [
    {
      "label": "Fastest",
      "distance_meters": 10562,
      "duration_seconds": 1043,
      "live_traffic_duration_seconds": 1043,
      "polyline": "{\"coordinates\":[[35.9106,31.9539],...],\"type\":\"LineString\"}",
      "maneuvers": [ {"type":"left","instruction":"Turn left onto ...","distance_m":230.0,"duration_s":25.0,"street_name":"..."} ]
    }
  ],
  "distance_meters": 10562,
  "duration_seconds": 1043,
  "polyline": "<primary route's GeoJSON LineString, as a JSON string>",
  "source": "redis",
  "engine": "google",
  "ownership_tier": "seed",
  "served_at": "2026-07-31T22:24:07Z",
  "served_in_ms": 3
}
Field Type Description
routes[] array One or more route alternatives; routes[0] is primary.
routes[].label string Human label (e.g. "Fastest").
routes[].distance_meters / duration_seconds int Per-route totals.
routes[].live_traffic_duration_seconds int Traffic-aware ETA (omitted when unavailable).
routes[].polyline string A GeoJSON LineString serialized as a JSON string ([lng,lat] coordinate order). Parse it before drawing.
routes[].maneuvers[] array Turn-by-turn steps when the routing tier natively produced them: {type, instruction, distance_m, duration_s, street_name?}. Omitted otherwise.
routes[].free_flow_duration_seconds / typical_duration_seconds int Optional traffic context (best-case / typical-for-time-of-day).
routes[].traffic_segments[] array Optional per-segment traffic for polyline coloring: {"d": <meters from start>, "t": <seconds from start>, "l": <level>, "s": <speed km/h>}.
distance_meters, duration_seconds, polyline Primary route's values, duplicated top-level for simple callers.
source string Cache tier that served this: redis | postgres | fresh.
engine string Underlying routing engine (osrm | valhalla | graphhopper | waze | google), preserved across cache reads. Omitted when unknown.
ownership_tier string Data-quality tier of the cached route.
served_at / served_in_ms Serve timestamp / duration.

2. Degraded straight line (timeout or all-sources failure — still 200):

{
  "polyline": "{\"type\":\"LineString\",\"coordinates\":[[35.9106,31.9539],[35.8340,31.9720]]}",
  "distance_meters": 7423,
  "duration_seconds": 1856,
  "routes": [],
  "partial": true,
  "reason": "degraded",
  "source": "server_straight",
  "elapsed_ms": 8000
}

distance_meters is the straight-line (haversine) distance; duration_seconds is a rough estimate (distance ÷ 4 m/s, minimum 30 s).

🔴 Never render a partial: true polyline as a road route. Show distance/ETA if useful and re-fetch shortly — the gather keeps running server-side, so the retry is usually a cache hit. Detect this shape by partial == true or source == "server_straight".

3. Multi-leg (any waypoints present):

{
  "polyline": "{\"type\":\"MultiLineString\",\"coordinates\":[[[lng,lat],...],[[lng,lat],...]]}",
  "distance_meters": 14210,
  "duration_seconds": 1580,
  "legs": 2,
  "partial": false,
  "source": "maps_v1_multileg",
  "elapsed_ms": 950
}

polyline is a GeoJSON MultiLineString (one member per leg, serialized as a JSON string). partial: true + source: "maps_v1_multileg_partial" means at least one leg degraded to a straight line — the same "don't draw as a road" rule applies to that response. No routes[] array on this shape. Multi-leg totals are a route preview — do not use them as a pricing/fare anchor.

Errors

400 VALIDATION_ERROR / SAME_POINT / TOO_MANY_WAYPOINTS / BAD_WAYPOINT / ENRICH_MANEUVERS_UNSUPPORTED, 401, 422 DIRECTIONS_UNROUTABLE (no route exists between the coordinates — don't retry unchanged), 429, 503 DIRECTIONS_BACKPRESSURE (Retry-After: 5). Note: generic routing failures do not produce a 5xx — they produce the degraded 200 above.


POST /maps/v1/prefetch-directions

Fire-and-forget cache warm-up. Same body and validation as /directions (including the 4-waypoint cap and enrich_maneuvers rejection). The server validates, queues the gather(s) in the background, and answers immediately — you never wait for routing.

Response — 200

{"queued": true, "legs": 1}

or, when the server's bounded prefetch pool is saturated (not an error — the prefetch is a hint, not a promise):

{"queued": false, "reason": "prefetch_saturated"}

legs = 1 + number of waypoints. Background gathers are capped server-side (default 16 concurrent, 20 s timeout each; env CHAFMAPS_API_PREFETCH_MAX_CONCURRENT / CHAFMAPS_API_PREFETCH_TIMEOUT_MS).

Errors

Same validation errors as /directions; 401; 429.


GET /maps/v1/place/{id}

Full details for a place ID (from autocomplete/geocode).

Path & query parameters

Param In Required Default Description
id path yes Place ID. g_/w_ source prefixes are stripped server-side — pass IDs from autocomplete/geocode unchanged. After stripping, must match a known shape: ChIJ... (Google Place ID), 0x<hex>:0x<hex> (feature ID), or 12–30 hex chars (CID); otherwise 400 BAD_PLACE_ID.
lat query no 0 Coordinate hint (helps disambiguation).
lng query no 0 Coordinate hint.
q query no Display-name hint; used to fill name on a total cache miss in cache_only mode.
cache_only query no false true/1: answer from cache only within 400 ms — never triggers a live gather. On a miss you get a minimal record (place_id, name from q, lat/lng from hints) instead of an error.
provider query no Accepted for forward-compatibility; currently has no effect.

Response — 200

{
  "place_id": "0x151b5f96539bcc23:0x83d61bcf8e637e6d",
  "name": "Amman Citadel",
  "address": "Amman Citadel, K. Ali Ben Al-Hussein St. 146, Amman",
  "lat": 31.9543163,
  "lng": 35.9365046,
  "phone": "+962 6 463 8795",
  "category": "Amman Citadel",
  "rating": 4.5,
  "ownership_tier": "enriched",
  "driver_verifications": 0
}
Field Type Description
place_id string The unprefixed ID (the g_/w_ marker is not echoed back).
cid string Optional numeric CID.
name string Display name.
name_localized object Optional {"en": ..., "ar": ...} map.
address string Formatted address.
lat / lng float Coordinates (always populated on a full lookup).
phone, website, category, hours string Optional; omitted when unknown.
rating float Optional star rating.
rating_count int Optional review count.
photos[] array of string Optional direct photo URLs.
ownership_tier, driver_verifications Data-quality signals (see overview).

Errors

400 MISSING_ID / BAD_PLACE_ID, 401, 429, 503 PLACE_UNAVAILABLE (Retry-After: 5), 503 PLACE_DISABLED / PLACE_BACKPRESSURE, 502 PLACE_FAILED.


GET /maps/v1/place-photos

Photos for whatever place is at a coordinate (reverse-geocode → place-resolve → photos, composed server-side, best-effort).

Query parameters

Param Type Required Description
lat float yes Latitude (numeric; missing/non-numeric → 400 MISSING_COORDS).
lng float yes Longitude.

Response — 200

{"place_id": "g_0x151b5fe8b12bd0c7:0x974306aae2775390", "photos": ["https://lh3.googleusercontent.com/...", "..."]}
Field Type Description
place_id string The resolved place (may be "" when nothing resolved).
photos[] array of string Direct, fetchable image URLs (googleusercontent.com/ggpht.com). Empty is normal and common — most residential coordinates have no photos. Any miss along the chain answers 200 with photos: [], never an error.

Errors

400 MISSING_COORDS, 401, 429.


POST /maps/v1/tile-token

Mints the short-lived HMAC tile token that authorizes URL-parameter tile access. No request body.

Response — 200

{
  "token": "bWFwc2tleTpkYTRkMWU3Ni0...",
  "expires_at": "2026-07-31T22:24:16.837974336Z"
}
Field Type Description
token string Opaque base64url token. Append to tile-family URLs as ?t=<token>. Bound to the API key that minted it — revoking the key kills the token.
expires_at RFC3339 Expiry — 24 h after minting by default (operator-tunable via CHAFMAPS_TILE_TOKEN_TTL). Mint a fresh token before this (the Dart SDK refreshes at 80% of lifetime automatically).

Errors

401, 429, 500 TILE_TOKEN_FAILED (server misconfiguration), 503 MAPS_UNAVAILABLE.


GET /maps/v1/tiles/{z}/{x}/{y}[@2x|@3x].png

Raster basemap tiles (Web-Mercator XYZ scheme).

Path parameters & auth

Param Description
z Zoom, 0–20 (z19/z20 rendered on demand). Out of range → bare 400.
x, y Tile column/row; must lie within the 2^z grid → else bare 400.
@2x / @3x Optional DPR suffix on the y segment (e.g. /tiles/12/2456/1608@2x.png): @2x = 512 px, @3x = 768 px, none = 256 px.
extension Optional; default png. Accepted: png, jpg, jpeg, gif, webp (anything else → bare 400). The origin serves PNG in practice.

Auth: X-API-Key header or ?t=<tile token>. Anonymous → 401 AUTH_REQUIRED (JSON envelope).

Optional request header X-Chaf-Prefetch: <any value> marks speculative (pre-warming) traffic: cache misses are routed to a free prefetch pool, and a degraded stand-in tile becomes a 503 instead of an image (a prefetcher has no impatient viewer). Always send it from prefetch code paths.

Response — 200

Raw image bytes. Notable headers:

Header Meaning
Content-Type Sniffed real image MIME (e.g. image/png).
X-Tile-Source Where the tile came from: cache / memory / fresh / fresh-prefetch = verified content; ancestor-* etc. = degraded stand-in.
Cache-Control public, max-age=3600, stale-while-revalidate=86400 for verified tiles; no-store (plus CDN variants) for degraded stand-ins — never persist those.

Errors (bare status, no JSON body — except auth)

Status Meaning Retry?
400 Bad z/x/y or unsupported format. No — fix the URL.
401 Missing/invalid credential (JSON envelope: AUTH_REQUIRED, INVALID_TOKEN, TOKEN_EXPIRED, TOKEN_NOT_KEY_BOUND, API_KEY_REVOKED, INVALID_API_KEY). Re-auth / re-mint.
404 Origin has no such tile. No.
429 Per-IP tile rate cap (JSON envelope RATE_LIMIT_EXCEEDED, Retry-After). After Retry-After.
502 / 504 Transient upstream failure/timeout (Retry-After: 1 on 5xx). Yes, briefly deferred.
503 Service disabled, or degraded-tile-on-prefetch. Yes, later.

GET /maps/v1/vector/{source}/{z}/{x}/{y} — NOT OFFERED

Registered but not part of the product. On the live deployment it answers:

501 Not Implemented
{"status":"error","error":{"code":"VECTOR_TILES_NOT_SUPPORTED","message":"vector tiles are not offered by this service — the standalone Qartas API serves raster tiles only (tiles/@2x/@3x, style, glyphs, sprites, staticmap). ..."},"message":"..."}

Do not retry a 501. (The route exists so that a self-hosted operator could point CHAFMAPS_VECTOR_TILE_ORIGIN at their own tileserver-gl; the hosted service does not.)


GET /maps/v1/style/{name}

MapLibre GL JSON styles (version 8) whose tile/glyph URLs point back at this API.

Parameters & auth

Param In Required Description
name path yes raster-nav (generated raster style, @2x tile template), chafmaps (bundled on-disk style, @2x + ?t= template), or maplibre (alias for chafmaps). A .json suffix is accepted and stripped. Unknown names → bare 404.
token query no A tile token to embed into the returned style's tile URLs (raster-nav: appended as ?t=<token>; disk styles: substituted for the __TOKEN__ placeholder). Without it the style's tile URLs carry no token and tile fetches will 401 unless your renderer adds X-API-Key itself.

Note the two different query parameters: ?token= is what gets embedded in the style body; auth for fetching the style itself is X-API-Key or ?t= — same as the rest of the tile family. A typical map-widget call is:

GET /maps/v1/style/raster-nav.json?t=<tile token>&token=<same tile token>

Response — 200

Content-Type: application/json, Cache-Control: private, max-age=60. raster-nav additionally sets Access-Control-Allow-Origin: * (usable from MapLibre GL JS in a browser). Example (raster-nav):

{"version":8,"name":"Qartas SDK Raster Nav",
 "glyphs":"/maps/v1/glyphs/{fontstack}/{range}.pbf",
 "sources":{"g":{"type":"raster","tiles":["/maps/v1/tiles/{z}/{x}/{y}@2x.png"],"tileSize":256,"attribution":""}},
 "layers":[{"id":"g","type":"raster","source":"g"}]}

URLs are relative — they resolve against whatever host served the style (the operator can switch them to absolute via CHAFMAPS_API_PUBLIC_BASE_URL).


GET /maps/v1/glyphs/{fontstack}/{range}

SDF font glyphs for MapLibre text rendering.

Param In Description
fontstack path Font name; comma-separated candidates are tried in order (MapLibre sends "Font A,Font B"). Bundled: Noto Sans Regular. / or \ in either param → bare 400.
range path The glyph range including the .pbf suffix (matches the style template {range}.pbf), e.g. 0-255.pbf. Bundled ranges: 0-255, 256-511 (Latin), 1536-1791 (Arabic). Anything else → bare 404.

Auth: X-API-Key or ?t=. Response: application/x-protobuf, Cache-Control: public, max-age=2592000, immutable.


GET /maps/v1/sprites/{name}

Sprite stub for MapLibre's automatic sprite requests (the bundled styles use no icon layers, so a valid-empty answer is correct).

Request path ends in Response
.json (e.g. /sprites/sprite.json, /sprites/sprite@2x.json) 200 {} (application/json)
.png 200 67-byte transparent 1×1 PNG
anything else bare 404

Auth: X-API-Key or ?t=. Cache-Control: public, max-age=3600.


GET /maps/v1/staticmap

Server-rendered PNG of a route path over the basemap (stitched from real tiles + drawn polyline). This is a genuine per-call render, so it is credentialed like the JSON endpoints (X-API-Key header; a ?t= tile token is not accepted here) and billed to its own "tiles" daily quota bucket (see rate-limits-and-plans.md).

Query parameters

Param Type Required Default Description
path string yes Flat comma list lat,lng,lat,lng,...never semicolons. Non-numeric pairs are skipped; fewer than 2 valid points → bare 400.
w int no 600 Image width px; values ≤0 → default; capped at 1024.
h int no 260 Image height px; same clamping.

Response — 200

image/png bytes, Cache-Control: public, max-age=86400.

Errors

Bare 400 (bad path), 401 (envelope), 429, bare 502 (render failed — there is no degraded fallback shape for an image).


Health endpoints (no auth)

Endpoint Response
GET /livez 200 {"status":"ok"} — process is up (dependency-free).
GET /healthz 200/503 {"postgres":"ok","redis":"ok","chafmaps_wired":true} — dependency health; string fields carry an error message instead of "ok" on failure.

← Documentation