Telemap Docs

Telemap Developer Docs

Geomate's own OSM/OSRM-based map platform for Nepal — routing, geocoding, and live traffic, self-hosted end to end, no third-party map vendor. This page covers everything built so far: how to call the API, how each feature works, and the JS SDK for embedding it in your own app.

Two paths to the API, on purpose. Each backend service has a live, interactive Swagger UI (/docs) for exploring requests directly against its own dev port — unauthenticated, local-only. Real integration traffic goes through the gateway instead, at http://localhost:8010, with an apikey header. Every example below uses whichever path is actually correct for that call.

What's running

Routing live

Route planning, realistic ETAs, multi-stop routes, and GPS map-matching across four vehicle profiles (car / bus / motorcycle / truck), each with real vehicle-dimension constraints.

routing-api reference →

Geocoding live

Address search, typeahead, and reverse geocoding over OpenStreetMap plus Geomate's own corporate/landmark POIs — Nepal-only, English and Nepali.

geocoding-api reference →

Live traffic live

Continuous GPS ingest, map-matching into road-segment speeds, a historical speed model, and a live traffic overlay on the map.

live-tracking reference →

Geofencing live

Real circular geofence zones checked against every GPS ping — built for Module C's location-triggered ad campaigns.

Geofencing guide →

Gateway & billing live

API keys, per-consumer rate limiting, usage metering, usage-based billing, and a free sandbox tier — the customer-facing layer in front of everything above.

Gateway reference →

JS SDK live

TelemapClient and TelemapMap — a zero-build JavaScript wrapper for the gateway and a MapLibre map with the live traffic overlay built in.

SDK reference →
ServiceDirect dev portGated behind gatewayWraps
routing-apihttp://localhost:8000/route, /route/stops, /eta, /matchOSRM (car/bus/motorcycle/truck graphs)
geocoding-apihttp://localhost:8001/search, /reversePhoton → Nominatim
live-trackinghttp://localhost:8002/traffic/segments, /vehicles, /geofences, /geofences/eventsrouting-api's /match
gateway (Kong)http://localhost:8010 — the actual customer-facing entrypointall three above

