# TypeScript SDK

`@wherebox/sdk` is a small typed client for the HTTP API. It builds `<img>` URLs, signs them for the hosted service, renders images, and turns every error into one class you can branch on by `code`. The map spec types and validation are built in, its only dependency is `zod`, and it uses only `fetch` and `globalThis.crypto.subtle`, so the same code runs in browsers, Deno, Bun, Cloudflare Workers and Node 20 or newer. It ships as ESM and CommonJS with type declarations.

The SDK adds no parameters of its own. The map you describe is the [`MapSpec`](/docs/reference/request-body.md) JSON body, and the SDK turns it into query parameters when you ask for a URL.

## Install

```bash
npm install @wherebox/sdk
```

`pnpm add @wherebox/sdk`, `yarn add @wherebox/sdk` and `bun add @wherebox/sdk` work the same way. Inside this repository's workspace, use `pnpm add "@wherebox/sdk@workspace:*"` instead.

## Quick start

Create one client per server and reuse it. `render()` sends `POST /v1/map` and returns the image bytes with the final size, whether it came from the cache, and its `ETag`.

```ts
import { createClient } from '@wherebox/sdk';

const wherebox = createClient({
  baseUrl: 'https://maps.example.com',
  secretKey: process.env.WHEREBOX_SECRET_KEY, // only on servers; omit for an open self-hosted API
});

const { image, contentType, width, height, cached } = await wherebox.render({
  area: 'Czechia',
  pois: [{ place: 'Chřiby', marker: { shape: 'triangle' } }],
  width: 468,
  height: 280,
});
console.log(`${contentType} ${width}x${height}, ${image.byteLength} bytes, cached: ${cached}`);
```

That request, as the JSON body the SDK sends:

```json
{
  "area": "Czechia",
  "pois": [{ "place": "Chřiby", "marker": { "shape": "triangle" } }],
  "width": 468,
  "height": 280
}
```

`createClient` takes `baseUrl` and, depending on where the code runs, `secretKey`, or `publishableKey` with `signingSecret`. See [Authentication](/docs/guides/authentication.md) for what each key is. An optional `fetch` replaces the global one, which is handy for tests and instrumentation.

Besides `render()` the client has `renderJson()` (the `?response=json` body: a URL of the cached image plus metadata), `searchAreas()`, `searchPlaces()`, `getArea()` and `url()`.

## Embedding in an `<img>`

`url()` returns an absolute GET URL, with the format taken from the path extension. It is asynchronous because it may sign the URL.

```ts
import { createClient } from '@wherebox/sdk';

const wherebox = createClient({ baseUrl: 'https://maps.example.com' });

const src = await wherebox.url(
  { area: 'Czechia', pois: [{ place: 'Chřiby', marker: { shape: 'triangle' } }], width: 468, height: 280 },
  { format: 'webp' },
);
const html = `<img src="${src}" width="468" height="280" alt="Czechia, Chřiby highlighted">`;
console.log(html);
```

The same map as a GET request, which is what ends up in `src`:

```http
GET /v1/map.webp?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280
```

The spec is the same as in the quick start, so the same fields exist; the registry that parses GET parameters also writes them, which is why the round trip is exact. Some specs cannot be written as a query string: custom fonts, GeoJSON areas, more than one route, labels containing `|`. For those, `url()` throws `PostOnlyError` listing the offending fields instead of dropping them silently. Use `render()` or `renderJson()` for such maps.

## Signing on a server

The hosted service refuses unsigned URLs from browsers, because anything in a page is public. A **publishable key** (`pk_…`) goes in the URL, and a signature made with its **signing secret** proves the URL came from you (see [How to sign](/docs/guides/authentication.md#how-to-sign)). Give the client both and `url()` signs for you, optionally with an expiry:

```ts
import { createClient, signUrl } from '@wherebox/sdk';

const wherebox = createClient({
  baseUrl: 'https://maps.example.com',
  publishableKey: process.env.WHEREBOX_PUBLISHABLE_KEY,
  signingSecret: process.env.WHEREBOX_SIGNING_SECRET,
});

// Adds apikey, exp and sig. The URL stops working after an hour.
const src = await wherebox.url({ area: 'Czechia', width: 468, height: 280 }, { expiresIn: 3600 });

// Or sign a URL you already have; the output is identical to the server's own signUrl.
const signed = await signUrl(
  'https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280',
  process.env.WHEREBOX_PUBLISHABLE_KEY ?? '',
  process.env.WHEREBOX_SIGNING_SECRET ?? '',
  { expiresAt: new Date(Date.now() + 3600 * 1000) },
);
console.log(src, signed);
```

**Never ship `signingSecret` or `secretKey` to a browser.** Signing with `url()` is for servers and edge functions. In the browser, create the client without keys (or do not use it for signed maps at all) and use URLs that your backend signed. A secret key in a page is a leaked key.

### A Next.js route handler

A route handler that returns a signed URL for a name the page asks about. It uses the standard `Request` and `Response`, so it works in the App Router and, with the same two lines, in any framework built on web standards (an Express route does the same with `req.query` and `res.json`).

```ts
import { createClient, WhereboxError, PostOnlyError } from '@wherebox/sdk';

const wherebox = createClient({
  baseUrl: process.env.WHEREBOX_URL ?? 'https://maps.example.com',
  publishableKey: process.env.WHEREBOX_PUBLISHABLE_KEY,
  signingSecret: process.env.WHEREBOX_SIGNING_SECRET,
});

// app/api/map-url/route.ts
export async function GET(request: Request): Promise<Response> {
  const area = new URL(request.url).searchParams.get('area');
  if (!area) return Response.json({ error: 'area is required' }, { status: 400 });
  try {
    // Expires in an hour, so a leaked URL stops working.
    const src = await wherebox.url({ area, width: 468, height: 280 }, { expiresIn: 3600 });
    return Response.json({ src });
  } catch (err) {
    if (err instanceof PostOnlyError || err instanceof WhereboxError) return Response.json({ error: err.message }, { status: 400 });
    throw err;
  }
}
```

The page then calls `/api/map-url?area=Czechia` and puts the returned `src` into an `<img>`.

## Rendering to a file

`render()` gives you a `Uint8Array`. In Node, Deno and Bun you can write it straight to disk. The `format` option sets the image type, and `width` and `height` are the final pixel size, which can exceed `width × scale` when `neighborfit=extend` grew the image.

```ts
import { writeFile } from 'node:fs/promises';
import { createClient } from '@wherebox/sdk';

const wherebox = createClient({ baseUrl: 'http://localhost:3000' });

const map = await wherebox.render(
  { area: 'Czechia', neighbors: { show: 'flag+code' }, width: 468, height: 280, scale: 2 },
  { format: 'webp' },
);
await writeFile('czechia.webp', map.image);
console.log(`saved ${map.width}x${map.height} (${map.etag})`);
```

```json
{
  "area": "Czechia",
  "neighbors": { "show": "flag+code" },
  "width": 468,
  "height": 280,
  "scale": 2,
  "format": "webp"
}
```

Add `routingKey` or `providerKey` to the call options to send the `X-Routing-Key` or `X-Provider-Key` header for routed maps and commercial basemaps; the keys stay out of URLs and logs. `renderJson()` is the better choice when you want to store a link instead of bytes: its `url` points at the cached image and is immutable.

## Looking things up

`searchAreas()` and `searchPlaces()` return the matches directly, and `getArea()` returns one area by id. Their result types (`AreaSummary`, `PlaceSummary`) are exported.

```ts
import { createClient } from '@wherebox/sdk';

const wherebox = createClient({ baseUrl: 'https://maps.example.com', secretKey: process.env.WHEREBOX_SECRET_KEY });

const matches = await wherebox.searchAreas('Nová Ves', { limit: 5 });
for (const area of matches) console.log(area.id, area.adminLevel, area.context);

const first = matches[0];
if (first) {
  // Ids from the lookup endpoints are used as `osm:<id>` in a spec.
  const map = await wherebox.renderJson({ area: first.ref, width: 400, height: 300 });
  console.log(map.url);
}
```

## Error handling

Every non-2xx answer throws a `WhereboxError` built from the [problem+json body](/docs/guides/errors-and-limits.md#the-shape). Branch on `code`, the part of `type` after `#`; it is typed as the error catalog's codes, so a typo in a comparison does not compile. The other fields are `status`, `type`, `title`, `detail`, and, where the server sends them, `issues`, `candidates`, `suggestions` and `retryAfter` (seconds, from the `Retry-After` header). A response that is not a problem document (a proxy's error page, say) has the code `'unknown'`. Network failures are not wrapped; they surface as the `TypeError` that `fetch` throws.

```ts
import { createClient, WhereboxError, type MapSpec } from '@wherebox/sdk';

const wherebox = createClient({ baseUrl: 'https://maps.example.com', secretKey: process.env.WHEREBOX_SECRET_KEY });

async function renderWithRetry(spec: MapSpec, tries = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await wherebox.render(spec);
    } catch (err) {
      if (!(err instanceof WhereboxError)) throw err;
      if (err.code === 'ambiguous') {
        // Show err.candidates[i].context to a person, then retry with area: err.candidates[i].ref.
        console.log(err.candidates?.map((c) => `${c.id}: ${c.context}`));
      } else if (err.code === 'invalid-spec') {
        console.error(err.issues); // a bug in the request: [{ path, message }]
      } else if (err.code === 'not-found') {
        console.log(err.suggestions?.map((s) => s.name));
      } else if ((err.code === 'busy' || err.code === 'rate-limited') && attempt < tries) {
        await new Promise((resolve) => setTimeout(resolve, (err.retryAfter ?? 1) * 1000));
        continue;
      }
      throw err;
    }
  }
}

await renderWithRetry({ area: { query: 'Nová Ves', level: 8, country: 'CZ' }, width: 400, height: 300 });
```

```json
{
  "area": { "query": "Nová Ves", "level": 8, "country": "CZ" },
  "width": 400,
  "height": 300
}
```

Which codes exist and what they mean is in the [error reference](/docs/errors.md); for authentication failures (`unauthorized`, `forbidden`, `plan-limit`, `rate-limited`, `quota-exceeded`) see [Authentication](/docs/guides/authentication.md#errors).

## Edge runtimes

Nothing in the SDK needs Node, so a Cloudflare Worker, a Deno Deploy handler or a Bun server can sign URLs with the same code. Keep the keys in the platform's secrets, not in the source, and reuse the client between requests.

```ts
import { createClient } from '@wherebox/sdk';

interface Env {
  WHEREBOX_URL: string;
  WHEREBOX_PUBLISHABLE_KEY: string;
  WHEREBOX_SIGNING_SECRET: string;
}

export default {
  // Redirects /map?area=… to a freshly signed image URL.
  async fetch(request: Request, env: Env): Promise<Response> {
    const wherebox = createClient({
      baseUrl: env.WHEREBOX_URL,
      publishableKey: env.WHEREBOX_PUBLISHABLE_KEY,
      signingSecret: env.WHEREBOX_SIGNING_SECRET,
    });
    const area = new URL(request.url).searchParams.get('area') ?? 'Czechia';
    return Response.redirect(await wherebox.url({ area, width: 468, height: 280 }, { expiresIn: 86400 }), 302);
  },
};
```

The signature is computed with WebCrypto (HMAC-SHA256) and matches the server's byte for byte; a test signs dozens of awkward URLs (accents, spaces, `+`, repeated parameters, an existing `sig`) with both implementations and checks that the server accepts the SDK's output. A runtime without `crypto.subtle` gets a clear error from `url()` when signing, and unsigned URLs still work.
