On this page
Authentication (hosted service)
This guide applies to servers that run with WHEREBOX_AUTH=keys, such as the hosted service. A self-hosted server with the default WHEREBOX_AUTH=none is open and ignores everything below; the apikey, sig and exp parameters are accepted and skipped, so the same URLs work against both.
Every map request needs a key. There are two kinds, and which one you use depends on where the request is made.
Keys#
- A secret key (
sk_followed by 32 characters) goes in theAuthorization: Bearer sk_…header. Use it from your server. - A publishable key (
pk_followed by 32 characters) goes in theapikeyparameter of a signed URL. Use it for<img>tags in pages and emails.
Treat a secret key like a password. It authorizes any request for your account, so it must never appear in a URL, a web page, a mobile app or a log. If you put one in a URL, the server refuses the request with forbidden, and you should rotate the key, because URLs end up in server logs, browser history and Referer headers.
A publishable key is designed to be visible. It only works together with a signature, and the signature is what protects you. Each publishable key comes with a signing secret, which stays on your server and signs the URLs you embed in pages. Operators get both when they create the key (see admin commands). The token is shown once, so store it right away.
Publishable and secret keys are not interchangeable. A pk_ key in the Authorization header and a signature-less pk_ key in a URL are both refused with forbidden. Sending both a header and apikey is refused with unauthorized, because it is unclear which one you meant.
Server-to-server calls#
Send the secret key in the Authorization header. Everything else is the same as without authentication, including POST /v1/map and the lookup endpoints.
/v1/map.pngGET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&neighbors=flag+code&w=468&h=280curl -H "Authorization: Bearer $WHEREBOX_SECRET_KEY" \
"https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280" -o czechia.pngThe same header works for /v1/areas/search, /v1/places/search and /v1/areas/{id}. Images fetched by content hash (/v1/images/{key}), /openapi.json, /healthz and /playground are public.
Signed URLs for <img> embeds#
A browser cannot keep a secret, so an <img src> cannot carry an sk_ key. Use a publishable key and sign the URL on your server instead:
<img src="https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo" alt="Czechia">Why signatures are needed#
Anything in a web page is public. Without a signature, anyone could read your pk_ key from the page source and use it to render any map they like, and every render would count against your quota. With a signature, the pk_ key alone is useless. The signature is an HMAC over the whole request: the path and every parameter. It can only be produced by whoever holds the signing secret, and changing even one character of the URL invalidates it. A leaked URL shows the same map it always showed. It cannot be turned into a different area, a larger image or another endpoint (the sig parameter covers the path and all other parameters, including exp).
The signature does not make a URL secret. Anyone who has the URL can request that exact map until it expires, so use exp and allowed origins when that matters.
How to sign#
Take the URL without apikey and sig, then:
- Add
apikey=<your pk_ key>, andexp=<Unix seconds>if the URL should expire. - Build the canonical request: the path,
?, then every parameter exceptsig, sorted by name and then by value (UTF-16 code unit order), each written asencodeURIComponent(name)=encodeURIComponent(value)and joined with&. Repeated parameters stay repeated. - Compute
HMAC-SHA256of the canonical request, using the signing secret (hex-decoded to bytes) as the key. - Encode the digest as base64url without padding, keep the first 32 characters, and add it as
sig.
Two details matter. First, sign the decoded values. In a query string + and %20 both mean a space, and your canonical request must encode a space as %20 either way. A literal plus sign (%2B) stays %2B. Second, the order of parameters in the final URL does not matter, but nothing may be added, removed or changed after signing.
The reference lists the parameters you add: apikey, sig and exp.
Node.js with @wherebox/accounts#
Inside this repository (or any service that depends on the workspace package), use the helper the server itself uses. It accepts relative and absolute URLs and replaces an existing sig.
import { signUrl } from '@wherebox/accounts';
const url = signUrl(
'https://maps.example.com/v1/map.png?area=Zlínský kraj&w=468&h=280',
process.env.WHEREBOX_PUBLISHABLE_KEY,
process.env.WHEREBOX_SIGNING_SECRET,
{ expiresAt: new Date(Date.now() + 3600 * 1000) },
);Node.js without dependencies#
This is the same algorithm with only node:crypto. It is CommonJS; in an ES module use import { createHmac } from 'node:crypto' instead of the first line. The test suite runs this exact snippet against the server's verifier.
const { createHmac } = require('node:crypto');
function canonicalRequest(pathname, searchParams) {
const pairs = [...searchParams.entries()].filter(([name]) => name !== 'sig');
pairs.sort(([an, av], [bn, bv]) => (an < bn ? -1 : an > bn ? 1 : av < bv ? -1 : av > bv ? 1 : 0));
const query = pairs.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&');
return `${pathname}?${query}`;
}
/** url: absolute URL; signingSecret: the hex signing secret; expiresAt: optional Date. */
function signUrl(url, publishableKey, signingSecret, expiresAt) {
const u = new URL(url);
u.searchParams.delete('sig');
u.searchParams.set('apikey', publishableKey);
if (expiresAt) u.searchParams.set('exp', String(Math.floor(expiresAt.getTime() / 1000)));
const sig = createHmac('sha256', Buffer.from(signingSecret, 'hex'))
.update(canonicalRequest(u.pathname, u.searchParams))
.digest('base64url')
.slice(0, 32);
u.searchParams.set('sig', sig);
return u.toString();
}Python#
import base64
import hashlib
import hmac
import time
from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit
# Characters that JavaScript's encodeURIComponent leaves alone.
SAFE = "-_.!~*'()"
def _enc(text):
return quote(text, safe=SAFE)
def _utf16_key(pair):
# JavaScript compares strings by UTF-16 code units; Python compares code points.
return (pair[0].encode("utf-16-be"), pair[1].encode("utf-16-be"))
def canonical_request(path, pairs):
kept = sorted(((k, v) for k, v in pairs if k != "sig"), key=_utf16_key)
return path + "?" + "&".join(_enc(k) + "=" + _enc(v) for k, v in kept)
def sign_url(url, publishable_key, signing_secret, expires_at=None):
"""url: absolute URL; signing_secret: the hex signing secret; expires_at: optional Unix seconds."""
parts = urlsplit(url)
pairs = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k not in ("sig", "apikey")]
pairs.append(("apikey", publishable_key))
if expires_at is not None:
pairs = [(k, v) for k, v in pairs if k != "exp"] + [("exp", str(int(expires_at)))]
digest = hmac.new(
bytes.fromhex(signing_secret),
canonical_request(parts.path, pairs).encode("utf-8"),
hashlib.sha256,
).digest()
sig = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")[:32]
pairs.append(("sig", sig))
query = "&".join(_enc(k) + "=" + _enc(v) for k, v in pairs)
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, ""))
# signed = sign_url("https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280",
# PUBLISHABLE_KEY, SIGNING_SECRET, expires_at=time.time() + 3600)Checking a signed URL#
Signed URLs look like this. The signature below is only an illustration, so this request does not authenticate against a real server, but it parses like any other:
/v1/map.pngGET /v1/map.png?area=Czechia&poi=place:Chřiby&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGoNames with spaces and accents sign the same way. This one also expires:
/v1/map.pngGET /v1/map.png?area=Zlínský+kraj&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&exp=1798761600&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGoIf a signed URL is refused with invalid URL signature, the usual causes are a changed parameter, a different path (.png vs .jpg is a different path), signing with the wrong key's signing secret, or an encoding mismatch (+ signed as + instead of %20).
Expiring URLs#
Add exp (Unix seconds) before signing and the URL stops working at that moment, with a forbidden response (signed URL has expired). Because exp is part of the signature, nobody can extend it by editing the URL; you must sign a new one.
Use short lifetimes for maps in emails or one-off pages. For maps that stay on a page for a long time, remember that a new exp makes a new URL, so the browser cannot reuse its cached copy of the old one. Rounding exp to the next hour or day keeps the URL stable for long enough to be cached.
Allowed origins#
A publishable key can be limited to the sites that may embed it. The server compares the host of the Origin header (or the Referer header when there is no Origin) with the key's list. A pattern is either an exact host (example.com) or a subdomain wildcard (*.example.com, which matches www.example.com and a.b.example.com but not example.com itself). Look-alike hosts such as evil-example.com do not match.
Keep two things in mind. Browsers send these headers on their own, but a script or a curl command can send anything, so origins limit casual reuse on other websites and are not a replacement for signatures or expiry. And when a key has allowed origins, a request without either header is refused. If your page uses referrerpolicy="no-referrer" or a strict Referrer-Policy, the <img> request has no Referer and the map is refused; use strict-origin-when-cross-origin (the browser default) or leave the key without origins.
Rate limits and quotas#
Each account has a rate limit, in requests per minute, set by its plan and shared by all of its keys and connected apps. You can burst up to one minute's worth of requests; the allowance refills continuously. Every authenticated response carries the state:
X-RateLimit-Limit: requests per minute for the accountX-RateLimit-Remaining: requests left right nowX-RateLimit-Reset: when the allowance is full again (Unix seconds)
When it runs out you get 429 rate-limited with a Retry-After header (seconds). Wait that long. Limits are per account, so creating more keys does not raise them.
The quota is the number of fresh renders per calendar month (UTC) for the whole account. A plan can also have no quota at all. Cache hits are free: a request whose image is already in the cache does not count, and it still works after the quota is used up. The same goes for a request that arrives while an identical render is already running. A response that rendered a new image carries X-Quota-Remaining, the renders left this month. A cache hit has no such header because nothing was rendered. Renders with a road route (car, bike, foot) can also have their own, smaller monthly allowance. When the quota is used up, a request that would need a new render gets 429 quota-exceeded, while images that are already cached keep working. To make good use of the quota, keep your URLs stable (see caching), so repeated views of a map are hits.
Rate limits apply to every request, including lookups. Only map renders are metered against the quota.
Plans also limit the output size and whether server-side routing is included. A request over the plan's pixel limit, or one that asks for a routed profile (car, bike, foot) on a plan without routing, is refused with 403 plan-limit before any rendering starts. Straight and geodesic lines are always allowed. The plan definitions are operator settings (WHEREBOX_PLANS in the configuration reference). On the hosted service, which is free, the current numbers are on the fair use page.
Errors#
All failures are application/problem+json, as described in Errors and limits. Branch on type:
401unauthorized: no key, a malformed or unknown key, or both a header andapikey. Fix the request.403forbidden: the key or account is revoked or disabled, the signature is missing, wrong or expired, the origin is not allowed, a secret key was used in a URL, or a publishable key was used in the header. Retrying the same request will not help.403plan-limit: the image is larger than the plan allows, or routing is not included.429rate-limited: wait forRetry-After.429quota-exceeded: the monthly quota is used up. Cached maps still work.
const res = await fetch(url, { headers: { authorization: `Bearer ${process.env.WHEREBOX_SECRET_KEY}` } });
if (res.status === 429) {
const code = (await res.json()).type.split('#')[1]; // "rate-limited" or "quota-exceeded"
if (code === 'rate-limited') {
await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 1) * 1000));
// then retry
}
}Admin commands#
Operators manage accounts and keys with the CLI. It needs DATABASE_URL and the same WHEREBOX_AUTH_SECRET as the API (changing that secret invalidates every key and signing secret). Add --json to print JSON.
pnpm mm account create --email [email protected] --name "Acme"
pnpm mm account list
pnpm mm account plan <acc_id> plus # e.g. a higher allowance granted on request
pnpm mm account disable <acc_id> # and: enable
pnpm mm key create <acc_id> --kind secret --name backend
pnpm mm key create <acc_id> --kind publishable --name website --origin example.com --origin "*.example.com"
pnpm mm key list <acc_id>
pnpm mm key revoke <key_id>
pnpm mm usage <acc_id> --from 2026-11-01 --to 2026-11-30
pnpm mm sign "https://maps.example.com/v1/map.png?area=Czechia" --key pk_… --secret <signing secret> --expires 3600Creating a key prints the token once, and for a publishable key also its signing secret. Only a peppered hash of the token is stored, so a lost token cannot be recovered; create a new key and revoke the old one. Plan changes, disable and revoke reach a running API within about a minute, because it caches key lookups. usage lists renders, cache hits and bytes per key and day. sign is handy for checking your own signing code: the URL it prints must be identical in meaning to the one your code produces.