One static map image per open house, with every stop labelled

Generate a labelled static map for every Sunday open-house tour in one REST call. No tile server, no JS map library, no client-side rendering.

| June 02, 2026
One static map image per open house, with every stop labelled

Every Sunday tour sheet is the same story. An agent exports a list of open houses from the MLS, pastes it into a Word template, and either leaves the map blank or screenshots something from a consumer maps app — an act that ranges from legally grey to outright prohibited by the platform's terms of service. The buyer unfolds it on the passenger seat, reads "11:00am – 47 Elm Street, then 12:30pm – 82 Canary Lane" and has no spatial intuition for how those two stops relate to each other, how long the drive between them actually is, or whether the 1:45pm viewing at a third address is achievable without a time machine.

The fix is not hard. A static map image — one PNG, labelled with stop numbers, auto-zoomed to fit all pins, print-ready — costs one API call. This post shows exactly how to produce it, what the request shape looks like, how to wire it into your listing pipeline, and what the failure modes are before you hit them in production.

Why static maps and not a JavaScript widget

The embedded JS map widget is the obvious first answer when a product manager says "we need a map." It is also the wrong answer in at least four real-estate contexts where a static map image is strictly better.

Print. A JS widget does not render on a PDF, a printed flyer, an email client with images-only mode, or a CMA report opened in a browser with JavaScript disabled. A <img src="…"> tag renders in all of them. Tour sheets, flyers, CMA packages, and listing brochures all go to print or PDF. The correct data type for print is an image.

Open-house tour apps and kiosks. A React Native property-tour app often shows a mini overview map of the day's stops before navigating to each address individually. Generating that overview map server-side as a PNG and bundling it in the tour object payload is far simpler than loading a tile library into a mobile WebView, managing zoom and bounds state, and handling the edge case where the phone goes offline halfway through the tour.

Email deliverability. Marketing emails with embedded JS get quarantined by nearly every enterprise mail gateway. An inline image does not. A weekly "your saved searches have new open houses" digest email with a small map image of the listings has measurably higher engagement than one with a link that says "click to see a map."

Cost and latency. A JS widget loads a tile library (50–200 kB), makes one or more tile requests per viewport, and renders in the browser. A static map image is one HTTP call, one response, one <img> tag. For high-volume pipelines — tens of thousands of tour sheets generated weekly — the client-side rendering architecture does not scale; the server-side image approach does.

What the API returns

The Static Maps endpoint at GET /api/v1/staticmap returns a raw image binary — Content-Type: image/png or image/jpg depending on your format parameter. The request describes the map in terms of centre, zoom, and pins. For an open-house tour use case, the pattern is:

  • Pass each stop as a pin with a label (the stop number: "1", "2", "3" …)
  • Let the API auto-fit the viewport to all pins when you omit center and zoom
  • Request a size appropriate for the output format: 800x600 for screen, 1200x900 for print

The endpoint does not return JSON — the body is the image itself. Your application writes it to S3, an img tag, or a PDF stream directly. There is no separate "get the URL of the image" step; the image is the response.

A minimal curl call for a two-stop tour:

curl -s -o tour_sunday.png \
  "https://csv2geo.com/api/v1/staticmap?api_key=$KEY&size=800x600&format=png&pins=51.5074,-0.1278,1|51.5155,-0.0922,2"

That outputs a PNG with two numbered pins, auto-zoomed and auto-centred, ready to drop into an <img> tag. The pipe-separated lat,lng,label format is the entire pin syntax.

Anatomy of a tour-sheet map request

Before writing production code, it is worth understanding the parameters that matter for the open-house context.

`size` — width×height in pixels. For a half-page print flyer, 1200x900 at 150 dpi gives you an 8×6 inch print-quality image. For a mobile-app thumbnail, 400x300 is enough. For a full-width email hero, 600x300 works well and keeps the image under 100 kB.

`format`png or jpg. Use png for print and flyers (lossless, sharp text labels). Use jpg for email and app thumbnails (smaller file, acceptable quality for screen display at these sizes).

`pins` — the most important parameter for this use case. Each pin is lat,lng,label, multiple pins separated by |. The label becomes the visible callout on the map — for a tour sheet, 1, 2, 3 etc. maps cleanly to the numbered stops in the text list alongside the image.

