DOC Using it from an app

Use Overspan from your app

Your key belongs on a server you control, never inside the app. This page gives you that server as one small function for Cloudflare Workers, Next.js on Vercel, or Supabase, plus the code your app uses to call it.

The pattern

A mobile app or a web page is code your users can read. Anything shipped inside it, an API key included, can be pulled out and used by someone else until you replace it. So the app never talks to Overspan directly. It asks a small function of yours, and the function adds the key:

how a lookup travels
your app
  GET /nearby?kind=cafe&lat=52.520&lon=13.405   (no key)
    ↓
your function
  checks the request, adds your key, caches the answer
    ↓
Overspan
  POST /api/interpreter   (Authorization: Bearer YOUR_KEY)

The function also does three jobs that save you money and trouble:

  • It only asks fixed questions. The app sends a place type and a point; the function builds the Overpass query. Anyone who finds your function can ask the same questions your app can, and nothing else.
  • It caches. Locations are rounded to about 100 metres and answers are kept for a day, so a thousand users on the same street cost you one request, not a thousand.
  • It passes our limits on. When Overspan asks you to wait, the function tells the app instead of retrying in a loop.
Browser calls work, but not for shipped apps The API answers requests made straight from a browser, because tools like overpass-turbo need that. Use it for your own tools and notebooks, never for code you ship to other people.

The shared part

One file does the real work, and it is the same on every platform below. It knows eight place types, turns a request into one Overpass query, and returns the places sorted by distance with the OpenStreetMap credit attached.

nearby.ts
// nearby.ts: the part that is the same on every platform.
// It turns an app's "what is near me?" request into one fixed Overpass query,
// sends it to Overspan with your key, and returns a small JSON answer.

// How long an answer can be reused. Shops and amenities in OpenStreetMap
// change slowly, so a day is safe; lower it if your app needs fresher data.
export const CACHE_SECONDS = 24 * 60 * 60;

// The only things your app can ask for. Fixed filters mean nobody can send
// their own queries through your function and spend your key's quota.
const KINDS: Record<string, string> = {
  cafe: '["amenity"="cafe"]',
  restaurant: '["amenity"="restaurant"]',
  pharmacy: '["amenity"="pharmacy"]',
  toilets: '["amenity"="toilets"]',
  drinking_water: '["amenity"="drinking_water"]',
  charging_station: '["amenity"="charging_station"]',
  playground: '["leisure"="playground"]',
  supermarket: '["shop"="supermarket"]',
};
const RADII = [250, 500, 1000, 2000];

export interface NearbyQuery {
  kind: string;
  radius: number;
  lat: number;
  lon: number;
}

export interface Place {
  id: string; // the OpenStreetMap element, e.g. "node/123" or "way/456"
  name: string | null;
  lat: number;
  lon: number;
  distance_m: number; // from the rounded point the answer was made for
  tags: Record<string, string>;
}

export interface NearbyBody {
  attribution: string;
  kind: string;
  radius: number;
  lat: number;
  lon: number;
  places: Place[];
}

export type NearbyResult =
  | { ok: true; body: NearbyBody }
  | { ok: false; status: number; error: string; retryAfter?: string };

interface OverpassElement {
  type: string;
  id: number;
  lat?: number;
  lon?: number;
  center?: { lat: number; lon: number };
  tags?: Record<string, string>;
}

// Check the app's request. Coordinates are rounded to 3 decimals (about
// 100 m), so everyone on the same block shares one cached answer.
export function parseNearby(params: URLSearchParams): NearbyQuery | string {
  const kind = params.get("kind") ?? "cafe";
  const radius = Number(params.get("radius") ?? 500);
  const lat = parseFloat(params.get("lat") ?? "");
  const lon = parseFloat(params.get("lon") ?? "");
  if (!Object.hasOwn(KINDS, kind))
    return `kind must be one of: ${Object.keys(KINDS).join(", ")}`;
  if (!RADII.includes(radius)) return `radius must be one of: ${RADII.join(", ")}`;
  if (!(Math.abs(lat) <= 90 && Math.abs(lon) <= 180)) return "lat and lon are required";
  return { kind, radius, lat: round3(lat), lon: round3(lon) };
}

