{
  "openapi": "3.1.0",
  "info": {
    "title": "Overspan API",
    "version": "1.0.0",
    "summary": "Hosted OpenStreetMap Overpass API with keyed access and metered tiers.",
    "description": "Overspan runs the open-source Overpass API software on dedicated hardware, behind a gateway that authenticates every request against a subscription key and enforces that key's tier limits.\n\nThe query endpoint is path-compatible with the public Overpass servers, so pointing an existing client at Overspan is a change of base URL. Full metadata is served (`out meta` returns version, timestamp, changeset, user, uid), so output matches the public servers element for element. Attic queries are not enabled.\n\nThree behavioural differences from the public servers are worth knowing before you switch. A query that sets no `[timeout:]` gets 25 seconds, not 180. A query that sets no `[maxsize:]` gets 256 MB. Values above your tier cap are rejected with a 400 rather than quietly clamped, because a clamped query returns different results from the one you wrote.\n\nErrors are always JSON of the shape `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. Branch on `error.code`, which is stable; `message` is written for a human reading a log and may be reworded.\n\nThe data is OpenStreetMap data, licensed under the ODbL. A subscription pays for hosting and access, not for the data, and does not change your licence obligations.",
    "termsOfService": "https://overspan.dev/terms",
    "license": {
      "name": "OpenStreetMap data, under the Open Database License (ODbL) 1.0",
      "url": "https://opendatacommons.org/licenses/odbl/1-0/"
    },
    "contact": {
      "name": "Overspan support",
      "email": "hello@overspan.dev",
      "url": "https://overspan.dev/contact"
    }
  },
  "externalDocs": {
    "description": "Quickstart, client setup, error reference, and migration guide",
    "url": "https://overspan.dev/docs/"
  },
  "servers": [
    {
      "url": "https://api.overspan.dev",
      "description": "Production"
    }
  ],
  "security": [
    { "bearerKey": [] },
    { "queryKey": [] }
  ],
  "tags": [
    {
      "name": "query",
      "description": "Running Overpass QL against the database."
    },
    {
      "name": "metering",
      "description": "Reading your own quota and usage. Never consumes query quota."
    },
    {
      "name": "service",
      "description": "Unauthenticated service state."
    }
  ],
  "paths": {
    "/{api_key}/api/interpreter": {
      "description": "The key-in-path form. Use this when your client only lets you set a base URL, which is the common case for JOSM, overpass-turbo, and osmnx.",
      "parameters": [
        { "$ref": "#/components/parameters/ApiKeyPath" }
      ],
      "get": {
        "tags": ["query"],
        "operationId": "interpreterPathKeyGet",
        "summary": "Run an Overpass QL query (key in path, query in the URL)",
        "security": [],
        "parameters": [
          { "$ref": "#/components/parameters/DataQuery" }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/OverpassResult" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/LimitReached" },
          "502": { "$ref": "#/components/responses/UpstreamError" },
          "503": { "$ref": "#/components/responses/NoBackend" },
          "504": { "$ref": "#/components/responses/UpstreamTimeout" }
        }
      },
      "post": {
        "tags": ["query"],
        "operationId": "interpreterPathKeyPost",
        "summary": "Run an Overpass QL query (key in path, query in the body)",
        "security": [],
        "requestBody": { "$ref": "#/components/requestBodies/OverpassQuery" },
        "responses": {
          "200": { "$ref": "#/components/responses/OverpassResult" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/LimitReached" },
          "502": { "$ref": "#/components/responses/UpstreamError" },
          "503": { "$ref": "#/components/responses/NoBackend" },
          "504": { "$ref": "#/components/responses/UpstreamTimeout" }
        }
      }
    },
    "/api/interpreter": {
      "description": "The keyless-path form. Authenticate with an `Authorization: Bearer` header, which is the preferred option, or with a `key` query parameter.",
      "get": {
        "tags": ["query"],
        "operationId": "interpreterGet",
        "summary": "Run an Overpass QL query (query in the URL)",
        "parameters": [
          { "$ref": "#/components/parameters/DataQuery" }
        ],
        "responses": {
          "200": { "$ref": "#/components/responses/OverpassResult" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/LimitReached" },
          "502": { "$ref": "#/components/responses/UpstreamError" },
          "503": { "$ref": "#/components/responses/NoBackend" },
          "504": { "$ref": "#/components/responses/UpstreamTimeout" }
        }
      },
      "post": {
        "tags": ["query"],
        "operationId": "interpreterPost",
        "summary": "Run an Overpass QL query (query in the body)",
        "requestBody": { "$ref": "#/components/requestBodies/OverpassQuery" },
        "responses": {
          "200": { "$ref": "#/components/responses/OverpassResult" },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/LimitReached" },
          "502": { "$ref": "#/components/responses/UpstreamError" },
          "503": { "$ref": "#/components/responses/NoBackend" },
          "504": { "$ref": "#/components/responses/UpstreamTimeout" }
        }
      }
    },
    "/{api_key}/api/usage": {
      "description": "The key-in-path form of the usage report.",
      "parameters": [
        { "$ref": "#/components/parameters/ApiKeyPath" }
      ],
      "get": {
        "tags": ["metering"],
        "operationId": "usagePathKey",
        "summary": "Read your quota, limits, and usage history (key in path)",
        "security": [],
        "responses": {
          "200": { "$ref": "#/components/responses/UsageReport" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/UsageRateLimited" }
        }
      }
    },
    "/api/usage": {
      "description": "Reads the metering database only. It does not depend on a healthy Overpass backend, because a backend being down is exactly when you want to look at your usage. It never consumes query quota and has its own 60 requests/minute bucket, so polling it costs you nothing.",
      "get": {
        "tags": ["metering"],
        "operationId": "usage",
        "summary": "Read your quota, limits, and usage history",
        "responses": {
          "200": { "$ref": "#/components/responses/UsageReport" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/UsageRateLimited" }
        }
      }
    },
    "/healthz": {
      "get": {
        "tags": ["service"],
        "operationId": "health",
        "summary": "Service state",
        "description": "No key required. Reports whether at least one database backend is both reachable and current enough to serve queries. A backend that has fallen behind the OSM replication feed is reported unhealthy rather than served from.",
        "security": [],
        "responses": {
          "200": {
            "description": "At least one backend can serve queries.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Health" },
                "example": {
                  "status": "ok",
                  "backends": [{ "healthy": true, "lag_s": 122.87, "error": null }]
                }
              }
            }
          },
          "503": {
            "description": "No backend can serve queries. Query requests will return `no_backend`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Health" },
                "example": {
                  "status": "degraded",
                  "backends": [{ "healthy": false, "lag_s": 5400.0, "error": "data lag 5400s" }]
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your subscription key as a bearer token: `Authorization: Bearer YOUR_KEY`. Preferred where your client allows it, because keys in URLs end up in shell history, proxy logs, and pasted snippets."
      },
      "queryKey": {
        "type": "apiKey",
        "in": "query",
        "name": "key",
        "description": "Your subscription key as a `key` query parameter. Use it when you are building the URL yourself and cannot set headers."
      }
    },
    "parameters": {
      "ApiKeyPath": {
        "name": "api_key",
        "in": "path",
        "required": true,
        "description": "Your subscription key as the first path segment. Equivalent to the other two forms; this one exists because most Overpass clients only let you configure a base URL.",
        "schema": { "type": "string" },
        "example": "YOUR_KEY"
      },
      "DataQuery": {
        "name": "data",
        "in": "query",
        "required": true,
        "description": "The Overpass QL query. Missing or empty returns `missing_query`.",
        "schema": { "type": "string" },
        "example": "[out:json][timeout:60];node[amenity=cafe](-36.86,174.75,-36.84,174.78);out meta 5;"
      }
    },
    "requestBodies": {
      "OverpassQuery": {
        "required": true,
        "description": "The Overpass QL query, either form-encoded as `data` or as the raw request body. The raw form exists because JOSM and curl commonly POST the bare query.",
        "content": {
          "application/x-www-form-urlencoded": {
            "schema": {
              "type": "object",
              "required": ["data"],
              "properties": {
                "data": {
                  "type": "string",
                  "description": "The Overpass QL query."
                }
              }
            },
            "example": {
              "data": "[out:json][timeout:60];area[name=\"Wellington\"]->.a;node[amenity=drinking_water](area.a);out meta;"
            }
          },
          "text/plain": {
            "schema": { "type": "string" },
            "example": "[out:json][timeout:60];node[amenity=cafe](-36.86,174.75,-36.84,174.78);out meta 5;"
          }
        }
      }
    },
    "headers": {
      "X-Overspan-Quota-Limit": {
        "description": "Requests included in your tier this month.",
        "schema": { "type": "integer" },
        "example": 250000
      },
      "X-Overspan-Quota-Remaining": {
        "description": "Requests left this month, after accounting for this one.",
        "schema": { "type": "integer" },
        "example": 249843
      },
      "X-Overspan-Quota-Reset": {
        "description": "When the monthly counter resets, UTC.",
        "schema": { "type": "string", "format": "date-time" },
        "example": "2026-09-01T00:00:00Z"
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying. Where it is sent, it is the real answer rather than an estimate.",
        "schema": { "type": "integer" },
        "example": 12
      }
    },
    "responses": {
      "OverpassResult": {
        "description": "The Overpass response, streamed through unchanged. The media type follows the query's `[out:]` setting and is passed through from the database.",
        "headers": {
          "X-Overspan-Quota-Limit": { "$ref": "#/components/headers/X-Overspan-Quota-Limit" },
          "X-Overspan-Quota-Remaining": { "$ref": "#/components/headers/X-Overspan-Quota-Remaining" },
          "X-Overspan-Quota-Reset": { "$ref": "#/components/headers/X-Overspan-Quota-Reset" }
        },
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "description": "Result of an `[out:json]` query. The envelope carries `version`, `generator`, `osm3s` (including the data timestamp), and `elements`.",
              "properties": {
                "version": { "type": "number" },
                "generator": { "type": "string" },
                "osm3s": { "type": "object" },
                "elements": { "type": "array", "items": { "type": "object" } }
              }
            }
          },
          "application/osm3s+xml": {
            "schema": {
              "type": "string",
              "description": "Result of an `[out:xml]` query."
            }
          },
          "text/csv": {
            "schema": {
              "type": "string",
              "description": "Result of an `[out:csv]` query."
            }
          }
        }
      },
      "UsageReport": {
        "description": "Your tier, its limits, month-to-date quota, a 24-hour summary, 30 days of daily figures, and your twenty most recent requests.",
        "headers": {
          "X-Overspan-Quota-Limit": { "$ref": "#/components/headers/X-Overspan-Quota-Limit" },
          "X-Overspan-Quota-Remaining": { "$ref": "#/components/headers/X-Overspan-Quota-Remaining" },
          "X-Overspan-Quota-Reset": { "$ref": "#/components/headers/X-Overspan-Quota-Reset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/UsageReport" }
          }
        }
      },
      "BadRequest": {
        "description": "`missing_query` (no Overpass QL found) or `query_caps` (`[timeout:]` or `[maxsize:]` above your tier maximum). Neither is worth retrying unchanged.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "query_caps": {
                "summary": "Over-cap value, rejected rather than clamped",
                "value": {
                  "error": {
                    "code": "query_caps",
                    "message": "[timeout:600] exceeds the indie tier maximum of 60. Lower it or upgrade your tier."
                  }
                }
              },
              "missing_query": {
                "summary": "No query in the request",
                "value": {
                  "error": {
                    "code": "missing_query",
                    "message": "No Overpass QL found. Send it as the 'data' parameter or the request body."
                  }
                }
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "`missing_key` (no key supplied) or `invalid_key` (unknown, revoked, or not yet activated). Neither is worth retrying unchanged.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "invalid_key": {
                "value": {
                  "error": { "code": "invalid_key", "message": "Unknown or inactive API key." }
                }
              },
              "missing_key": {
                "value": {
                  "error": {
                    "code": "missing_key",
                    "message": "Provide your key in the URL path (/KEY/api/interpreter), ?key=, or the Authorization header."
                  }
                }
              }
            }
          }
        }
      },
      "LimitReached": {
        "description": "One of your tier limits. `rate_limited` is a token bucket, so short bursts are fine as long as the average holds. `concurrency_limit` clears as soon as one of your running queries finishes. `quota_exceeded` means the monthly allowance is used; there is no overage billing, so it resets at the start of the next month or clears on an upgrade. Rejected requests never consume quota.",
        "headers": {
          "Retry-After": { "$ref": "#/components/headers/Retry-After" },
          "X-Overspan-Quota-Limit": { "$ref": "#/components/headers/X-Overspan-Quota-Limit" },
          "X-Overspan-Quota-Remaining": { "$ref": "#/components/headers/X-Overspan-Quota-Remaining" },
          "X-Overspan-Quota-Reset": { "$ref": "#/components/headers/X-Overspan-Quota-Reset" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "examples": {
              "rate_limited": {
                "value": {
                  "error": { "code": "rate_limited", "message": "Rate limit of 60 requests/minute exceeded." }
                }
              },
              "concurrency_limit": {
                "value": {
                  "error": {
                    "code": "concurrency_limit",
                    "message": "Tier allows 2 concurrent queries; one just finished frees a slot."
                  }
                }
              },
              "quota_exceeded": {
                "value": {
                  "error": {
                    "code": "quota_exceeded",
                    "message": "Monthly quota of 50000 requests used. Resets at month start (UTC), or upgrade."
                  }
                }
              }
            }
          }
        }
      },
      "UsageRateLimited": {
        "description": "The usage endpoint's own 60 requests/minute bucket, separate from your query allowance.",
        "headers": {
          "Retry-After": { "$ref": "#/components/headers/Retry-After" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": { "code": "rate_limited", "message": "Usage endpoint allows 60 requests/minute." }
            }
          }
        }
      },
      "UpstreamError": {
        "description": "`upstream_error`. The database failed to answer, which is our fault rather than your query's. Retry with backoff.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": { "code": "upstream_error", "message": "The backend failed to answer. Retry shortly." }
            }
          }
        }
      },
      "NoBackend": {
        "description": "`no_backend`. No database was both reachable and current enough to serve you. Stale results that look fresh are worse than an honest error, so a lagging backend is refused rather than used. Retry after the interval given.",
        "headers": {
          "Retry-After": { "$ref": "#/components/headers/Retry-After" }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": { "code": "no_backend", "message": "No healthy backend available. Retry shortly." }
            }
          }
        }
      },
      "UpstreamTimeout": {
        "description": "`upstream_timeout`. The query started but did not finish in time. Treat it as a signal to make the query cheaper rather than as a transient blip: narrow the bounding box, filter earlier, split the job, or raise `[timeout:]` within your tier cap.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": {
              "error": { "code": "upstream_timeout", "message": "The query did not complete in time." }
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Every rejection has this shape, whatever the status code.",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable code. Branch on this.",
                "enum": [
                  "missing_key",
                  "invalid_key",
                  "missing_query",
                  "query_caps",
                  "rate_limited",
                  "concurrency_limit",
                  "quota_exceeded",
                  "no_backend",
                  "upstream_timeout",
                  "upstream_error"
                ]
              },
              "message": {
                "type": "string",
                "description": "Written for a human reading a log. May be reworded, so do not match on it."
              }
            }
          }
        }
      },
      "Health": {
        "type": "object",
        "required": ["status", "backends"],
        "properties": {
          "status": {
            "type": "string",
            "enum": ["ok", "degraded"],
            "description": "`ok` when at least one backend can serve queries."
          },
          "backends": {
            "type": "array",
            "description": "One entry per database backend, in no meaningful order and with no identifying detail.",
            "items": {
              "type": "object",
              "properties": {
                "healthy": { "type": "boolean" },
                "lag_s": {
                  "type": ["number", "null"],
                  "description": "Seconds this backend's data trails the live OSM replication feed."
                },
                "error": {
                  "type": ["string", "null"],
                  "description": "Why the backend is unhealthy, or null when it is fine."
                }
              }
            }
          }
        }
      },
      "UsageReport": {
        "type": "object",
        "required": ["tier", "status", "quota", "limits", "last_24h", "daily", "recent"],
        "properties": {
          "tier": {
            "type": "string",
            "enum": ["indie", "pro", "business"]
          },
          "status": {
            "type": "string",
            "description": "State of your key.",
            "example": "active"
          },
          "quota": {
            "type": "object",
            "required": ["limit", "used", "remaining", "month", "resets_at"],
            "properties": {
              "limit": { "type": "integer", "description": "Requests included this month." },
              "used": { "type": "integer" },
              "remaining": { "type": "integer" },
              "month": { "type": "string", "description": "The month being counted, `YYYY-MM` UTC.", "example": "2026-08" },
              "resets_at": { "type": "string", "format": "date-time" }
            }
          },
          "limits": {
            "type": "object",
            "description": "Your tier's enforced limits. All are enforced at the front door and none bills as overage.",
            "required": ["per_minute", "concurrency", "max_timeout_s", "max_maxsize_bytes"],
            "properties": {
              "per_minute": { "type": "integer" },
              "concurrency": { "type": "integer" },
              "max_timeout_s": { "type": "integer", "description": "Highest `[timeout:]` your tier accepts." },
              "max_maxsize_bytes": { "type": "integer", "description": "Highest `[maxsize:]` your tier accepts." }
            }
          },
          "last_24h": { "$ref": "#/components/schemas/UsageTotals" },
          "daily": {
            "type": "array",
            "description": "One entry per UTC day over the last 30 days. Days with no traffic are absent rather than zeroed.",
            "items": { "$ref": "#/components/schemas/UsageDay" }
          },
          "recent": {
            "type": "array",
            "description": "Your twenty most recent requests, newest first.",
            "items": { "$ref": "#/components/schemas/UsageRequest" }
          },
          "note": {
            "type": "string",
            "description": "Standing reminder that query text is never stored.",
            "example": "Query text is never stored. These records cover timing and outcome only."
          }
        }
      },
      "UsageTotals": {
        "type": "object",
        "description": "Rolled-up figures for a period.",
        "properties": {
          "requests": { "type": "integer" },
          "bytes_out": { "type": "integer" },
          "avg_duration_ms": { "type": "integer" },
          "p95_duration_ms": { "type": "integer" },
          "completed": { "type": "integer" },
          "rejected": { "type": "integer" },
          "failed": { "type": "integer" },
          "aborted": { "type": "integer" },
          "by_outcome": {
            "type": "object",
            "description": "Raw outcome labels to counts, more granular than the four grouped totals. Rejections name the check that fired, for example `rejected:quota` or `rejected:caps:timeout`.",
            "additionalProperties": { "type": "integer" }
          }
        }
      },
      "UsageDay": {
        "type": "object",
        "properties": {
          "date": { "type": "string", "format": "date" },
          "requests": { "type": "integer" },
          "completed": { "type": "integer" },
          "rejected": { "type": "integer" },
          "aborted": { "type": "integer" },
          "failed": { "type": "integer" },
          "bytes_out": { "type": "integer" }
        }
      },
      "UsageRequest": {
        "type": "object",
        "description": "One metered request. Query text is not recorded, so there is nothing here about what was asked for.",
        "properties": {
          "ts": { "type": "string", "format": "date-time" },
          "outcome": {
            "type": "string",
            "description": "`completed`, `aborted`, an `upstream_*` fault, or `rejected:*` naming the check that fired.",
            "example": "completed"
          },
          "http_status": { "type": ["integer", "null"] },
          "duration_ms": { "type": ["integer", "null"] },
          "bytes_out": { "type": ["integer", "null"] }
        }
      }
    }
  }
}