`center` and `zoom` — optional. When omitted, the API fits the viewport to all pins with a reasonable margin. For most tour sheets, omit both and let the auto-fit handle it. Override only when the auto-fit produces a zoom level that loses useful neighbourhood context — which can happen when all pins cluster within two city blocks.

`width` and `padding` — optional margin control. A padding=40 (pixels) ensures no pin is clipped at the image edge. Default is 20 px, which is fine for 2–4 pins; bump it to 40 for 6+ pins to prevent label truncation at the image boundary.

HowTo: build the Sunday tour map pipeline

The following five steps take you from "a list of open-house addresses" to "a print-ready PNG attached to each tour document."

Step 1: geocode the address list

The Static Maps endpoint works with coordinates, not free-text addresses. Your first job is turning the MLS export into a list of (lat, lng, stop_number) tuples. Use the CSV2GEO geocoding endpoint with a batch or a simple loop:

import os
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]

def geocode_address(address: str) -> dict:
    r = requests.get(
        f"{API}/geocode",
        params={"q": address, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None
    best = results[0]
    return {"lat": best["lat"], "lng": best["lng"], "confidence": best["confidence"]}

tour_stops = [
    "14 Harrington Close, Bristol, BS1 4LG",
    "27 Redcliffe Way, Bristol, BS1 6SG",
    "9 Temple Back East, Bristol, BS1 6FN",
    "51 Victoria Street, Bristol, BS1 6AN",
]

coords = []
for i, address in enumerate(tour_stops, start=1):
    result = geocode_address(address)
    if result and result["confidence"] >= 0.7:
        coords.append({"stop": i, **result})
    else:
        print(f"WARN: stop {i} did not geocode confidently — verify manually")

Any stop with confidence below 0.7 gets flagged for manual review before you generate the map. A tour map with a misplaced pin is worse than a tour map with a gap — the gap is obvious, the misplaced pin sends the buyer to the wrong street.

Step 2: build the pins parameter

The pin string format is lat,lng,label per stop, joined by |. In Python:

def build_pins(coords: list[dict]) -> str:
    return "|".join(f"{c['lat']},{c['lng']},{c['stop']}" for c in coords)

pins = build_pins(coords)
# e.g. "51.4545,-2.5879,1|51.4499,-2.5920,2|51.4512,-2.5848,3|51.4481,-2.5991,4"

In Node:

function buildPins(coords) {
  return coords.map(c => `${c.lat},${c.lng},${c.stop}`).join('|');
}

Label values can be any short string — a number, a letter, or an emoji. For print, stick to single characters or two-character numbers: 1 through 9 render cleanly at label-pin size. Three-character labels (10, 11 …) are fine for screen; at small print sizes they start to crowd.

Step 3: call the Static Maps endpoint

One request, one image:

def generate_tour_map(pins: str, size: str = "1200x900", fmt: str = "png") -> bytes:
    r = requests.get(
        f"{API}/staticmap",
        params={
            "pins": pins,
            "size": size,
            "format": fmt,
            "padding": 40,
            "api_key": KEY,
        },
        timeout=30,
    )
    if r.status_code == 422:
        raise ValueError(f"Invalid parameters: {r.json()}")
    r.raise_for_status()
    return r.content   # raw image bytes

image_bytes = generate_tour_map(pins)
with open("tour_map_sunday.png", "wb") as f:
    f.write(image_bytes)

The same in Node with fetch:

async function generateTourMap(pins, size = '1200x900', fmt = 'png') {
  const params = new URLSearchParams({
    pins,
    size,
    format: fmt,
    padding: '40',
    api_key: process.env.CSV2GEO_API_KEY,
  });
  const r = await fetch(`https://csv2geo.com/api/v1/staticmap?${params}`);
  if (r.status === 422) {
    const err = await r.json();
    throw new Error(`Invalid parameters: ${JSON.stringify(err)}`);
  }
  if (!r.ok) throw new Error(`http ${r.status}`);
  return Buffer.from(await r.arrayBuffer());
}

The 422 case deserves explicit handling because it is the error you will hit during development — an out-of-range size string, a malformed pin coordinate, a missing api_key. It is distinct from a 5xx which you retry, and from a 401 which means your key is wrong. Build the three branches before you ship.

Step 4: attach the image to the tour document

Where the image lands depends on your stack. Three common patterns:

S3 + signed URL. Write the raw bytes to s3://your-bucket/tours/{tour_id}/map.png. Generate a presigned URL with a 7-day TTL. Embed the URL in the tour document JSON. When the agent opens the tour sheet in the CRM, the <img> tag loads the image. This is the right pattern for any volume above a few dozen tours per week — it separates generation (async, background job) from serving (CDN-speed, cached).

import boto3

s3 = boto3.client("s3")
bucket = "your-tours-bucket"
key = f"tours/{tour_id}/map.png"

s3.put_object(Bucket=bucket, Key=key, Body=image_bytes, ContentType="image/png")
url = s3.generate_presigned_url("get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=604800)

Inline base64 in HTML email. For email, encode the bytes as base64 and embed directly. Keeps the email self-contained — no broken images if the S3 bucket is ever restructured.

import base64

b64 = base64.b64encode(image_bytes).decode()
img_tag = f'<img src="data:image/png;base64,{b64}" width="600" alt="Open house tour map"/>'

PDF embedding. Libraries such as reportlab (Python) or pdfkit accept raw image bytes and position them on the page. The tour map occupies the top third of the sheet; the address list and times occupy the lower two-thirds. One A4 PDF, one print job, zero blank map rectangles.

Step 5: cache aggressively and regenerate on change

A tour document does not change after it is published. Once you have generated the map for tour T-20260601-AM, that PNG is the canonical image for that tour. Store it in S3 with Cache-Control: public, max-age=604800 and point your CDN at it. You are charged one credit at generation time. Every subsequent serving — to ten agents, to a hundred buyers, to a PDF generator — costs zero additional API credits.

Regeneration triggers are explicit events: a stop is added, a stop is removed, an address is corrected after a geocoding flag. Wire those events to a queue that invalidates the cached image and re-calls the API. Everything else is a cache hit.

The broader caching pattern — applicable to any geocoding or map result in your pipeline — is covered in depth in Caching Geocoding Results — 90% Cost Reduction. The principles apply directly here.

Handling edge cases in production

Three failure modes that will reach you eventually if you do not build for them first.

All pins in the same building. When a tour covers multiple units in the same condominium block, all coordinates resolve to essentially the same point. The auto-fit zoom level will be at maximum street detail, which is usually fine — the map shows the block clearly. But all pin labels will overlap. For same-building tours, generate a single pin with label "All stops" and list the unit schedule in the text below the map. Add a check: if the bounding box of all pins is smaller than 50 metres across, collapse to a single-pin map.

A tour spanning two disconnected geographies. An agent with listings in two suburbs 40 km apart will produce a map that auto-fits to show both, which means a zoom level where neither suburb is legible. Detect this: if the bounding box spans more than 15 km in either dimension, generate two maps (one per cluster) and label them "Morning tour" and "Afternoon tour." The API call cost doubles but the output is usable.

Geocoding failures mid-tour. If stop 3 of 5 fails to geocode confidently, generate the map for stops 1, 2, 4, and 5 and add a prominent "STOP 3 ADDRESS UNVERIFIED — DO NOT RELY ON MAP FOR THIS STOP" callout to the tour sheet. Do not silently drop the stop from the text list. An incomplete map with a warning is better than a silent omission.

The cost arithmetic

A real estate team generating 200 tour maps per Sunday — a reasonable volume for a mid-size urban brokerage — spends:

  • 200 geocoding calls per stop × average 4 stops = 800 geocoding credits
  • 200 Static Maps calls = 200 credits
  • Total: approximately 1,000 credits per Sunday, ~4,000 per month

At the entry paid tier ($54/month for 100,000 calls), 4,000 credits is 4% of the monthly budget. The rest of the budget absorbs listing enrichment, nearby-places lookups, and any batch geocoding jobs. Even a team with 1,000 Sunday tours per week stays well within mid-range pricing. See csv2geo.com/pricing/api for the current tier breakdown.

The free tier (3,000 calls/day, no credit card) is enough to run a full pilot — geocode a month's worth of tour addresses, generate all the maps, and validate the output quality before a purchasing conversation happens.

A note on labelling strategy

The numbered pin is the obvious choice, and it is right for most tour sheets. But labels can carry any short string, which opens a few variations worth considering.

Time labels instead of numbers. Pins labelled 11am, 12:30, 2pm give the buyer the sequence and the timing at a glance on the map, without needing to cross-reference the address list. Works well when the tour is a buyer's own itinerary rather than an agent-guided group tour.

Price-band labels. Pins labelled £450k, £520k, £490k let a buyer visually correlate location with price across the portfolio. Useful for a multi-property listing presentation, less useful for an individual buyer's tour sheet.

Bedroom-count labels. 3B, 4B, 5B — quick visual filter for a buyer comparing options across a neighbourhood.

Pick the label strategy based on what question the buyer is trying to answer at the moment they look at the map. The API is indifferent — the label field is a string, not an integer.

Connecting to elevation for the honest flood story

If any tour stops are in coastal or low-lying areas, the map image is not the whole story. A buyer looking at a house in a coastal suburb deserves to know whether the street sits at 1 m above sea level or 12 m. That number changes the insurance conversation, the mortgage conversation, and the buyer's decade-long risk picture.

The pattern is a one-line addition to the geocoding step: after resolving each stop's coordinates, call /api/v1/elevation with all the stops batched. Append the elevation figure to the text block next to each stop on the tour sheet. "Stop 2 — 82 Canary Lane — Elevation: 3 m" is a different product from a bare address. Miami returns approximately 1 m, Denver returns approximately 1,597 m — the numbers that come back from the API are real terrain heights, not placeholders, and they belong next to the address.

The full pattern for adding elevation to a property pipeline is covered in Adding Elevation to Property Data. The short version: batch all stops into one elevation call immediately after geocoding, add one column to your tour object, and render it as a human-readable delta ("3 m above sea level") in the tour sheet template.

For aerial imagery of each individual stop — roof condition, lot shape, neighbouring structures — the aerial image endpoint pattern is described in Per-Policy Roof and Terrain Snapshots Without Satellite Licenses. That post is insurance-framed but the technique is identical for a buyer's due-diligence pack.

Frequently Asked Questions

Can I use the generated image in a printed flyer without additional licensing?

Yes. The static map image the endpoint returns is generated from licensed tile data that covers the use cases described here, including print. You do not need a separate per-image license or a commercial arrangement beyond the API key. Store the image binary; the API response headers carry the relevant metadata if you ever need it for an audit.

What is the maximum number of pins I can pass in a single call?

The endpoint accepts up to 50 pins per request. A 50-stop tour map is unreadable at any reasonable image size, so the practical limit is much lower — 8 to 10 stops is about the most a human can parse on a printed sheet. For larger tours, split into logical sub-tours (morning, afternoon; by neighbourhood cluster) and generate one map per segment.

Does the auto-fit viewport always produce a usable zoom level?

For pins spread across a single neighbourhood (within roughly 5 km), yes — the auto-fit produces a zoom level that shows street-level detail with all pins visible. For pins spanning an entire city or beyond, the auto-fit zooms out to a regional view where individual streets are not legible. Use the cluster detection logic described in the edge-cases section and generate one map per cluster when the bounding box exceeds 15 km.

Can I control the map style — light, dark, satellite?

The endpoint supports a style parameter. Available values depend on the tile set configured for your account — check the API reference for current options. For print, a light style with high-contrast pin labels is almost always the right choice. For a dark-themed mobile app, the dark style avoids a jarring white rectangle in an otherwise dark UI.

The free tier gives 3,000 calls per day. How many tour maps is that?

At 4 stops per tour, a full tour pipeline (geocode 4 addresses + 1 static map call) costs 5 credits per tour. 3,000 daily credits supports 600 complete tour-map generations per day on the free tier — more than enough to validate the integration at real production volume before committing to a paid plan.

Do SDKs exist, or is REST the only option?

Python and Node SDKs are available. This post shows REST because the REST API is stable and version-independent — the curl and requests examples above will work regardless of SDK release cycles. For a production pipeline, wrapping the four-line requests.get call in your own thin client is typically all you need. If you prefer the SDK, it uses the same endpoint and parameter names; switching from REST to SDK is a one-hour refactor.

What if a geocoded coordinate lands in the sea or outside any road network?

The static map will render the pin at the coordinate regardless — it is a visual layer, not a routing system. The issue is upstream: the geocoder returned a low-confidence result or the address is genuinely ambiguous. The confidence check in Step 1 (flag anything below 0.7 for manual review) catches the majority of these cases before they reach the map-generation step. A pin in the sea on a tour map is a QA failure, not an API limitation.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →