Wherebox
On this page

Python SDK

wherebox is a small Python client for the API: it builds and signs map URLs for <img> tags, renders images to bytes or files, and looks up areas and places. It uses only the standard library and works on Python 3.9 or newer.

pip install wherebox

From a checkout of this repository (to try unreleased changes):

pip install ./sdk/python

Quick start#

from wherebox import Client

client = Client(
    "https://maps.example.com",
    secret_key="sk_…",                  # server-side calls (render, lookups)
    publishable_key="pk_…",             # embeds
    signing_secret="<hex signing secret>",
)

spec = {
    "area": "Czechia",
    "pois": [{"place": "Chřiby", "label": "Chřiby", "marker": {"shape": "triangle"}}],
    "neighbors": {"show": "flag+code"},
    "width": 468,
    "height": 280,
}

img_src = client.url(spec)              # a signed GET URL for <img src="…">
result = client.render(spec)            # PNG bytes, rendered by POST /v1/map

A spec is the JSON body of POST /v1/map as a Python dict: see the request body reference. The same spec works for url() and render(). Names, defaults and ranges are in the reference; the guides explain what each part does (locator maps, points and labels, routes).

Every key is optional. A self-hosted server without WHEREBOX_AUTH=keys needs none, and url() then returns an unsigned URL.

Signed URLs for embeds#

A page or email can only show a map through a URL, and a publishable key is only accepted with a signature. Client.url() builds the URL from the spec and signs it. Do this on your server: the signing secret must never reach a browser.

url = client.url(spec, format="webp", expires_in=3600)   # valid for an hour

expires_in is optional (seconds); the expiry is covered by the signature. Without it the URL works until you rotate the key. url() raises PostOnlyError when the spec uses something a URL cannot carry (shapes, a second route, a GeoJSON area, a custom font, a | in a label); its paths attribute lists the offending fields. Render those with client.render(), which uses POST.

To sign a URL you built yourself, use sign_url(url, publishable_key, signing_secret, expires_at=None). The output is identical to the server's own signUrl (a cross-language test checks it) and to the snippet in the authentication guide.

Django#

from django.shortcuts import render
from wherebox import Client

maps = Client("https://maps.example.com", publishable_key=settings.WHEREBOX_PK, signing_secret=settings.WHEREBOX_SIGNING_SECRET)

def office(request):
    src = maps.url({"area": "Czechia", "pois": [{"place": "Brno", "label": "Our office"}], "width": 468, "height": 280}, expires_in=3600)
    return render(request, "office.html", {"map_src": src})   # <img src="{{ map_src }}" width="468" height="280" alt="Map">

Flask#

from flask import Flask, redirect
from wherebox import Client

app = Flask(__name__)
maps = Client("https://maps.example.com", publishable_key=PK, signing_secret=SIGNING_SECRET)

@app.get("/maps/<city>.png")
def city_map(city):
    # Sign on demand and redirect: the browser fetches the image from Wherebox, not through your server.
    return redirect(maps.url({"area": "Czechia", "pois": [{"place": city}], "width": 468, "height": 280}, expires_in=300))

Rendering to a file#

from wherebox import Client

client = Client("https://maps.example.com", secret_key="sk_…")
result = client.render(spec, format="png")

with open("czechia.png", "wb") as f:
    f.write(result.image)

print(result.content_type, result.width, result.height, result.cached)

RenderResult has image (bytes), content_type, width and height (the final size from X-Image-Width / X-Image-Height, which can exceed width × scale when neighbor labels extend the canvas), cached (the server answered from its cache) and etag. client.render_json(spec) returns the JSON form instead: a durable url for the cached image, its key and size.

Keys for a commercial basemap or a routing provider go in headers, not in the spec, so they stay out of logs: client.render(spec, provider_key="…", routing_key="…"). See HTTP: headers.

Lookups share the client:

client.search_areas("Zlín", limit=5)    # list of dicts with id, name, adminLevel, context
client.search_places("Chřiby")
client.get_area("osm:r51684")

Errors#

Every failed request raises WhereboxError, built from the problem+json body:

Attribute Meaning
status HTTP status (0 when the server could not be reached; code is then network)
code The catalog code, e.g. "ambiguous"; see the error reference
type, title, detail The RFC 7807 fields; detail says what happened
issues invalid-spec: [{path, message}] per problem
candidates ambiguous: matching areas with id, adminLevel and context
suggestions not-found: close matches
retry_after Seconds from Retry-After (busy, rate-limited), else None
retryable True for busy and rate-limited

Branch on code:

from wherebox import WhereboxError

try:
    result = client.render({"area": "Nová Ves"})
except WhereboxError as e:
    if e.code == "ambiguous":
        for c in e.candidates:                       # pick one, then retry with area=c["id"]
            print(c["id"], c["context"])
    elif e.code == "not-found":
        print("did you mean:", [s["name"] for s in e.suggestions])
    elif e.code == "invalid-spec":
        for issue in e.issues:
            print(issue["path"], issue["message"])
    elif e.code in ("unauthorized", "forbidden"):
        raise RuntimeError("check the API key") from e
    else:
        raise

Retries on 503 and 429#

busy (503) and rate-limited (429) are transient: wait retry_after seconds and try again. quota-exceeded is also a 429, but it lasts until next month or until the limit is raised, so retryable is False for it.

import time
from wherebox import WhereboxError

def render_with_retries(client, spec, attempts=4):
    for attempt in range(attempts):
        try:
            return client.render(spec)
        except WhereboxError as e:
            if not e.retryable or attempt == attempts - 1:
                raise
            time.sleep(e.retry_after if e.retry_after is not None else 2 ** attempt)

Cached images never hit the render queue, so identical requests are cheap to repeat. See errors and limits for caching and ETags.