export function cacheKey(q: NearbyQuery): string {
  return `${q.kind}/${q.radius}/${q.lat}/${q.lon}`;
}

export async function fetchNearby(
  q: NearbyQuery,
  apiKey: string | undefined,
  baseUrl = "https://api.overspan.dev",
): Promise<NearbyResult> {
  if (!apiKey) {
    console.error("OVERSPAN_API_KEY is not set");
    return { ok: false, status: 500, error: "not_configured" };
  }
  const query =
    `[out:json][timeout:25];` +
    `nwr${KINDS[q.kind]}(around:${q.radius},${q.lat},${q.lon});` +
    `out center tags 200;`;
  const res = await fetch(`${baseUrl}/api/interpreter`, {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}` },
    body: new URLSearchParams({ data: query }),
  });
  const text = await res.text();
  let data: {
    elements?: OverpassElement[];
    remark?: string;
    error?: { code?: string };
  } | null = null;
  try {
    data = JSON.parse(text);
  } catch {
    // Not JSON: treated as a failure below.
  }

  if (res.status === 429 || res.status === 503) {
    // A rate, concurrency or monthly limit, or a busy server. Pass the wait
    // on to the app and never retry in a loop from here.
    const error = data?.error?.code ?? "busy";
    console.warn("Overspan said wait:", res.status, error);
    return {
      ok: false,
      status: res.status,
      error,
      retryAfter: res.headers.get("Retry-After") ?? "60",
    };
  }
  if (res.status !== 200 || !Array.isArray(data?.elements) || data?.remark) {
    // A wrong key, or a query that failed on the server. Logged for you;
    // the app only needs to know it did not work.
    console.error(
      "Overspan request failed:",
      res.status,
      data?.error?.code ?? data?.remark ?? text.slice(0, 200),
    );
    return { ok: false, status: 502, error: "upstream_error" };
  }

  const places: Place[] = [];
  for (const e of data!.elements!) {
    const lat = e.lat ?? e.center?.lat;
    const lon = e.lon ?? e.center?.lon;
    if (lat === undefined || lon === undefined) continue;
    places.push({
      id: `${e.type}/${e.id}`,
      name: e.tags?.name ?? null,
      lat,
      lon,
      distance_m: Math.round(distance(q.lat, q.lon, lat, lon)),
      tags: e.tags ?? {},
    });
  }
  places.sort((a, b) => a.distance_m - b.distance_m);
  return {
    ok: true,
    body: {
      attribution: "© OpenStreetMap contributors",
      kind: q.kind,
      radius: q.radius,
      lat: q.lat,
      lon: q.lon,
      places,
    },
  };
}

// The response your function sends back to the app. Good answers may be
// cached for CACHE_SECONDS; errors never are.
export function toResponse(
  result: NearbyResult,
  extraHeaders: Record<string, string> = {},
): Response {
  const headers: Record<string, string> = {
    "Content-Type": "application/json; charset=utf-8",
    ...extraHeaders,
  };
  if (result.ok) {
    headers["Cache-Control"] =
      `public, max-age=${CACHE_SECONDS}, s-maxage=${CACHE_SECONDS}`;
    return new Response(JSON.stringify(result.body), { status: 200, headers });
  }
  headers["Cache-Control"] = "no-store";
  if (result.retryAfter) headers["Retry-After"] = result.retryAfter;
  return new Response(JSON.stringify({ error: result.error }), {
    status: result.status,
    headers,
  });
}

function round3(x: number): number {
  return Math.round(x * 1000) / 1000;
}

// Great-circle distance in metres.
function distance(lat1: number, lon1: number, lat2: number, lon2: number): number {
  const rad = Math.PI / 180;
  const a =
    Math.sin(((lat2 - lat1) * rad) / 2) ** 2 +
    Math.cos(lat1 * rad) *
      Math.cos(lat2 * rad) *
      Math.sin(((lon2 - lon1) * rad) / 2) ** 2;
  return 12_742_000 * Math.asin(Math.sqrt(a));
}

To offer another place type, add a line to KINDS with any Overpass tag filter. The radius list is fixed for the same reason the types are: fewer distinct questions means more answers come from the cache.

What your app gets back:

response (trimmed)
{
  "attribution": "© OpenStreetMap contributors",
  "kind": "cafe",
  "radius": 500,
  "lat": 52.52,
  "lon": 13.405,
  "places": [
    {
      "id": "node/1794131335",
      "name": "Espresso House",
      "lat": 52.5196945,
      "lon": 13.4036191,
      "distance_m": 99,
      "tags": { "amenity": "cafe", "brand": "Espresso House", "wheelchair": "yes", ... }
    },
    ...
  ]
}

Your function

Pick the platform your app already uses. Each version is nearby.ts plus a few lines, and each keeps the key in that platform's secret store.

Cloudflare Workers

Put nearby.ts in src/ next to this file:

src/index.ts
import { cacheKey, fetchNearby, parseNearby, toResponse } from "./nearby";

interface Env {
  OVERSPAN_API_KEY: string; // set with: npx wrangler secret put OVERSPAN_API_KEY
  OVERSPAN_API_URL?: string; // leave unset; only for testing against a stand-in
}

// Lets a web app on another domain call this Worker. Put your site's
// origin here instead of * if you prefer.
const CORS = { "Access-Control-Allow-Origin": "*" };

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname !== "/nearby") return new Response("Not found", { status: 404 });

    const q = parseNearby(url.searchParams);
    if (typeof q === "string")
      return toResponse({ ok: false, status: 400, error: q }, CORS);

    // Cloudflare's cache is per data centre, and Cloudflare documents it
    // for Workers on a custom domain.
    const key = new Request(`${url.origin}/nearby/${cacheKey(q)}`);
    const cached = await caches.default.match(key);
    if (cached) return cached;

    const result = await fetchNearby(q, env.OVERSPAN_API_KEY, env.OVERSPAN_API_URL);
    const response = toResponse(result, CORS);
    if (result.ok) ctx.waitUntil(caches.default.put(key, response.clone()));
    return response;
  },
};
wrangler.jsonc
{
  "name": "nearby",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01"
}

Store the key as a secret, then deploy:

terminal
npx wrangler secret put OVERSPAN_API_KEY
npx wrangler deploy

Your app then calls https://YOUR_DOMAIN/nearby?kind=cafe&lat=52.520&lon=13.405. Attach the Worker to your own domain: Cloudflare documents its cache for Workers on a custom domain, and the cache is kept per data centre.

Next.js on Vercel

Put nearby.ts in lib/ and this in app/api/nearby/route.ts:

app/api/nearby/route.ts
import type { NextRequest } from "next/server";
import { fetchNearby, parseNearby, toResponse } from "@/lib/nearby";

// Vercel's CDN keeps each good answer for a day (the s-maxage that
// toResponse sets), keyed by URL. The app rounds coordinates before it
// calls, so users on the same block share one cached answer.
export async function GET(request: NextRequest) {
  const q = parseNearby(request.nextUrl.searchParams);
  if (typeof q === "string") return toResponse({ ok: false, status: 400, error: q });
  return toResponse(
    await fetchNearby(q, process.env.OVERSPAN_API_KEY, process.env.OVERSPAN_API_URL),
  );
}

Add OVERSPAN_API_KEY under the project's environment variables in Vercel, and in .env.local for local development. Vercel's CDN keeps each answer by URL and per region, which is why the app-side code below rounds coordinates before it calls. On other hosts the same route runs unchanged, and caching depends on that host's CDN.

Supabase Edge Functions

Supabase has no response cache for functions, so answers are kept in a small table. Create it with a migration in supabase/migrations/:

migration (sql)
-- Answers from Overspan, kept so nearby users share one request.
create table public.overpass_cache (
  key text primary key,
  body jsonb not null,
  fetched_at timestamptz not null default now()
);

-- Row level security on with no policies: only the function's admin
-- client can read or write it, never the app directly.
alter table public.overpass_cache enable row level security;

Put nearby.ts next to the function's index.ts:

supabase/functions/nearby/index.ts
import { withSupabase } from "npm:@supabase/server";
import {
  CACHE_SECONDS,
  cacheKey,
  fetchNearby,
  parseNearby,
  toResponse,
} from "./nearby.ts";

// Supabase has no response cache for functions, so answers are kept in a
// small table (overpass_cache), shared by every copy of this function.
export default {
  // "publishable": the app calls with your project's publishable key in an
  // `apikey` header. Use "user" instead to allow signed-in users only.
  fetch: withSupabase({ auth: "publishable" }, async (req, ctx) => {
    const q = parseNearby(new URL(req.url).searchParams);
    if (typeof q === "string") return toResponse({ ok: false, status: 400, error: q });

    const key = cacheKey(q);
    const { data: hit, error } = await ctx.supabaseAdmin
      .from("overpass_cache")
      .select("body, fetched_at")
      .eq("key", key)
      .maybeSingle();
    if (error) console.error("cache read failed:", error.message);
    if (hit && Date.now() - Date.parse(hit.fetched_at) < CACHE_SECONDS * 1000) {
      return toResponse({ ok: true, body: hit.body });
    }

    const result = await fetchNearby(
      q,
      Deno.env.get("OVERSPAN_API_KEY"),
      Deno.env.get("OVERSPAN_API_URL"),
    );
    if (result.ok) {
      const { error } = await ctx.supabaseAdmin
        .from("overpass_cache")
        .upsert({ key, body: result.body, fetched_at: new Date().toISOString() });
      if (error) console.error("cache write failed:", error.message);
    }
    return toResponse(result);
  }),
};

The function checks for your project's publishable key rather than a signed-in user, so turn off Supabase's default sign-in check for it:

supabase/config.toml
[functions.nearby]
verify_jwt = false

Put OVERSPAN_API_KEY=YOUR_KEY in supabase/functions/.env (keep that file out of git), then push it and deploy:

terminal
supabase secrets set --env-file supabase/functions/.env
supabase functions deploy nearby

Your app calls https://YOUR_PROJECT.supabase.co/functions/v1/nearby with your publishable key in an apikey header. Old rows are replaced when that spot is asked for again; to keep the table small, delete stale ones on a schedule:

sql
delete from public.overpass_cache where fetched_at < now() - interval '2 days';

Calling it from your app

All three call your function, never Overspan. They round the location first, so the cache is shared, and when your function says wait they report it instead of retrying.

React

useNearby.ts
"use client";
import { useEffect, useState } from "react";

export interface Place {
  id: string;
  name: string | null;
  lat: number;
  lon: number;
  distance_m: number;
  tags: Record<string, string>;
}

interface NearbyState {
  places: Place[] | null;
  error: string | null;
  loading: boolean;
}

// Asks your own endpoint (never Overspan directly) for places near a point.
// Coordinates are rounded to 3 decimals, so the URL matches what other users
// on the same block ask for and the cache can answer it.
export function useNearby(
  lat: number | null | undefined,
  lon: number | null | undefined,
  kind = "cafe",
  radius = 500,
  endpoint = "/api/nearby",
): NearbyState {
  const [state, setState] = useState<NearbyState>({
    places: null,
    error: null,
    loading: false,
  });
  const latKey = lat?.toFixed(3);
  const lonKey = lon?.toFixed(3);

  useEffect(() => {
    if (latKey === undefined || lonKey === undefined) return;
    const controller = new AbortController();
    setState((s) => ({ ...s, loading: true, error: null }));
    const params = new URLSearchParams({
      kind,
      radius: String(radius),
      lat: latKey,
      lon: lonKey,
    });
    fetch(`${endpoint}?${params}`, { signal: controller.signal })
      .then(async (res) => {
        const body = await res.json().catch(() => ({}));
        if (res.status === 429 || res.status === 503)
          throw new Error("Busy, try again in a moment");
        if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
        setState({ places: body.places, error: null, loading: false });
      })
      .catch((err) => {
        if (err.name !== "AbortError")
          setState({ places: null, error: err.message, loading: false });
      });
    return () => controller.abort();
  }, [latKey, lonKey, kind, radius, endpoint]);

  return state;
}
in a component
const { places, error, loading } = useNearby(position?.lat, position?.lon, "pharmacy");

Flutter and Dart

Uses the http package (dart pub add http):

lib/nearby.dart
import 'dart:convert';

import 'package:http/http.dart' as http;

class Place {
  final String id;
  final String? name;
  final double lat;
  final double lon;
  final int distanceM;
  final Map<String, String> tags;

  Place.fromJson(Map<String, dynamic> j)
      : id = j['id'] as String,
        name = j['name'] as String?,
        lat = (j['lat'] as num).toDouble(),
        lon = (j['lon'] as num).toDouble(),
        distanceM = j['distance_m'] as int,
        tags = Map<String, String>.from(j['tags'] as Map);
}

class NearbyException implements Exception {
  final int status;
  final String error;
  final Duration? retryAfter; // set when the server asks you to wait

  NearbyException(this.status, this.error, this.retryAfter);

  @override
  String toString() => 'NearbyException($status, $error)';
}

/// Asks your own endpoint (never Overspan directly) for places near a point.
/// For the Supabase version, pass headers: {'apikey': yourPublishableKey}.
Future<List<Place>> fetchNearby(
  Uri endpoint,
  double lat,
  double lon, {
  String kind = 'cafe',
  int radius = 500,
  Map<String, String> headers = const {},
}) async {
  final uri = endpoint.replace(queryParameters: {
    'kind': kind,
    'radius': '$radius',
    // Rounded to about 100 m, so nearby users share one cached answer.
    'lat': lat.toStringAsFixed(3),
    'lon': lon.toStringAsFixed(3),
  });
  final res = await http.get(uri, headers: headers);
  if (res.statusCode != 200) {
    final seconds = int.tryParse(res.headers['retry-after'] ?? '');
    var error = 'http_${res.statusCode}';
    try {
      error = (jsonDecode(res.body) as Map)['error'] as String;
    } catch (_) {}
    throw NearbyException(res.statusCode, error,
        seconds == null ? null : Duration(seconds: seconds));
  }
  final body = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
  return (body['places'] as List)
      .map((p) => Place.fromJson(p as Map<String, dynamic>))
      .toList();
}
calling it
final places = await fetchNearby(
  Uri.parse('https://YOUR_DOMAIN/nearby'),
  52.5201,
  13.4049,
  kind: 'pharmacy',
);

Swift

Nearby.swift
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // Linux only; iOS and macOS do not need it
#endif

struct Place: Decodable {
    let id: String
    let name: String?
    let lat: Double
    let lon: Double
    let distanceM: Int
    let tags: [String: String]
}

struct NearbyError: Error {
    let status: Int
    let error: String
    let retryAfter: TimeInterval? // set when the server asks you to wait
}

/// Asks your own endpoint (never Overspan directly) for places near a point.
/// For the Supabase version, pass headers: ["apikey": yourPublishableKey].
func fetchNearby(
    endpoint: URL, lat: Double, lon: Double, kind: String = "cafe",
    radius: Int = 500, headers: [String: String] = [:]
) async throws -> [Place] {
    var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
    components.queryItems = [
        URLQueryItem(name: "kind", value: kind),
        URLQueryItem(name: "radius", value: String(radius)),
        // Rounded to about 100 m, so nearby users share one cached answer.
        URLQueryItem(name: "lat", value: String(format: "%.3f", lat)),
        URLQueryItem(name: "lon", value: String(format: "%.3f", lon)),
    ]
    var request = URLRequest(url: components.url!)
    for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }

    let (data, response) = try await URLSession.shared.data(for: request)
    let http = response as! HTTPURLResponse
    guard http.statusCode == 200 else {
        let body = try? JSONDecoder().decode([String: String].self, from: data)
        let wait = http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
        throw NearbyError(
            status: http.statusCode,
            error: body?["error"] ?? "http_\(http.statusCode)",
            retryAfter: wait)
    }
    struct Body: Decodable { let places: [Place] }
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    return try decoder.decode(Body.self, from: data).places
}
calling it
let places = try await fetchNearby(
    endpoint: URL(string: "https://YOUR_DOMAIN/nearby")!,
    lat: 52.5201, lon: 13.4049, kind: "pharmacy")

How big a plan you need

Every request that reaches Overspan counts against your monthly quota, and answers from your cache cost nothing. So the number that matters is how often the cache misses, not how often people look things up.

Say 2,000 people a day open your app and each looks something up five times. That is 10,000 lookups a day, about 300,000 a month:

Answered from your cacheRequests to Overspan a monthPlan that fits
None (no cache)300,000Business (1,000,000)
Half150,000Pro (250,000)
Four in five60,000Pro (Indie's 50,000 runs short)

How much the cache catches depends on how close together your users are: an app for one city does far better than one spread thinly across a country. After a week of real use, the usage dashboard shows your actual figure. All tier limits are on the quickstart, and none of them ever bills as overage.

When Overspan says wait

The function passes these on to the app with the same status and Retry-After, and never caches them:

  • rate_limited and concurrency_limit (429): too many requests this minute, or too many at once. Wait the seconds in Retry-After. A cache in front makes these rare.
  • quota_exceeded (429): the month's quota is used up. Nothing succeeds until the time in X-Overspan-Quota-Reset, so the function logs a warning for you. Upgrade in the billing portal or wait for the new month.
  • no_backend (503): brief; try again after Retry-After.

An app that retries the moment it gets a 429 only collects more of them. Show the user a "busy" state and let the next lookup try again.

Telling your coding agent

If an AI assistant writes your app, paste this into CLAUDE.md, AGENTS.md, your Cursor rules, or your app builder's knowledge settings, so it builds the pattern above instead of calling Overpass from the app:

AGENTS.md
## OpenStreetMap data (Overspan)

- Never call Overpass or Overspan from client code (browser, mobile app, or an
  app builder's front end). The app calls our own server function:
  GET /nearby?kind=cafe&lat=52.520&lon=13.405
- Only that function knows OVERSPAN_API_KEY, kept in the platform's secret
  store, never in the repo or the app bundle. It calls
  POST https://api.overspan.dev/api/interpreter with the header
  "Authorization: Bearer <key>" and the Overpass QL in a form field named data.
- Follow the recipe for our platform at https://overspan.dev/docs/apps?utm_source=agents-md&utm_medium=rules:
  fixed query shapes only, coordinates rounded to 3 decimals, answers cached
  for a day, results sorted by distance.
- On 429 or 503, pass Retry-After on to the app and never retry in a loop.
- Show "© OpenStreetMap contributors" wherever the places appear.

Lovable and other builders

Lovable can hold the key for you. A workspace admin or owner adds a custom connector in the workspace settings:

FieldValue
NameOverspan
AuthenticationBearer token (each user pastes their own key when connecting)
API base URLhttps://api.overspan.dev
Test requestGET /api/usage
Knowledge fileThe block above

GET /api/usage checks the key without using any quota. Lovable adds the key on its own servers, so it never appears in your project's code. With any other builder, put the key in the platform's server-side secrets and use one of the three functions above.

Credit OpenStreetMap

Every answer carries "attribution": "© OpenStreetMap contributors". Show it wherever the places appear, linked to openstreetmap.org/copyright. The quickstart's attribution section has the exact wording and what the licence asks of data you publish.


Something here wrong, unclear, or missing? Mail hello@overspan.dev. Documentation gaps are treated as bugs.