{
  "openapi": "3.1.0",
  "info": {
    "title": "Hack Club Geocoder",
    "summary": "Geocoding and IP geolocation with standardized JSON responses.",
    "description": "A free geocoding and IP geolocation API run by Hack Club.\n\nForward and reverse geocoding are backed by the Google Maps Platform Geocoding API; IP geolocation is backed by IPinfo. Every endpoint returns a standardized JSON shape and also includes the complete upstream provider payload under `raw_backend_response`.\n\nAuthentication is a single `key` query parameter on every `/v1/` endpoint; there is no header-based auth. API keys are issued by Hack Club HQ staff. Each key carries its own per-second rate limit (10 requests/second by default), and every `/v1/` response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers.\n\nResults are cached, so repeated lookups are fast and do not re-bill the upstream provider.",
    "version": "1.0.0",
    "license": {
      "name": "MIT",
      "identifier": "MIT"
    },
    "contact": {
      "name": "Hack Club",
      "url": "https://hackclub.com",
      "email": "team@hackclub.com"
    }
  },
  "externalDocs": {
    "description": "Human-readable API documentation",
    "url": "https://geocoder.hackclub.com/"
  },
  "servers": [
    {
      "url": "https://geocoder.hackclub.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ApiKeyQuery": []
    }
  ],
  "tags": [
    {
      "name": "Geocoding",
      "description": "Convert between addresses and coordinates."
    },
    {
      "name": "IP geolocation",
      "description": "Convert IP addresses into locations."
    },
    {
      "name": "Service",
      "description": "Unauthenticated service metadata."
    }
  ],
  "paths": {
    "/v1/geocode": {
      "get": {
        "operationId": "geocode",
        "summary": "Geocode a freeform address",
        "description": "Converts a freeform address string into coordinates. Results are cached, so repeating the same address is served from cache.",
        "tags": ["Geocoding"],
        "parameters": [
          {
            "name": "address",
            "in": "query",
            "required": true,
            "description": "The address to geocode.",
            "schema": {
              "type": "string",
              "minLength": 1
            },
            "example": "1600 Amphitheatre Parkway"
          },
          {
            "$ref": "#/components/parameters/ApiKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The geocoded location.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"}
            },
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/GeocodeResponse"}
              }
            }
          },
          "400": {
            "description": "The `address` parameter is missing or empty (`INVALID_ADDRESS`).",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/ErrorResponse"},
                "example": {
                  "error": {
                    "code": "INVALID_ADDRESS",
                    "message": "Address parameter is required",
                    "timestamp": "2026-01-15T10:30:00Z"
                  }
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "502": {"$ref": "#/components/responses/UpstreamFailure"},
          "503": {"$ref": "#/components/responses/UpstreamNotConfigured"}
        }
      }
    },
    "/v1/geocode_structured": {
      "get": {
        "operationId": "geocodeStructured",
        "summary": "Geocode an address supplied as separate components",
        "description": "Converts a structured address into coordinates. Every address field is optional, but at least one must be provided. Supplying components separately is more accurate than a freeform string for addresses collected through a form. The response shape is identical to `/v1/geocode`.",
        "tags": ["Geocoding"],
        "parameters": [
          {
            "name": "address_line_1",
            "in": "query",
            "required": false,
            "description": "Primary street address.",
            "schema": {"type": "string"},
            "example": "1600 Amphitheatre Parkway"
          },
          {
            "name": "address_line_2",
            "in": "query",
            "required": false,
            "description": "Secondary address line, such as a suite or apartment number.",
            "schema": {"type": "string"}
          },
          {
            "name": "city",
            "in": "query",
            "required": false,
            "description": "City name.",
            "schema": {"type": "string"},
            "example": "Mountain View"
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "description": "State or province.",
            "schema": {"type": "string"},
            "example": "CA"
          },
          {
            "name": "postal_code",
            "in": "query",
            "required": false,
            "description": "ZIP or postal code.",
            "schema": {"type": "string"},
            "example": "94043"
          },
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "Country name.",
            "schema": {"type": "string"},
            "example": "USA"
          },
          {
            "$ref": "#/components/parameters/ApiKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The geocoded location.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"}
            },
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/GeocodeResponse"}
              }
            }
          },
          "400": {
            "description": "Every address field was empty (`INVALID_ADDRESS`).",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/ErrorResponse"},
                "example": {
                  "error": {
                    "code": "INVALID_ADDRESS",
                    "message": "At least one address field is required",
                    "timestamp": "2026-01-15T10:30:00Z"
                  }
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "502": {"$ref": "#/components/responses/UpstreamFailure"},
          "503": {"$ref": "#/components/responses/UpstreamNotConfigured"}
        }
      }
    },
    "/v1/reverse_geocode": {
      "get": {
        "operationId": "reverseGeocode",
        "summary": "Convert coordinates into an address",
        "description": "Converts a latitude/longitude pair into a street address with separated components.",
        "tags": ["Geocoding"],
        "parameters": [
          {
            "name": "lat",
            "in": "query",
            "required": true,
            "description": "Latitude.",
            "schema": {
              "type": "number",
              "format": "double",
              "minimum": -90,
              "maximum": 90
            },
            "example": 37.422476
          },
          {
            "name": "lng",
            "in": "query",
            "required": true,
            "description": "Longitude.",
            "schema": {
              "type": "number",
              "format": "double",
              "minimum": -180,
              "maximum": 180
            },
            "example": -122.08425
          },
          {
            "$ref": "#/components/parameters/ApiKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The address at those coordinates.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"}
            },
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/ReverseGeocodeResponse"}
              }
            }
          },
          "400": {
            "description": "`lat` or `lng` is missing, unparseable, or out of range (`INVALID_COORDINATES`).",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/ErrorResponse"},
                "example": {
                  "error": {
                    "code": "INVALID_COORDINATES",
                    "message": "Latitude must be between -90 and 90",
                    "timestamp": "2026-01-15T10:30:00Z"
                  }
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "502": {"$ref": "#/components/responses/UpstreamFailure"},
          "503": {"$ref": "#/components/responses/UpstreamNotConfigured"}
        }
      }
    },
    "/v1/geoip": {
      "get": {
        "operationId": "geoip",
        "summary": "Locate an IP address",
        "description": "Resolves an IPv4 or IPv6 address to an approximate location.",
        "tags": ["IP geolocation"],
        "parameters": [
          {
            "name": "ip",
            "in": "query",
            "required": true,
            "description": "The IPv4 or IPv6 address to locate.",
            "schema": {
              "type": "string",
              "minLength": 1
            },
            "example": "8.8.8.8"
          },
          {
            "$ref": "#/components/parameters/ApiKey"
          }
        ],
        "responses": {
          "200": {
            "description": "The location of the IP address.",
            "headers": {
              "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
              "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
              "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"}
            },
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/GeoIPResponse"}
              }
            }
          },
          "400": {
            "description": "The `ip` parameter is missing or is not a valid IP address (`INVALID_IP`).",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/ErrorResponse"},
                "example": {
                  "error": {
                    "code": "INVALID_IP",
                    "message": "Invalid IP address format",
                    "timestamp": "2026-01-15T10:30:00Z"
                  }
                }
              }
            }
          },
          "401": {"$ref": "#/components/responses/Unauthorized"},
          "429": {"$ref": "#/components/responses/RateLimited"},
          "502": {"$ref": "#/components/responses/UpstreamFailure"}
        }
      }
    },
    "/health": {
      "get": {
        "operationId": "health",
        "summary": "Service health check",
        "description": "Reports database connectivity and whether each upstream provider is configured. No authentication required.",
        "tags": ["Service"],
        "security": [],
        "responses": {
          "200": {
            "description": "The service is `healthy` or `degraded`.",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/HealthStatus"}
              }
            }
          },
          "503": {
            "description": "The service is `unhealthy`: the database is unreachable, or no upstream provider is configured.",
            "content": {
              "application/json": {
                "schema": {"$ref": "#/components/schemas/HealthStatus"}
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyQuery": {
        "type": "apiKey",
        "in": "query",
        "name": "key",
        "description": "API key issued by Hack Club HQ staff, passed as the `key` query parameter. Header-based authentication is not supported."
      }
    },
    "parameters": {
      "ApiKey": {
        "name": "key",
        "in": "query",
        "required": true,
        "description": "Your API key.",
        "schema": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "headers": {
      "XRateLimitLimit": {
        "description": "Requests per second permitted for this API key.",
        "schema": {"type": "integer"}
      },
      "XRateLimitRemaining": {
        "description": "Requests remaining in the current second.",
        "schema": {"type": "integer"}
      },
      "XRateLimitReset": {
        "description": "Unix timestamp when the rate limit window resets.",
        "schema": {"type": "integer", "format": "int64"}
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "The `key` parameter is missing, invalid, or belongs to a deactivated key (`INVALID_API_KEY`).",
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/ErrorResponse"},
            "example": {
              "error": {
                "code": "INVALID_API_KEY",
                "message": "The provided API key is invalid or expired",
                "timestamp": "2026-01-15T10:30:00Z"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "This API key exceeded its per-second rate limit (`RATE_LIMIT_EXCEEDED`).",
        "headers": {
          "X-RateLimit-Limit": {"$ref": "#/components/headers/XRateLimitLimit"},
          "X-RateLimit-Remaining": {"$ref": "#/components/headers/XRateLimitRemaining"},
          "X-RateLimit-Reset": {"$ref": "#/components/headers/XRateLimitReset"}
        },
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/ErrorResponse"},
            "example": {
              "error": {
                "code": "RATE_LIMIT_EXCEEDED",
                "message": "Too many requests",
                "timestamp": "2026-01-15T10:30:00Z"
              }
            }
          }
        }
      },
      "UpstreamFailure": {
        "description": "The upstream provider returned an error or no result was found (`EXTERNAL_API_ERROR`).",
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/ErrorResponse"},
            "example": {
              "error": {
                "code": "EXTERNAL_API_ERROR",
                "message": "Failed to geocode address: no results found",
                "timestamp": "2026-01-15T10:30:00Z"
              }
            }
          }
        }
      },
      "UpstreamNotConfigured": {
        "description": "The upstream geocoding provider is not configured on this deployment (`EXTERNAL_API_ERROR`).",
        "content": {
          "application/json": {
            "schema": {"$ref": "#/components/schemas/ErrorResponse"},
            "example": {
              "error": {
                "code": "EXTERNAL_API_ERROR",
                "message": "Google Geocoding API not configured",
                "timestamp": "2026-01-15T10:30:00Z"
              }
            }
          }
        }
      }
    },
    "schemas": {
      "GeocodeResponse": {
        "type": "object",
        "description": "A standardized forward-geocoding result.",
        "required": [
          "lat",
          "lng",
          "formatted_address",
          "state_name",
          "state_code",
          "country_name",
          "country_code",
          "backend",
          "raw_backend_response"
        ],
        "properties": {
          "lat": {"type": "number", "format": "double", "description": "Latitude.", "examples": [37.4223]},
          "lng": {"type": "number", "format": "double", "description": "Longitude.", "examples": [-122.0844]},
          "formatted_address": {"type": "string", "description": "The full address as normalized by the provider.", "examples": ["1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA"]},
          "state_name": {"type": "string", "description": "Full state or province name, empty when the provider returns none.", "examples": ["California"]},
          "state_code": {"type": "string", "description": "Short state or province code, empty when the provider returns none.", "examples": ["CA"]},
          "country_name": {"type": "string", "description": "Full country name.", "examples": ["United States"]},
          "country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code.", "examples": ["US"]},
          "backend": {"type": "string", "description": "Identifies the upstream provider, which tells you how to read `raw_backend_response`.", "const": "google_maps_platform_geocoding"},
          "raw_backend_response": {"type": "object", "description": "The complete, unmodified response from the Google Maps Platform Geocoding API.", "additionalProperties": true}
        }
      },
      "ReverseGeocodeResponse": {
        "type": "object",
        "description": "A standardized reverse-geocoding result with separated address components.",
        "required": [
          "lat",
          "lng",
          "formatted_address",
          "address_line_1",
          "city",
          "state",
          "state_full",
          "postal_code",
          "country_name",
          "country_code",
          "backend",
          "raw_backend_response"
        ],
        "properties": {
          "lat": {"type": "number", "format": "double", "description": "Latitude that was queried.", "examples": [37.422476]},
          "lng": {"type": "number", "format": "double", "description": "Longitude that was queried.", "examples": [-122.08425]},
          "formatted_address": {"type": "string", "description": "The full address as normalized by the provider.", "examples": ["1600 Amphitheatre Parkway, Mountain View, CA 94043, USA"]},
          "address_line_1": {"type": "string", "description": "Street number and street name.", "examples": ["1600 Amphitheatre Parkway"]},
          "city": {"type": "string", "description": "City name.", "examples": ["Mountain View"]},
          "state": {"type": "string", "description": "Short state or province code.", "examples": ["CA"]},
          "state_full": {"type": "string", "description": "Full state or province name.", "examples": ["California"]},
          "postal_code": {"type": "string", "description": "ZIP or postal code.", "examples": ["94043"]},
          "country_name": {"type": "string", "description": "Full country name.", "examples": ["United States"]},
          "country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code.", "examples": ["US"]},
          "backend": {"type": "string", "description": "Identifies the upstream provider.", "const": "google_maps_platform_geocoding"},
          "raw_backend_response": {"type": "object", "description": "The complete, unmodified response from the Google Maps Platform Geocoding API.", "additionalProperties": true}
        }
      },
      "GeoIPResponse": {
        "type": "object",
        "description": "A standardized IP geolocation result.",
        "required": [
          "lat",
          "lng",
          "ip",
          "city",
          "region",
          "country_name",
          "country_code",
          "postal_code",
          "timezone",
          "org",
          "backend",
          "raw_backend_response"
        ],
        "properties": {
          "lat": {"type": "number", "format": "double", "description": "Approximate latitude.", "examples": [37.4056]},
          "lng": {"type": "number", "format": "double", "description": "Approximate longitude.", "examples": [-122.0775]},
          "ip": {"type": "string", "description": "The IP address that was queried.", "examples": ["8.8.8.8"]},
          "city": {"type": "string", "description": "City name.", "examples": ["Mountain View"]},
          "region": {"type": "string", "description": "Region, state, or province.", "examples": ["California"]},
          "country_name": {"type": "string", "description": "Full country name.", "examples": ["United States"]},
          "country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code.", "examples": ["US"]},
          "postal_code": {"type": "string", "description": "ZIP or postal code.", "examples": ["94043"]},
          "timezone": {"type": "string", "description": "IANA timezone identifier.", "examples": ["America/Los_Angeles"]},
          "org": {"type": "string", "description": "Autonomous system and organization owning the address.", "examples": ["AS15169 Google LLC"]},
          "backend": {"type": "string", "description": "Identifies the upstream provider.", "const": "ipinfo_api"},
          "raw_backend_response": {"type": "object", "description": "The complete, unmodified response from the IPinfo API.", "additionalProperties": true}
        }
      },
      "HealthStatus": {
        "type": "object",
        "description": "Service health snapshot.",
        "required": ["status", "services", "timestamp", "database_connected"],
        "properties": {
          "status": {
            "type": "string",
            "description": "Overall service status. `degraded` means one upstream provider is unconfigured but the service still answers.",
            "enum": ["healthy", "degraded", "unhealthy"]
          },
          "services": {
            "type": "object",
            "description": "Per-dependency status keyed by dependency name.",
            "additionalProperties": {
              "type": "string",
              "enum": ["healthy", "unhealthy", "not_configured"]
            },
            "examples": [
              {
                "database": "healthy",
                "google_geocoding": "healthy",
                "ipinfo": "healthy"
              }
            ]
          },
          "timestamp": {"type": "string", "format": "date-time", "description": "When the check ran."},
          "database_connected": {"type": "boolean", "description": "Whether the PostgreSQL connection is live."}
        }
      },
      "ErrorResponse": {
        "type": "object",
        "description": "The error envelope returned by every endpoint.",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message", "timestamp"],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable, machine-readable error code.",
                "enum": [
                  "INVALID_API_KEY",
                  "RATE_LIMIT_EXCEEDED",
                  "INVALID_ADDRESS",
                  "INVALID_COORDINATES",
                  "INVALID_IP",
                  "EXTERNAL_API_ERROR",
                  "UNSUPPORTED_VERSION"
                ]
              },
              "message": {"type": "string", "description": "Human-readable explanation. Not stable; branch on `code` instead."},
              "timestamp": {"type": "string", "format": "date-time", "description": "When the error occurred."}
            }
          }
        }
      }
    }
  }
}