Not gated by the gateway on any service: fleet-side ingest (POST /ingest — a GPS tracker isn't a "customer") and ops endpoints (/health, /version, /alerts) — those stay reachable only on the internal Docker network or direct host ports.

Getting started

Three things you need before making a real call: a base URL, an API key, and a vehicle profile (for routing calls). This section walks through all three.

  1. Get an API key

    There's no self-serve signup yet — keys are issued by running a script against a live stack:

    ./scripts/issue-api-key.py acme-corp                      # standard tier, 60 req/min
    ./scripts/issue-api-key.py acme-corp-trial --tier sandbox  # free trial tier, 10 req/min, never billed

    This appends a new consumer to gateway/kong.yml and hot-reloads Kong's config — no restart needed. Just trying things out? Ask for a --tier sandbox key.

  2. Call the gateway, not a direct service port

    Every real request goes to http://localhost:8010 with your key in an apikey header (or query param):

    curl -H "apikey: YOUR_KEY" \
      "http://localhost:8010/route?profile=car&from=85.3240,27.6710&to=85.4298,27.6710"

    No key or a bad one returns 401. A missing/invalid profile, malformed coordinates, or a point too far from any mapped road returns 400 from the underlying service — see each endpoint's own error list below.

  3. Watch your rate limit

    Standard keys get 60 requests/minute; sandbox keys get 10/minute. Every response carries RateLimit-* and Retry-After headers, and going over the limit returns 429.

  4. Pick a vehicle profile

    Every routing call needs a profile: car, bus, motorcycle, or truck. Each is backed by its own OSRM graph with real vehicle dimensions (height/width/length/weight) checked against OSM tags — a bus and a car can legitimately get routed onto different roads, not just scored with different ETAs on the same route.

  5. Explore interactively (optional, dev-only)

    Each backend service serves its own Swagger UI directly, unauthenticated, for poking at requests without writing any code:

    These talk straight to the backend, bypassing the gateway entirely — fine for local exploration, not the path a real integration should use.

API versioning

Every endpoint on this page is implicitly v1 — unprefixed paths, no version header. Check which version a service is serving with GET /version on any of the three backends:

curl "http://localhost:8000/version"
# {"service": "routing-api", "api_version": "v1"}
Backward-compatible — no bumpBreaking — needs a version bump
A new endpoint.
A new optional query param.
A new optional response field.
A field appended to a response, existing fields untouched.
Removing or renaming a response field or endpoint.
Changing a field's type or meaning.
Tightening validation so a previously-valid request starts failing.
Changing an existing param's default behavior.

When a breaking change is actually needed, it lands as a new path prefix (e.g. /v2/route) introduced alongside the existing v1 endpoint, which stays live through a deprecation window — never a retroactive rename or an abrupt cutoff.

Guides

Task-oriented walkthroughs for each feature. For the exhaustive param-by-param contract, see the API reference below — these guides link into it.

Plan a route

GET /route plans between exactly two points for one vehicle profile:

curl -H "apikey: YOUR_KEY" \
  "http://localhost:8010/route?profile=car&from=85.3240,27.6710&to=85.4298,27.6710"
{"profile": "car", "distance_km": 11.906, "duration_min": 12.57, "geometry": null}
Heads up: duration_min comes straight from OSRM's way-class legal/design speeds, which run well above real mixed-traffic travel time. Pass geometry=true to get the route polyline back as GeoJSON for drawing on a map. For a realistic time estimate, use /eta instead. Full reference: GET /route.

Get a realistic ETA

GET /eta takes the same inputs as /route but blends real historical speeds (where the road segment has been driven before) with a static per-profile average as fallback — instead of OSRM's optimistic legal-speed duration:

curl -H "apikey: YOUR_KEY" \
  "http://localhost:8010/eta?profile=bus&from=85.3240,27.6710&to=85.4298,27.6710"
{
  "profile": "bus", "distance_km": 11.938, "osrm_duration_min": 14.52,
  "eta_min": 43.03, "static_eta_min": 44.77,
  "average_speed_kmh": 16.0, "historical_coverage_pct": 5.9
}

historical_coverage_pct tells you how much of the route's distance was actually backed by real driven history (vs. the static fallback) — a route with zero coverage returns eta_min == static_eta_min exactly. All three numbers (eta_min, static_eta_min, osrm_duration_min) are returned together so you can see how far apart they are. Full reference: GET /eta.

Multi-stop routes

For a route through more than two points — an office shuttle's pickup list, a freight pickup-then-delivery run — use GET /route/stops with 2–25 waypoints, visited in the order you give them:

curl -H "apikey: YOUR_KEY" \
  "http://localhost:8010/route/stops?profile=bus&waypoints=85.3157,27.6997;85.2803,27.6939;85.3127,27.7167"
{
  "profile": "bus", "stop_count": 3, "distance_km": 15.916, "duration_min": 20.07,
  "legs": [
    {"from_index": 0, "to_index": 1, "distance_km": 9.1, "duration_min": 11.59},
    {"from_index": 1, "to_index": 2, "distance_km": 6.816, "duration_min": 8.48}
  ],
  "geometry": null
}
Not a VRP solver. This is plain multi-waypoint routing — stops are visited in the given order, not optimized. Stop-order optimization is a roadmap item, not built yet. Full reference: GET /route/stops.

Map-match a GPS trace

POST /match snaps a sequence of noisy GPS pings onto the road network — the reverse problem from /route: given where a vehicle actually was, figure out which roads it drove.

curl -X POST "http://localhost:8010/match?profile=car" \
  -H "apikey: YOUR_KEY" -H "Content-Type: application/json" -d '[
  {"lon": 85.3240, "lat": 27.6710, "timestamp": 1700000000},
  {"lon": 85.3300, "lat": 27.6720, "timestamp": 1700000015},
  {"lon": 85.3360, "lat": 27.6730, "timestamp": 1700000030}
]'

A ping too far from any mapped road comes back "matched": false instead of forcing a bad snap. A trace can split into multiple matchings (e.g. across a large time gap), each with its own confidence score. Pass annotations=true to also get per-road-segment distance/duration/speed, timestamped by interpolation — this is exactly what live-tracking uses to build the traffic history behind /eta. Full reference: POST /match.

