Batch Map Export: Automation via API

2026-03-127 min read
APIbatchautomationPythonREST

Exporting a single map manually is easy. But what if you need to download 50 cities in GeoJSON, 200 neighborhoods in DXF, or update data daily for 10 regions? That's what the REST API with batch export support is for.

API Overview

The osm2cdr.ru API follows REST principles. Key endpoints:

Endpoint Method Description
/api/render POST Create an export task
/api/status/{task_id} GET Task status
/api/download/{task_id}/{format} GET Download result
/api/batch/export POST Batch export (a pack of areas)
/api/batch/status/{batch_id} GET Batch status
/api/batch/download/{batch_id} GET Download the batch archive
/api/formats GET List available formats

API access requires an API key, available from your dashboard. It goes in the X-API-Key header.

Single Export: curl

Let's start with a simple request to export one area. Note the shape of the body: bbox is an object with min_lon/min_lat/max_lon/max_lat, and formats is a list (1 to 5 formats at a time).

curl -X POST https://osm2cdr.ru/api/render \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bbox": {"min_lon": 37.59, "min_lat": 55.73, "max_lon": 37.65, "max_lat": 55.76},
    "formats": ["geojson"],
    "map_style": "standard",
    "detail_level": 4
  }'

The response carries a task id — the file itself arrives later, in a separate request:

{
  "task_id": "abc123-def456",
  "status": "pending",
  "created_at": "2026-08-07T13:56:03.726383Z",
  "formats": ["geojson"]
}

Checking status and downloading:

# Check status
curl -H "X-API-Key: YOUR_API_KEY" \
  https://osm2cdr.ru/api/status/abc123-def456

# Download result (when status = "completed"); the format is required in the path
curl -H "X-API-Key: YOUR_API_KEY" \
  -o moscow_center.geojson \
  https://osm2cdr.ru/api/download/abc123-def456/geojson

Batch Export: batch endpoint

The batch endpoint accepts an array of areas and returns a single batch_id for the whole pack. This is more efficient than sending requests one by one — the server optimizes execution and can reuse the PostGIS cache, and the result comes back as one archive.

Each item is its own area with its own list of formats; render settings go into that item's config.

curl -X POST https://osm2cdr.ru/api/batch/export \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {"bbox": {"min_lon": 37.59, "min_lat": 55.73, "max_lon": 37.65, "max_lat": 55.76}, "formats": ["geojson"]},
      {"bbox": {"min_lon": 30.28, "min_lat": 59.92, "max_lon": 30.36, "max_lat": 59.96}, "formats": ["geojson"]},
      {"bbox": {"min_lon": 37.59, "min_lat": 55.73, "max_lon": 37.65, "max_lat": 55.76}, "formats": ["dxf"]}
    ],
    "archive_format": "zip"
  }'

Response (HTTP 202):

{
  "batch_id": "b7f1c0e2-9a44-4f2b-8d31-0e5a1c9b7a20",
  "status": "pending",
  "items_total": 3,
  "items_completed": 0,
  "items_failed": 0
}

Batch limits:

Tier Areas per batch
Free not available
Hobby not available
Pro 5
Business 20
Enterprise 50

On top of that: up to 5 formats per area, and archive_format is either zip or tar.gz. Without authentication the batch endpoint answers 403 — anonymous batch export is closed.

Python Automation

Exporting a List of Cities

import requests
import time
from pathlib import Path

API_URL = "https://osm2cdr.ru/api"
API_KEY = "YOUR_API_KEY"
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}

# bbox order: [min_lon, min_lat, max_lon, max_lat]
cities = {
    "moscow": [37.32, 55.57, 37.95, 55.92],
    "saint_petersburg": [30.08, 59.83, 30.55, 60.09],
    "berlin": [13.08, 52.34, 13.76, 52.68],
    "paris": [2.22, 48.81, 2.47, 48.90],
    "london": [-0.35, 51.38, 0.15, 51.60],
}

def as_bbox(values: list) -> dict:
    """[min_lon, min_lat, max_lon, max_lat] -> the bbox object the API expects."""
    keys = ("min_lon", "min_lat", "max_lon", "max_lat")
    return dict(zip(keys, values))

def export_city(name: str, bbox: list, fmt: str = "geojson") -> str:
    """Create an export task and wait for the result."""
    resp = requests.post(f"{API_URL}/render", headers=HEADERS, json={
        "bbox": as_bbox(bbox),
        "formats": [fmt],
        "map_style": "standard",
        "detail_level": 4
    })
    resp.raise_for_status()
    task_id = resp.json()["task_id"]
    print(f"[{name}] Task created: {task_id}")

    for _ in range(120):
        status = requests.get(f"{API_URL}/status/{task_id}", headers=HEADERS).json()
        if status["status"] == "completed":
            break
        if status["status"] == "failed":
            raise RuntimeError(f"Export failed: {status.get('error')}")
        time.sleep(5)

    output = Path(f"output/{name}.{fmt}")
    output.parent.mkdir(exist_ok=True)
    dl = requests.get(f"{API_URL}/download/{task_id}/{fmt}", headers=HEADERS)
    output.write_bytes(dl.content)
    print(f"[{name}] Saved to {output} ({len(dl.content)} bytes)")
    return str(output)

for city, bbox in cities.items():
    export_city(city, bbox, "geojson")

