On this page
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 JSON body, and the SDK turns it into query parameters when you ask for a URL.
Install#
npm install @wherebox/sdkpnpm 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.
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:
/v1/map{
"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 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.
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:
/v1/map.webpGET /v1/map.webp?area=Czechia&poi=place:Chřiby|marker:triangle&w=468&h=280The 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). Give the client both and url() signs for you, optionally with an expiry:
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).
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.
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})`);/v1/map{
"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.
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. 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.
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 });/v1/map{
"area": { "query": "Nová Ves", "level": 8, "country": "CZ" },
"width": 400,
"height": 300
}Which codes exist and what they mean is in the error reference; for authentication failures (unauthorized, forbidden, plan-limit, rate-limited, quota-exceeded) see Authentication.
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.
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.