Forward search (free text → places) and reverse geocoding (coordinate → nearest place), over OpenStreetMap plus Geomate's own POIs:

curl -H "apikey: YOUR_KEY" "http://localhost:8010/search?q=Thamel&limit=5"
curl -H "apikey: YOUR_KEY" "http://localhost:8010/reverse?lat=27.7172&lon=85.3240"

Every result carries source: "osm" or "custom" — that's how you tell a GeoMate-added POI (corporate offices, depots, landmarks not in OSM) apart from plain OSM data. Custom POIs aren't writable through this API yet — they're seeded in Postgres and synced into search via a script, read-only from the API's point of view. /reverse returns result: null (not a 404) when nothing is found nearby — a normal outcome, not an error. Full reference: GET /search · GET /reverse.

Live vehicle tracking

A GPS tracker (or a simulator standing in for one) posts pings one at a time to POST /ingest/{vehicle_id} — this endpoint is fleet-side, not gated by the gateway, so it's called directly against live-tracking's own port:

curl -X POST "http://localhost:8002/ingest/bus-14?profile=bus" \
  -H "Content-Type: application/json" \
  -d '{"lat": 27.7000, "lon": 85.3200, "timestamp": 1786524200}'

Pings buffer per-vehicle in memory; a background loop flushes each vehicle's window to /match every ~20 seconds once it has at least 2 pings, and persists every matched road segment to Postgres. Check a vehicle's live status (buffer size, last flush outcome) through the gateway:

curl -H "apikey: YOUR_KEY" "http://localhost:8010/vehicles/bus-14"

Full reference: POST /ingest/{vehicle_id} · GET /vehicles.

Live traffic overlay

Recent matched road segments, as GeoJSON, straight off the persisted match history — this is what a map viewer polls to draw a color-coded live traffic layer:

curl -H "apikey: YOUR_KEY" "http://localhost:8010/traffic/segments?minutes=5"

