# @navvo/sdk

The official **Navvo** server-side client library for Node.js (TypeScript). Typed, authenticated, retried, rate-limit-aware access to the [Navvo Web Service](https://docs.navvo.io): geocoding, search, places, routing, route optimization, traffic, incidents, weather, EV and transit.

This is the **headless integration client** — there is no map UI and no tile rendering. It wraps the REST API at `https://navvo.io/api/v1`.

- Zero runtime dependencies — uses the built-in global `fetch` (Node 18+).
- Dual **ESM + CJS** build with full `.d.ts` types.
- Typed request/response models with a `.raw` escape hatch on geocode results.
- Exponential backoff with full jitter, `Retry-After` aware.
- Typed error hierarchy for clean `instanceof` handling.
- Coordinate-order bugs are designed out: you always pass `{ lat, lng }`; the SDK serialises to whatever each endpoint expects.

## Install

```bash
npm install @navvo/sdk
# or: pnpm add @navvo/sdk / yarn add @navvo/sdk
```

Requires **Node 18+**.

## Authentication

Navvo API keys have the form `nvvo_<48 hex>` and are sent in the `x-api-key` header (the SDK does this for you — the key never goes in a URL or a log line). For browser-fetched map style/tile URLs, the key goes in the `?api_key=` query param instead; use [`client.styleUrl()`](#tiles--config) for that.

```ts
import { NavvoClient } from "@navvo/sdk";

// Explicit key:
const navvo = new NavvoClient({ apiKey: "nvvo_..." });

// Or read NAVVO_API_KEY from the environment:
const navvo2 = NavvoClient.fromEnv();
```

Keys are scoped (`search, geocode, places, routing, traffic, telemetry, tiles, imagery, geo, insights, optimize`). A request without the right scope returns `403`, surfaced as `NavvoForbiddenError`.

## Quickstart

```ts
import { NavvoClient } from "@navvo/sdk";

const navvo = new NavvoClient(); // NAVVO_API_KEY from env

// Geocode
const hits = await navvo.geocode("Abdali Hospital, Amman", { limit: 3 });
console.log(hits[0].lat, hits[0].lng, hits[0].displayName); // already numbers

// Search
const s = await navvo.search("coffee in Abdoun", { limit: 10 });
console.log(s.places?.items.map((p) => p.nameEn));

// Place detail → route to the recommended entrance
const place = await navvo.place("jordan-beans-cafe");
const dest = NavvoClient.routeTargetFor(place) ?? { lat: 31.95, lng: 35.91 };
const plan = await navvo.route({
  locations: [{ lat: 31.95, lng: 35.91, name: "Me" }, dest],
  costing: "auto",
});
console.log((plan.engine ?? plan)?.data?.trip?.summary?.time, "seconds");
```

## Configuration

```ts
const navvo = new NavvoClient({
  apiKey: process.env.NAVVO_API_KEY, // or omit to read the env var
  baseUrl: "https://navvo.io/api/v1", // override for staging/self-hosted
  timeoutMs: 15_000,                  // per-attempt timeout
  retry: {
    retries: 3,
    baseDelayMs: 500,
    factor: 2,
    jitter: true,
    maxDelayMs: 20_000,
    respectRetryAfter: true,
    retryUnavailable: false,          // also retry available:false envelopes
  },
  userAgent: "acme-dispatch/2.3",     // appended to the default UA
  defaultHeaders: { "x-trace-id": "…" },
  telemetry: {
    enabled: true,
    onResponse: (e) => metrics.histogram("navvo.latency_ms", e.durationMs, { path: e.path }),
    onRetry: (e) => metrics.inc("navvo.retry", { path: e.path, attempt: e.attempt }),
  },
});
```

Every method takes an optional last argument `{ signal, timeoutMs, headers }` for per-call cancellation and overrides:

```ts
const ac = new AbortController();
const p = navvo.list({ category: "cafe" }, { signal: ac.signal });
setTimeout(() => ac.abort(), 1000);
```

## Coordinates

You always supply points as **`{ lat, lng }`** (a `LatLng`). The SDK serialises to whatever each endpoint expects (`{ lon, lat }` for routing, WKT for place centers, `west,south,east,north` for bbox, …). Helpers:

```ts
import { parseWktPoint, decodePolyline } from "@navvo/sdk";

const center = parseWktPoint(place.center);        // "POINT(35.913 31.953)" → { lat, lng }
const line = NavvoClient.decodePolyline(legShape); // polyline6 → LatLng[]
```

`BBox` is `{ west, south, east, north }`.

## Methods

Every method returns a `Promise`. Examples below assume `const navvo = new NavvoClient()`.

### Tiles / config

```ts
await navvo.styles();                 // GET /tiles/styles
navvo.styleUrl("standard");           // browser style URL with ?api_key=
await navvo.mapConfig();              // GET /map/config
```

### Search

```ts
await navvo.search("pharmacy near me", { limit: 25, lang: "en", bbox });
```

### Geocoding (scope `geocode`)

```ts
await navvo.geocode("Amman", { limit: 5, near: { lat: 31.95, lng: 35.91 }, bbox }); // GET /geocode/search → result[]
await navvo.reverseGeocode(31.95, 35.91);          // GET /geocode/reverse → result | null
await navvo.geocodeBatch(["Amman", "Aqaba"], { locale: "ar" }); // auto-chunks ≤50
await navvo.reverseGeocodeBatch([{ lat: 31.95, lng: 35.91 }]);
```

### Places (scope `places`)

```ts
await navvo.list({ category: "cafe", openNow: true, minRating: 4.5, near, radiusKm: 3, sort: "rating", limit: 20 });
for await (const p of navvo.iterate({ category: "restaurants", limit: 100 })) { /* auto-pager */ }
await navvo.place("jordan-beans-cafe");            // detail, null on unknown slug
await navvo.thumb("jordan-beans-cafe");            // card thumb + open state
await navvo.facets({ q: "cafe" });                 // amenity facets
await navvo.placeCategories();                     // category taxonomy
await navvo.hours("jordan-beans-cafe", { at: "2026-03-20T08:00:00Z" });
await navvo.popularTimes("jordan-beans-cafe");
await navvo.attributes("jordan-beans-cafe");
await navvo.reviewsSummary("jordan-beans-cafe");
await navvo.photos("jordan-beans-cafe", { limit: 20 });
await navvo.nearby("jordan-beans-cafe", { radiusKm: 3, sameCategory: true });
await navvo.reachable({ origin: { lat: 31.95, lng: 35.91 }, minutes: 15, costing: "pedestrian" });

NavvoClient.routeTargetFor(place);                 // recommended entrance, else center
```

### Routing (scope `routing`)

```ts
// High-level, corridor-aware planner (recommended)
await navvo.route({
  locations: [{ lat: 31.95, lng: 35.91, name: "Warehouse" }, { lat: 31.99, lng: 35.95 }],
  costing: "truck",
  preference: "fastest",
  avoid: ["tolls", "unpaved"],
  optimize: true,
  truck: { heightM: 4.0, weightT: 18 },
  constraintsText: "Deliver to Customer A before 11:00.",
  includeLabels: false,
});

await navvo.simpleRoute({ locations: [a, b], costing: "auto", units: "kilometers" });
await navvo.matrix({ sources: [a, b], targets: [c], costing: "auto", units: "kilometers" });
await navvo.isochrone({ locations: [a], costing: "pedestrian", contours: [{ minutes: 5 }, { minutes: 10 }] });
await navvo.mapMatch({ shape: trace, costing: "auto", shapeMatch: "map_snap" });
await navvo.routingOptimize({ locations: [a, b, c, d], costing: "auto" }); // TSP reorder
await navvo.routingProfiles();
```

### Route optimization (scope `optimize`)

```ts
await navvo.optimizeHealth();
await navvo.optimize({ stops, numVehicles: 2, vehicleCapacities: [10, 10], demands: [0, 4, 3, 5], tier: "balanced" });
await navvo.reoptimize({ vehiclePositions, remainingStops, tier: "fast" });
await navvo.binpack({ itemWeights: [3, 5, 2, 8], binCapacities: [10, 10] });
await navvo.roster({ numDrivers: 4, numDays: 7, demand });

const { jobId } = await navvo.submitJob("routes", { tier: "best", numVehicles: 8, stops });
const done = await navvo.waitForJob(jobId, { timeoutMs: 120_000, pollMs: 1500 });
await navvo.job(jobId); // single poll
```

`stops[0]` is the depot. The SDK auto-raises the request timeout for the `best` tier.

### Traffic (scopes `routing` + `traffic`)

```ts
await navvo.trafficRoute({ locations: [a, b], costing: "auto" }); // POST /traffic/route
await navvo.trafficSummary(bbox);                                  // GET /traffic/summary
```

### Incidents / road edits / risk

```ts
await navvo.incidents({ bbox, type: "accident", limit: 100 }); // GET /incidents/geojson
await navvo.incidentTypes();
await navvo.reportIncident({ type: "accident", lat: 31.95, lng: 35.91 });
await navvo.roadEdits(bbox);
await navvo.routeRisk(geojsonGeometry);                         // POST /map/route-risk
```

### Weather / air quality

```ts
await navvo.weatherPoint(31.95, 35.91, { units: "metric" });
await navvo.weatherForecast(31.95, 35.91);
await navvo.weatherCities(bbox, 8);
await navvo.airQualityCities(bbox, 8);
```

### EV / transit

```ts
await navvo.evStations({ bbox, limit: 50 });
await navvo.transitStops(31.95, 35.91, 500);
await navvo.gbfs(31.95, 35.91, 500);
```

## Error handling

Every failure is a subclass of `NavvoError`:

```ts
import {
  NavvoRateLimitError,
  NavvoForbiddenError,
  NavvoServiceUnavailableError,
  NavvoValidationError,
} from "@navvo/sdk";

try {
  const hits = await navvo.geocode("Abdali Hospital, Amman");
} catch (e) {
  if (e instanceof NavvoRateLimitError) {
    console.warn(`rate limited, retry after ${e.retryAfter}s`);
  } else if (e instanceof NavvoForbiddenError) {
    console.error(`scope/quota problem: ${e.reason ?? e.message}`);
  } else if (e instanceof NavvoServiceUnavailableError) {
    console.error("geocoder upstream down — fall back");
  } else if (e instanceof NavvoValidationError) {
    console.error("bad request:", e.message);
  } else {
    throw e;
  }
}
```

| Error | Maps from |
| --- | --- |
| `NavvoConfigError` | bad/missing key, insecure URL, bad option (client-side) |
| `NavvoValidationError` | `400` / `422` |
| `NavvoAuthError` | `401` — no/unknown key |
| `NavvoForbiddenError` | `403` — revoked key / missing scope / quota (`.reason`, `.scope`) |
| `NavvoNotFoundError` | `404` |
| `NavvoRateLimitError` | `429` (`.retryAfter`, `.limit`, `.remaining`, `.resetAt`) |
| `NavvoServiceUnavailableError` | `502`/`503` **and** envelope `available:false` |
| `NavvoServerError` | other `5xx` |
| `NavvoTimeoutError` | per-attempt timeout exhausted |
| `NavvoNetworkError` | connection/DNS/TLS failure |
| `NavvoAbortError` | caller aborted via `AbortSignal` |

**Retry policy:** `5xx`, `408`, `429`, network errors and (opt-in) `available:false` are retried with exponential backoff + full jitter, honouring `Retry-After`. `400/401/403/404/409/422` are never retried.

**Soft-null contract:** `place()` is a soft-null getter — an unknown slug returns `null`, never throws. The API answers an unknown slug with a genuine HTTP `404`; `place()` catches that `NavvoNotFoundError` internally and returns `null` (other errors — auth, rate-limit, validation, outage — still throw). `thumb()` likewise returns a null-fielded object for an unknown slug.

## Notes on response shapes

- Geocode results have **numeric** `lat`/`lng` (the API returns strings; the SDK parses them) plus a `raw` object with the original fields.
- Routing endpoints return **HTTP 201** on success and wrap the routing-engine reply in an envelope; the SDK accepts both `200` and `201`.
- Place `center` and entrance `location` are WKT `POINT(lng lat)` strings — use `parseWktPoint` or `NavvoClient.routeTargetFor(place)`.

## License

MIT © Navvo. See [LICENSE](./LICENSE).

## Links

- API reference: https://docs.navvo.io/en/api-reference
- Geocoding, Search, Places, Routing, Optimization guides on https://docs.navvo.io