Batch Export: one pack, one archive

Batch export works differently from a loop of single tasks: you submit a list of areas in one request, get one batch_id, and at the end pick up a single archive with all the files.

def batch_export(cities: dict, fmt: str = "shp", archive: str = "zip"):
    """Batch export via the batch endpoint: a pack -> an archive."""
    items = [
        {"bbox": as_bbox(bbox), "formats": [fmt]}
        for bbox in cities.values()
    ]

    resp = requests.post(f"{API_URL}/batch/export", headers=HEADERS, json={
        "items": items,
        "archive_format": archive
    })
    resp.raise_for_status()
    batch = resp.json()
    batch_id = batch["batch_id"]
    print(f"Batch created: {batch_id}, {batch['items_total']} items")

    for _ in range(240):  # up to 20 minutes
        st = requests.get(f"{API_URL}/batch/status/{batch_id}", headers=HEADERS).json()
        print(f"{st['items_completed']}/{st['items_total']} done, "
              f"{st['items_failed']} failed")
        if st["status"] in ("completed", "failed"):
            break
        time.sleep(5)

    suffix = "zip" if archive == "zip" else "tar.gz"
    path = Path(f"output/batch_{batch_id}.{suffix}")
    path.parent.mkdir(exist_ok=True)
    dl = requests.get(f"{API_URL}/batch/download/{batch_id}", headers=HEADERS)
    path.write_bytes(dl.content)
    print(f"Saved to {path} ({len(dl.content)} bytes)")
    return path

batch_export(cities, "shp")

In the status response, results carries one entry per area: item_index (its position in your original list), status, files and error — match the output back to your city list by item_index.

Parallel Polling for Multiple Tasks

For long-running tasks (large areas, complex formats) the easiest way to track progress is parallel polling of the REST endpoint /api/status/{task_id}. That is the path for a set of independent /api/render tasks; a pack created via /api/batch/export has its own aggregate status at /api/batch/status/{batch_id}. The WebSocket endpoint /ws/task/{task_id} exists only as a stub (it immediately closes with code 1001) and is reserved for a future implementation via Celery -> Redis Pub/Sub — do not rely on it in production scripts.

Basic single-task polling pattern:

import requests
import time

def poll_until_done(task_id: str, interval: int = 5, timeout: int = 600) -> dict:
    """Poll GET /api/status/{task_id} until completed/failed."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        st = requests.get(f"{API_URL}/status/{task_id}", headers=HEADERS).json()
        progress = st.get("progress", 0)
        step = st.get("step", "")
        print(f"[{task_id[:8]}] {st['status']} {progress}% — {step}")
        if st["status"] == "completed":
            return st
        if st["status"] == "failed":
            raise RuntimeError(f"Export failed: {st.get('error')}")
        time.sleep(interval)
    raise TimeoutError(f"Task {task_id} did not finish in {timeout}s")

Parallel batch monitoring via ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutor, as_completed

def poll_batch(task_ids: list[str], max_workers: int = 5) -> dict:
    """Poll a list of task_ids in parallel; return {task_id: final_status}."""
    results = {}
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = {pool.submit(poll_until_done, tid): tid for tid in task_ids}
        for fut in as_completed(futures):
            tid = futures[fut]
            try:
                results[tid] = fut.result()
            except Exception as e:
                results[tid] = {"status": "failed", "error": str(e)}
    return results

# Usage: collect task_ids from several /api/render calls and wait for all in parallel
# results = poll_batch(task_ids, max_workers=5)

On HTTP 429 (rate limit), increase interval or lower max_workers — the sliding window rate limiter counts every request made with your API key, including /api/status/{task_id} calls.

Rate Limiting

The API uses sliding window rate limiting. When limits are exceeded, HTTP 429 is returned with a Retry-After header:

resp = requests.post(f"{API_URL}/render", headers=HEADERS, json=payload)
if resp.status_code == 429:
    wait = int(resp.headers.get("Retry-After", 60))
    print(f"Rate limited. Waiting {wait}s...")
    time.sleep(wait)

Best Formats for Automation

Not all formats are equally suited for batch scenarios:

Format Speed Size Best for
GeoJSON Fast Medium Web apps, analysis
Shapefile Medium Compact GIS, ArcGIS, QGIS
GeoPackage Medium Compact Single file vs SHP bundle
DXF Medium Large AutoCAD, engineering
CSV Fast Small Data analysis, spreadsheets
FlatGeobuf Fast Compact Streaming, web maps

For batch operations, we recommend GeoJSON (universal) or FlatGeobuf (fast and compact).

Example: Daily Data Updates

import schedule

def daily_update():
    """Update data for key regions."""
    regions = {
        "moscow_roads": {"bbox": [37.3, 55.5, 37.9, 55.9], "format": "geojson"},
        "spb_buildings": {"bbox": [30.1, 59.8, 30.5, 60.1], "format": "shp"},
    }
    for name, params in regions.items():
        try:
            export_city(name, params["bbox"], params["format"])
        except Exception as e:
            print(f"Error exporting {name}: {e}")

schedule.every().day.at("03:00").do(daily_update)

while True:
    schedule.run_pending()
    time.sleep(60)

Automating exports via the API saves hours of manual work. The batch endpoint, parallel status polling, and a single archive per pack let you process hundreds of areas in minutes.

← All articles