Each feature carries speed_kmh — color it red under 15 km/h, orange 15–30, green above 30 (the same bands the reference map viewer and the JS SDK's TelemapMap use). The JS SDK wraps this whole flow (polling + rendering) in one call. Full reference: GET /traffic/segments.

Geofencing for ad triggers

Circular zones defined in Postgres — every ingested ping is checked against all of them (a real meter-radius check, not a flat-degrees approximation), and enter/exit transitions are logged. Built for Module C's location-triggered in-vehicle ad campaigns (e.g. "play this campaign when the bus is within 200m of the office").

curl -H "apikey: YOUR_KEY" "http://localhost:8010/geofences"
curl -H "apikey: YOUR_KEY" "http://localhost:8010/geofences/events"
A real ad-trigger consumer would act on each enter event (play that zone's campaign) — this service only detects and logs the transition. Zones themselves are read-only through the API; define/edit them directly in Postgres. Events are a bounded in-memory log (last 200), not durable history. Full reference: GET /geofences · GET /geofences/events.

Monitor pipeline health

GET /alerts flags three kinds of gap in the live-tracking pipeline: dead trackers (a vehicle that's stopped sending pings), match errors (a vehicle whose most recent flush to routing-api failed), and a stalled pipeline (nothing new persisted anywhere, independent of any one vehicle). It's an ops endpoint — not gated by the gateway, and detection-only, no built-in alerting channel:

curl "http://localhost:8002/alerts"

Wire the non-zero exit code of scripts/traffic-gap-check.py into cron or a monitoring system. Full reference: GET /alerts.

Embed the map (JS SDK)

The fastest path to a working map with search, routing, and live traffic in your own app:

import { TelemapClient, TelemapMap } from "@telemap/sdk";

const client = new TelemapClient({ apiKey: "YOUR_KEY" });
const results = await client.search("Thamel", { limit: 5 });

const telemap = new TelemapMap({ apiKey: "YOUR_KEY", container: "map", client });
telemap.traffic.setLiveEnabled(true); // polls trafficSegments() every 15s

No MapLibre import, no engine instance to configure — see the full SDK reference below, or the working example at sdk/example/ in the repo.

API reference

The complete contract for every endpoint across the three backend services, plus the gateway that sits in front of them. All three backends are FastAPI — interactive Swagger docs are auto-served at /docs on each direct port. routing-api and geocoding-api are stateless; live-tracking is not.

Gateway & auth

Source: gateway/kong.ymlKong in DB-less/declarative mode, port 8010 (proxy). This is the actual customer-facing entrypoint; everything documented under "routing-api" / "geocoding-api" / "live-tracking" below describes each service's own contract, reached in production through this gateway, not its direct port.

ConcernDetail
AuthEvery gated request needs an apikey header (or query param). No key or a bad one → 401.
Rate limit60 req/min per consumer, standard tier. 10 req/min on sandbox-tier keys. RateLimit-*/Retry-After headers on every response; 429 past the limit.
Issuing a key./scripts/issue-api-key.py <name> [--tier sandbox] — appends a consumer to kong.yml and hot-reloads Kong, no restart.
Usage meteringEvery gated request logged as one JSON line (consumer, route, status, latency). scripts/usage-report.py aggregates it per consumer.
Billingscripts/billing-report.py turns the usage log into a per-consumer bill — free monthly allowance, flat rate above it, sandbox never billed.
Sandbox tier--tier sandbox on key issuance — free, 10 req/min, excluded from billing. Same mechanism as a standard key, just a per-consumer rate-limit override.
CORSWide open (*) — no browser-based customer auth flow to protect yet.
What's gated/route, /route/stops, /eta, /match, /search, /reverse, /traffic/segments, /vehicles, /geofences, /geofences/events.
What's not gatedPOST /ingest (fleet-side), /health, /version, /alerts (ops) — internal Docker network / direct host ports only.

routing-api

Source: services/routing/. Base URL (direct): http://localhost:8000. One vehicle profile per request — car, bus, motorcycle, or truck — each backed by its own OSRM graph with real vehicle dimensions checked against OSM tags.

GET/routegated

Plan a route between two points.

ParamTypeRequiredNotes
profilestringyescar / bus / motorcycle / truck
fromstringyesorigin, "lon,lat"
tostringyesdestination, "lon,lat"
geometryboolnoinclude route polyline as GeoJSON (default false)
Example
curl "http://localhost:8000/route?profile=car&from=85.3240,27.6710&to=85.4298,27.6710"
{"profile": "car", "distance_km": 11.906, "duration_min": 12.57, "geometry": null}

Errors: 400 unknown profile, malformed/out-of-range coordinate, or a coordinate >20km from the nearest known road. 404 no route found. 502 OSRM backend unreachable.

GET/route/stopsgated

Route through 2–25 points, visited in the given order — not stop-order optimization.

ParamTypeRequiredNotes
profilestringyesone of the four profiles
waypointsstringyes2–25 "lon,lat" stops, ;-separated, in visit order
geometryboolnoinclude route polyline as GeoJSON (default false)
Example
curl "http://localhost:8000/route/stops?profile=bus&waypoints=85.3157,27.6997;85.2803,27.6939;85.3127,27.7167"
{
  "profile": "bus", "stop_count": 3, "distance_km": 15.916, "duration_min": 20.07,
  "legs": [
    {"from_index": 0, "to_index": 1, "distance_km": 9.1, "duration_min": 11.59},
    {"from_index": 1, "to_index": 2, "distance_km": 6.816, "duration_min": 8.48}
  ],
  "geometry": null
}

legs[i] is the hop from waypoints[from_index] to waypoints[to_index]; top-level distance_km/duration_min sum across all legs.

Errors: 400 unknown profile, fewer than 2 or more than 25 waypoints, a malformed waypoint, or any waypoint >20km from a known road. 404 no route found. 502 backend unreachable.

GET/etagated

Same inputs as /route (minus geometry) — returns a travel-time estimate blending real historical speeds with a static fallback.

ParamTypeRequiredNotes
profilestringyesone of the four profiles
fromstringyesorigin, "lon,lat"
tostringyesdestination, "lon,lat"
Example
curl "http://localhost:8000/eta?profile=bus&from=85.3240,27.6710&to=85.4298,27.6710"
{
  "profile": "bus", "distance_km": 11.938, "osrm_duration_min": 14.52,
  "eta_min": 43.03, "static_eta_min": 44.77,
  "average_speed_kmh": 16.0, "historical_coverage_pct": 5.9
}

eta_min walks the route's OSM node-pair microsegments and, per segment, uses a real historical speed where one exists, the static per-profile average (car 22, bus 16, motorcycle 26, truck 18 km/h) otherwise. historical_coverage_pct (0–100) is the share of route distance actually backed by real history.

Errors: same as /route.

POST/matchgated

Snap a sequence of live GPS pings onto the road network (map-matching).

ParamTypeRequiredNotes
profilestring (query)yesone of the four profiles
geometrybool (query)nomatched-segment polylines as GeoJSON (default false)
annotationsbool (query)noper-road-segment distance/duration/speed, timestamped (default false)
bodyJSON arrayyes≥2 pings, timestamps strictly increasing: {lon, lat, timestamp, accuracy_m?}
Example
curl -X POST "http://localhost:8000/match?profile=car" -H "Content-Type: application/json" -d '[
  {"lon": 85.3240, "lat": 27.6710, "timestamp": 1700000000},
  {"lon": 85.3300, "lat": 27.6720, "timestamp": 1700000015},
  {"lon": 85.3360, "lat": 27.6730, "timestamp": 1700000030}
]'
{
  "profile": "car",
  "points": [
    {"input_index": 0, "matched": true, "lon": 85.324206, "lat": 27.670902, "matching_index": 0},
    {"input_index": 1, "matched": true, "lon": 85.329716, "lat": 27.672237, "matching_index": 0},
    {"input_index": 2, "matched": true, "lon": 85.335616, "lat": 27.673113, "matching_index": 0}
  ],
  "matchings": [
    {"index": 0, "confidence": 0.62, "distance_km": 1.67, "duration_min": 3.56, "geometry": null, "segments": null}
  ]
}

A ping too far from any mapped road comes back "matched": false". A trace can split into multiple matchings across a large time gap, each with its own confidence (0–1). With annotations=true, each matching also gets a segments list, one entry per OSM node-to-node microsegment:

{"from_node": 1832396853, "to_node": 1496663340, "distance_m": 44.11, "duration_s": 2.9, "speed_kmh": 54.72, "observed_at": 1786524870.4}

observed_at is interpolated by apportioning the timespan between bounding pings across that leg's microsegments. This is what live-tracking uses to build traffic history.

Errors: 400 unknown profile, fewer than 2 pings, or non-increasing timestamps. 404 no match found. 502 backend unreachable.

GET/healthungated

Liveness check → {"status": "ok"}. Same shape on all three backends.

GET/versionungated

{"service": "routing-api", "api_version": "v1"}. See API versioning.


geocoding-api

Source: services/geocoding/. Base URL (direct): http://localhost:8001. Wraps Photon (which sits on Nominatim). Every query is logged for a weekly relevance-tuning report — nothing a caller needs to do.

GET/searchgated

Forward geocoding / autocomplete.

ParamTypeRequiredNotes
qstringyesfree-text query
limitintno1–50, default 10
langstringnoen or ne, default en
Example
curl "http://localhost:8001/search?q=Thamel&limit=2"
{
  "query": "Thamel",
  "results": [
    {
      "name": "Thamel", "lat": 27.7166578, "lon": 85.3127015,
      "osm_type": "place", "osm_value": "neighbourhood", "place_type": "locality",
      "source": "osm",
      "address": {"city": "Kathmandu", "state": "Bagmati Province", "country": "Nepal", "countrycode": "NP"}
    }
  ]
}

source is "osm" for OpenStreetMap data or "custom" for a GeoMate-added POI. Custom POIs aren't writable through this API — added to Postgres and synced in separately; this is search/read only.

Errors: 422 empty q or limit out of range. 502 Photon unreachable.

GET/reversegated

Coordinate → nearest place.

ParamTypeRequiredNotes
latfloatyes-90 to 90
lonfloatyes-180 to 180
langstringnodefault en
Example
curl "http://localhost:8001/reverse?lat=27.7172&lon=85.3240"
{
  "lat": 27.7172, "lon": 85.324,
  "result": {
    "name": "Nano Hana Garden &Thakali Chulo", "lat": 27.7176566, "lon": 85.3242651,
    "osm_type": "amenity", "osm_value": "restaurant", "place_type": "house",
    "source": "osm", "address": {"city": "Kathmandu", "country": "Nepal"}
  }
}

result is null (not a 404) if nothing is found near the coordinate — a normal outcome, not an error.

Errors: 422 lat/lon out of range. 502 Photon unreachable.

GET/healthungated

Liveness check.

GET/versionungated

See API versioning.


live-tracking

Source: services/live-tracking/. Base URL (direct): http://localhost:8002. Different shape from the two above: this ingests a continuous stream of GPS pings per vehicle, buffers them in memory, and periodically hands each vehicle's window to routing-api's POST /match. No real GeoMate fleet feed is wired up yet — a simulator stands in.

POST/ingest/{vehicle_id}ungated · fleet-side

Record one GPS ping for a vehicle.

ParamTypeRequiredNotes
vehicle_idstring (path)yesany identifier; tracked automatically on first ping
profilestring (query)noone of the four profiles, default car
bodyJSON objectyes{lat, lon, timestamp, accuracy_m?}
Example
curl -X POST "http://localhost:8002/ingest/demo-car-1?profile=car" \
  -H "Content-Type: application/json" \
  -d '{"lat": 27.7000, "lon": 85.3200, "timestamp": 1786524200}'
{"vehicle_id": "demo-car-1", "buffered_pings": 1}

Pings aren't matched synchronously — a background loop flushes each vehicle's buffer to /match every 20s (default), once it has at least 2 pings. Check /vehicles/{id} for last-flush visibility.

GET/vehicles /vehicles/{vehicle_id}gated

Current state for one or all tracked vehicles — buffer size and last flush outcome. Debug/demo visibility, not a stable data API.

Example
curl "http://localhost:8002/vehicles/demo-car-1"
{"profile": "car", "buffered_pings": 1, "last_flush_at": null, "last_match_confidence": null, "last_match_error": null, "current_geofences": []}

current_geofences is the list of geofence zone names this vehicle's most recent ping fell inside.

Errors: 404 from /vehicles/{vehicle_id} if that vehicle has never sent a ping.

GET/traffic/segmentsgated

Recent matched road segments as GeoJSON — the map viewer's live traffic overlay polls this. Capped at 5000 features.

ParamTypeRequiredNotes
minutesintnohow far back to look, 1–60, default 5
Example
curl "http://localhost:8002/traffic/segments?minutes=5"
{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "geometry": {"type": "LineString", "coordinates": [[85.400175, 27.674118], [85.400201, 27.674111]]},
    "properties": {"speed_kmh": 24.84, "profile": "car", "vehicle_id": "sim-car-1", "observed_at": "2026-08-12T10:55:47.9+00:00"}
  }]
}

GET/alertsungated · ops

Pipeline-gap detection: dead trackers, match errors, and a stalled pipeline. Detection only, no alerting channel.

Example
curl "http://localhost:8002/alerts"
{
  "checked_at": 1786552424.08, "gap_threshold_seconds": 90.0,
  "dead_trackers": [{"vehicle_id": "sim-car-1", "last_ingest_at": 1786552330.1, "seconds_since_ingest": 93.9}],
  "match_errors": [],
  "pipeline": {"last_observation_at": 1786552409.9, "seconds_since_last_observation": 14.2, "stale": false},
  "healthy": false
}

GET/geofencesgated

Configured geofence zones, for Module C's location-targeted ad triggers. Read-only — define/edit rows directly in Postgres.

Example
curl "http://localhost:8002/geofences"
[{"id": 1, "name": "GeoMate Head Office", "lon": 85.3157, "lat": 27.6997, "radius_m": 200.0, "metadata": {"ad_campaign": "hq-local-diner-promo"}}]

GET/geofences/eventsgated

Recent zone enter/exit transitions, most recent first.

ParamTypeRequiredNotes
limitintno1–200, default 50
Example
curl "http://localhost:8002/geofences/events"
[{"ts": 1786554678.1, "vehicle_id": "ad-geofence-demo", "zone_id": 1, "zone_name": "GeoMate Head Office", "event": "exit"}]

Bounded to the last 200 events in memory — a live feed, not persisted history.

GET/healthungated

Liveness check.

GET/versionungated

See API versioning.

JS SDK

Source: the sibling telemap-sdk repo, not this one. TypeScript, built with esbuild into two bundles — an ESM build for your own bundler and a self-contained browser IIFE build. The underlying map engine (MapLibre GL JS today) is never exposed — nothing in the public API accepts, returns, or requires a MapLibre type or instance; every argument/return value is a named type under the Telemap namespace (Telemap.Pin, Telemap.Route.Plan, Telemap.Vehicle.Position, …), never a plain object. That's deliberate: it means the engine can be swapped later without breaking anything built against this SDK.

TelemapClient

A thin, fully-typed fetch wrapper over the gateway contract.

import { TelemapClient, type Telemap } from "@telemap/sdk";

const client = new TelemapClient({
  apiKey: "YOUR_KEY",
  baseUrl: "http://localhost:8010", // default
});
MethodReturnsWraps
searchTelemap.Search.ResponseGET /search
reverseTelemap.Reverse.ResponseGET /reverse
routeTelemap.Route.PlanGET /route
routeStopsTelemap.Route.MultiStopPlanGET /route/stops
etaTelemap.EtaGET /eta
trafficSegmentsTelemap.Traffic.Segment[]GET /traffic/segments
vehicles / vehicleTelemap.Vehicle.StateGET /vehicles
geofences / geofenceEventsTelemap.Geofence.Zone[] / Event[]GET /geofences
whoamiTelemap.WhoamiGET /whoami
addPoi / listPois / getPoi / deletePoiTelemap.Poi.ItemPOI write API — public by default, visibility: "private" needs an entitled plan

Every call rejects with a TelemapApiError (carrying status and body) on a non-2xx response. Field names are camelCase throughout (distanceKm, not distance_km) — the client translates the wire format, so the TypeScript-facing surface reads like a real SDK, not a JSON pass-through.

TelemapMap

A map pre-configured with Telemap's own style, plus telemap.pins (markers — no MapLibre knowledge required), telemap.traffic (the live overlay), telemap.routes (drawing a planned route's geometry), telemap.vehicles (live tracking + animation), and telemap.geofences (zone rendering + enter/exit events).

import { TelemapMap, type Telemap } from "@telemap/sdk";

const telemap = new TelemapMap({
  apiKey: "YOUR_KEY",
  container: "map",       // element id or HTMLElement
  center: [85.3240, 27.7172], // default: Kathmandu
  zoom: 12,
  theme: "light",          // "light" | "dark"
});

const pin: Telemap.Pin = { id: "hq", lon: 85.3157, lat: 27.6997, label: "GeoMate HQ" };
telemap.pins.add(pin, { onClick: (p) => console.log("clicked", p.id) });

await telemap.traffic.setLiveEnabled(true, { minutes: 5, pollMs: 15000 });

// Needs geometry: true, or draw() throws a clear error instead of drawing nothing.
const plan = await client.route("car", [85.3157, 27.6997], [85.2803, 27.6939], { geometry: true });
telemap.routes.draw("hq-to-depot", plan); // fits the camera to it by default

// telemap.vehicles: bring-your-own-data (path 1) — no Telemap backend involved,
// the SDK just renders it, animating between hops and rotating to face travel.
telemap.vehicles.update("bus-14", { lat: 27.6997, lon: 85.3157, timestamp: Date.now() / 1000 });

// telemap.vehicles: Telemap-managed (path 2) — pings ingested elsewhere via
// client.ingest()/POST /ingest, polled back and fed into the same update().
telemap.vehicles.track("bus-14", { pollMs: 3000 });

// telemap.geofences: zones are global, read-only data -- one fixed overlay of
// real geodesic circles, plus a delivery hook for enter/exit events.
await telemap.geofences.show();
telemap.geofences.onEvent((e) => console.log(e.event, e.vehicleId, e.zoneName));

// ... later
telemap.destroy();

telemap.traffic.setLiveEnabled polls trafficSegments() on the given interval while enabled and color-codes each segment by speed (red <15, orange 15–30, green >30 km/h) — the same live overlay verified in the reference map viewer, just re-platformed behind the adapter. telemap.routes.draw() is id-keyed like pins (a dispatch-style caller can show more than one route at once) and accepts the direct result of client.route() or client.routeStops(). telemap.vehicles.update() is the shared rendering primitive for both paths in the vehicle-tracking model — bring-your-own-data calls it directly with zero backend involvement, and telemap.vehicles.track(vehicleId) (the Telemap-managed path) polls client.vehicle() and feeds each result into the same update(). Animation (position interpolation, heading) is engine-agnostic logic in the SDK itself, not the map engine. telemap.geofences.show() renders every zone from GET /geofences as one fixed overlay (real geodesic circles, not a zoom-dependent pixel radius) — global read-only data, so it's a single toggle like telemap.traffic, not id-keyed like pins/routes/vehicles. telemap.geofences.onEvent() is a delivery hook, not a rendering feature: it polls geofenceEvents() and calls your handler for genuinely new enter/exit transitions (the first poll seeds a baseline rather than replaying history). All five modules named in the original SDK v2 plan are now built.

Plain <script>, zero build step: <script src="telemap-sdk.iife.js"></script> is the only tag needed — no separate MapLibre script or stylesheet, both are inlined and injected automatically. Exposes a global Telemap object (const { TelemapClient, TelemapMap } = Telemap;).

Known gap: map tiles/style/glyphs are served from this same dev stack, not through the gateway or a public CDN — TelemapMap works today against the local dev stack only, not something a real external app could point at from elsewhere yet.

Full working example: sdk/example/index.html — search flies the map to a result, the traffic toggle renders live segments, a pin toggle demonstrates telemap.pins, two vehicle toggles demonstrate both tracking paths side by side, and a geofence toggle renders the real zones already configured in the stack with a live enter/exit event log.

Known limitations

Real gaps, documented rather than hidden — what this platform can't do yet.

AreaGap
OnboardingNo self-serve API key signup UI — keys are issued by running a script against a live stack.
BillingNo live Stripe integration — billing-report.py computes exactly what a real metered-billing call would need and stops there.
Map assetsTiles/style/glyphs aren't behind the gateway or a public CDN; fonts/sprites are pulled from public third-party hosts, not self-hosted.
RoutingNo pedestrian/walking profile. No alternative or connecting routes (no GTFS data).
Multi-stop/route/stops is not a VRP solver — no stop-order optimization.
Live dataPoll-only — no WebSocket/MQTT/GTFS-Realtime push feed. No real GeoMate fleet feed wired up yet (a simulator stands in).
Geofence eventsBounded in-memory log (last 200), not durable Proof-of-Play-style analytics storage.
Custom POIsNo PATCH /pois/{id} yet (create/read/delete only); /reverse doesn't merge private POIs the way /search does.
DogfoodingNo real production traffic or paid third-party (Google Maps/Mapbox) account exists yet to benchmark accuracy against — genuinely blocked on a real module launching, not a missing feature.

Build status by leg

LegFocusStatus
01Local dev environment, PostGIS, Nepal OSM importDone
02Custom vector tiles & cartographyDone
03OSRM routing, vehicle profiles, map-matching, ETA, load testDone
04Geocoding — Nominatim, Photon, synonyms, custom POIs, relevance loopDone
05Live GPS ingest, traffic history model, live overlay, gap alertingDone
06Module A/B/C integration reference clients, geofencing, versioningDone — dogfooding period genuinely blocked on a real module launch
07API gateway, auth, billing, developer docs, JS SDK, sandbox tierDone

See MAP_ENGINE_ROADMAP.md and each leg's section in README.md for the full detail behind each line.