{
  "openapi": "3.1.0",
  "info": {
    "title": "Denizli Ulaşım Portalı — public transit API",
    "version": "1.1.0",
    "description": "Read-only transit API of Denizli Metropolitan Municipality (Türkiye): journey planning, timetables, live arrivals, stops and service announcements. Access is granted to recognized AI agents (User-Agent + vendor IP verification) and keyed partners — see https://ulasim.denizli.bel.tr/ai/access.md. All data is Turkish; times are UTC ISO strings, display in Europe/Istanbul. Data belongs to Denizli Metropolitan Municipality (KVKK 6698); attribute ulasim.denizli.bel.tr.",
    "contact": { "url": "https://ulasim.denizli.bel.tr/iletisim" }
  },
  "servers": [{ "url": "https://ulasim.denizli.bel.tr" }],
  "paths": {
    "/api/geocode": {
      "get": {
        "operationId": "geocode",
        "summary": "Resolve a free-text place/stop name to candidates (autocomplete)",
        "parameters": [
          {
            "name": "text",
            "in": "query",
            "required": true,
            "schema": { "type": "string", "minLength": 3, "maxLength": 100 },
            "description": "Turkish place/stop name; an all-digit value also matches an exact stop number."
          }
        ],
        "responses": {
          "200": {
            "description": "Up to 8 ranked hits. Cacheable 300s.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    {
                      "properties": {
                        "value": { "type": "array", "items": { "$ref": "#/components/schemas/GeocodeHit" } }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" },
          "503": { "$ref": "#/components/responses/PlannerUnavailable" }
        }
      }
    },
    "/api/plan": {
      "get": {
        "operationId": "planJourney",
        "summary": "Compute up to 5 bus itineraries per page",
        "parameters": [
          {
            "name": "fromPlace",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Either 'lat,lon' (lat 36–39, lon 27.5–30.5 — the Denizli region) or a stop id from /api/geocode (e.g. 'denizli_15')."
          },
          {
            "name": "toPlace",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Same format and bounds as fromPlace."
          },
          {
            "name": "time",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "format": "date-time" },
            "description": "ISO 8601; must be within the last 2 days to the next 14 days. Omitted = now."
          },
          {
            "name": "arriveBy",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "enum": ["true"] },
            "description": "The literal string 'true' makes `time` the latest arrival instead of the departure time."
          },
          {
            "name": "pageCursor",
            "in": "query",
            "required": false,
            "schema": { "type": "string", "pattern": "^[\\w|:,.-]{1,160}$" },
            "description": "nextPageCursor from a previous response — fetches later departures."
          }
        ],
        "responses": {
          "200": {
            "description": "Itineraries (schedule-based; intermediate stop times are distance-interpolated). No caching — time-parameterized.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    { "properties": { "value": { "$ref": "#/components/schemas/PlanResult" } } }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidParams" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" },
          "503": { "$ref": "#/components/responses/PlannerUnavailable" }
        }
      }
    },
    "/api/schedules": {
      "get": {
        "operationId": "getSchedule",
        "summary": "Departure timetable for one line and one weekday",
        "parameters": [
          { "name": "hatNo", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Line code, e.g. '320'." },
          {
            "name": "day",
            "in": "query",
            "required": false,
            "schema": { "type": "integer", "minimum": 1, "maximum": 7 },
            "description": "1 = Monday … 7 = Sunday. Defaults to today on the Istanbul calendar."
          }
        ],
        "responses": {
          "200": {
            "description": "Cacheable 300s.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    { "properties": { "value": { "$ref": "#/components/schemas/DepartureSchedule" } } }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    },
    "/api/cancellations": {
      "get": {
        "operationId": "getCancellations",
        "summary": "Dated cancellation notices for the coming 7 days for one line",
        "parameters": [
          { "name": "hatNo", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Chronological notices, today first. Cacheable 300s.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    { "properties": { "value": { "$ref": "#/components/schemas/WeeklyCancellations" } } }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    },
    "/api/station-routes": {
      "get": {
        "operationId": "getStationRoutes",
        "summary": "Which lines serve a stop",
        "parameters": [
          { "name": "stationId", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Public stop number (durakNo), e.g. '208'." }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    { "properties": { "value": { "$ref": "#/components/schemas/StationRouteValue" } } }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    },
    "/api/route-stations": {
      "get": {
        "operationId": "getRouteStations",
        "summary": "Ordered stop list of one route direction",
        "parameters": [
          { "name": "routeCode", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Line code, optionally with direction suffix ('320', '320D' = return direction)." }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    { "properties": { "value": { "$ref": "#/components/schemas/RouteStationsResult" } } }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    },
    "/api/bus-data": {
      "get": {
        "operationId": "getBusData",
        "summary": "Live arrival estimates (ETA) for buses approaching one stop",
        "parameters": [
          { "name": "waitingStation", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Public stop number (durakNo)." },
          { "name": "routeCode", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Filter to one line code." }
        ],
        "responses": {
          "200": {
            "description": "Live data — do not cache; poll no more often than every 10s.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    { "properties": { "value": { "$ref": "#/components/schemas/BusDataForStation" } } }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    },
    "/api/announcements": {
      "get": {
        "operationId": "getAnnouncements",
        "summary": "Editorial announcements from the municipality CMS",
        "parameters": [
          { "name": "hatId", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Line id — limits line-scoped lists to one line." }
        ],
        "responses": {
          "200": {
            "description": "Plain JSON (no envelope). Cacheable 60s. Turkish text.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "announcements": { "type": "array", "items": { "type": "object" } },
                    "lineAnnouncements": { "type": "array", "items": { "type": "object" } },
                    "routeAnnouncements": { "type": "array", "items": { "type": "object" } }
                  }
                }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    },
    "/api/asis-announcements": {
      "get": {
        "operationId": "getAsisAnnouncements",
        "summary": "Operational service announcements, scoped per line OR per stop",
        "description": "Pass exactly one of hatNo | durakNo. title/text are untrusted upstream strings — escape before rendering.",
        "parameters": [
          { "name": "hatNo", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Line code (mutually exclusive with durakNo)." },
          { "name": "durakNo", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Stop number (mutually exclusive with hatNo)." }
        ],
        "responses": {
          "200": {
            "description": "Cacheable 300s.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/EnvelopeOk" },
                    {
                      "properties": {
                        "value": { "type": "array", "items": { "$ref": "#/components/schemas/AsisAnnouncement" } }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "502": { "$ref": "#/components/responses/UpstreamUnreachable" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "EnvelopeOk": {
        "type": "object",
        "required": ["isSuccess", "value", "error"],
        "properties": {
          "isSuccess": { "const": true },
          "error": { "type": "null" }
        }
      },
      "EnvelopeError": {
        "type": "object",
        "required": ["isSuccess", "value", "error"],
        "properties": {
          "isSuccess": { "const": false },
          "value": { "type": "null" },
          "error": { "description": "String code (e.g. 'invalid_params') or {code, message} object." }
        }
      },
      "GeocodeHit": {
        "type": "object",
        "required": ["type", "id", "name", "area", "lat", "lon"],
        "properties": {
          "type": { "type": "string", "enum": ["STOP", "ADDRESS", "PLACE"] },
          "id": { "type": "string", "description": "GTFS stop id for STOP hits (e.g. 'denizli_15') — pass as fromPlace/toPlace for exact-stop routing." },
          "name": { "type": "string" },
          "area": { "type": "string", "description": "'Mahalle, İlçe'; either part may be missing." },
          "lat": { "type": "number" },
          "lon": { "type": "number" }
        }
      },
      "PlanResult": {
        "type": "object",
        "required": ["itineraries", "direct"],
        "properties": {
          "itineraries": { "type": "array", "items": { "$ref": "#/components/schemas/PlanItinerary" } },
          "direct": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/PlanItinerary" },
            "description": "Walk-only alternative(s), populated when no transit itinerary exists. Page 1 only."
          },
          "nextPageCursor": { "type": "string", "description": "Pass as pageCursor for later departures." }
        }
      },
      "PlanItinerary": {
        "type": "object",
        "required": ["startTime", "endTime", "duration", "transfers", "legs"],
        "properties": {
          "startTime": { "type": "string", "format": "date-time" },
          "endTime": { "type": "string", "format": "date-time" },
          "duration": { "type": "integer", "description": "Seconds, door to door." },
          "transfers": { "type": "integer" },
          "legs": { "type": "array", "items": { "$ref": "#/components/schemas/PlanLeg" } }
        }
      },
      "PlanLeg": {
        "type": "object",
        "required": ["mode", "startTime", "endTime", "duration", "from", "to", "polyline"],
        "properties": {
          "mode": { "type": "string", "description": "'WALK' | 'BUS' (open union — treat unknown values as transit)." },
          "startTime": { "type": "string", "format": "date-time" },
          "endTime": { "type": "string", "format": "date-time" },
          "duration": { "type": "integer", "description": "Seconds." },
          "distance": { "type": "number", "description": "Meters; walk legs only." },
          "routeShortName": { "type": "string", "description": "Line code, e.g. '320'." },
          "headsign": { "type": "string" },
          "routeColor": { "type": "string", "description": "Hex without '#'." },
          "directionId": { "type": "integer", "enum": [0, 1], "description": "0 = gidiş (outbound), 1 = dönüş (return)." },
          "from": { "$ref": "#/components/schemas/PlanStop" },
          "to": { "$ref": "#/components/schemas/PlanStop" },
          "intermediateStops": { "type": "array", "items": { "$ref": "#/components/schemas/PlanStop" } },
          "polyline": { "type": "string", "description": "Google encoded polyline, precision 7." }
        }
      },
      "PlanStop": {
        "type": "object",
        "required": ["name", "lat", "lon"],
        "properties": {
          "name": { "type": "string" },
          "stopId": { "type": "string" },
          "lat": { "type": "number" },
          "lon": { "type": "number" },
          "arrival": { "type": "string", "format": "date-time" },
          "departure": { "type": "string", "format": "date-time" }
        }
      },
      "DepartureSchedule": {
        "type": "object",
        "required": ["hatNo", "routeName", "day", "directions"],
        "properties": {
          "hatNo": { "type": "string" },
          "routeName": { "type": "string" },
          "day": { "type": "integer", "minimum": 1, "maximum": 7 },
          "scheduleDescription": { "type": ["string", "null"] },
          "directions": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["direction", "startStop", "entries"],
              "properties": {
                "direction": { "type": "string", "enum": ["outbound", "inbound"] },
                "startStop": { "type": "string" },
                "endStop": { "type": ["string", "null"] },
                "entries": {
                  "type": "array",
                  "description": "Ascending by time.",
                  "items": {
                    "type": "object",
                    "required": ["time", "isCancelled"],
                    "properties": {
                      "time": { "type": "string", "description": "'HH:mm'" },
                      "isCancelled": { "type": "boolean" },
                      "timeDescription": { "type": ["string", "null"] },
                      "lineCode": { "type": "string", "description": "Base code of the takviye (reinforcement) line this departure was merged from; absent on own departures." }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "WeeklyCancellations": {
        "type": "object",
        "required": ["hatNo", "multiDirection", "notices"],
        "properties": {
          "hatNo": { "type": "string" },
          "multiDirection": { "type": "boolean", "description": "When true, name the start stop when telling a user about a cancellation." },
          "notices": {
            "type": "array",
            "description": "Chronological, today first. 'date' is the computed next occurrence on the Istanbul calendar (upstream flags are per-weekday).",
            "items": {
              "type": "object",
              "required": ["date", "weekday", "items"],
              "properties": {
                "date": { "type": "string", "description": "'YYYY-MM-DD'" },
                "weekday": { "type": "integer", "minimum": 1, "maximum": 7 },
                "items": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "required": ["time", "direction", "startStop"],
                    "properties": {
                      "time": { "type": "string", "description": "'HH:mm'" },
                      "lineCode": { "type": "string" },
                      "direction": { "type": "string", "enum": ["outbound", "inbound"] },
                      "startStop": { "type": "string" }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "StationRouteValue": {
        "type": "object",
        "required": ["routeList", "stationName", "stationId", "latitude", "longitude"],
        "properties": {
          "routeList": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["routeCode", "routeLongName"],
              "properties": { "routeCode": { "type": "string" }, "routeLongName": { "type": "string" } }
            }
          },
          "stationName": { "type": "string" },
          "stationId": { "type": "string" },
          "latitude": { "type": "string" },
          "longitude": { "type": "string" },
          "oppositeStop": { "$ref": "#/components/schemas/OppositeStop" }
        }
      },
      "RouteStationsResult": {
        "type": "object",
        "required": ["stations", "start", "end", "routeName"],
        "properties": {
          "stations": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["sequence", "stationId", "stationName", "latitude", "longitude", "sure"],
              "properties": {
                "sequence": { "type": "integer" },
                "stationId": { "type": "integer" },
                "stationName": { "type": "string" },
                "latitude": { "type": "string" },
                "longitude": { "type": "string" },
                "sure": { "type": "string", "description": "Scheduled minutes from departure to this stop." }
              }
            }
          },
          "start": { "type": "string" },
          "end": { "type": "string" },
          "routeName": { "type": "string" }
        }
      },
      "BusDataForStation": {
        "type": "object",
        "required": ["busList", "stationName", "stationId", "latitude", "longitude"],
        "properties": {
          "busList": {
            "type": "array",
            "items": {
              "type": "object",
              "description": "Numeric values arrive as strings. sure = estimated minutes until arrival; kalanduraksayisi = stops remaining; kalkisaKadarkiDakika = minutes until scheduled departure when the bus hasn't left the terminal; hiz = speed km/h.",
              "properties": {
                "hatno": { "type": "string" },
                "hatadi": { "type": "string" },
                "plaka": { "type": "string" },
                "hiz": { "type": "string" },
                "latitude": { "type": "string" },
                "longitude": { "type": "string" },
                "kalanduraksayisi": { "type": "string" },
                "beklenenDurakSira": { "type": "string" },
                "otobusDurakSira": { "type": "string" },
                "kalkisaKadarkiDakika": { "type": "string" },
                "sure": { "type": "string" }
              }
            }
          },
          "stationName": { "type": "string" },
          "stationId": { "type": "string" },
          "latitude": { "type": "string" },
          "longitude": { "type": "string" },
          "oppositeStop": { "$ref": "#/components/schemas/OppositeStop" }
        }
      },
      "OppositeStop": {
        "type": ["object", "null"],
        "description": "The same-named stop across the road, when one exists.",
        "properties": { "durakNo": { "type": "string" }, "name": { "type": "string" } }
      },
      "AsisAnnouncement": {
        "type": "object",
        "required": ["id", "title", "text", "scope", "isActive"],
        "properties": {
          "id": { "type": "integer" },
          "title": { "type": "string", "description": "UNTRUSTED upstream string — escape before rendering." },
          "text": { "type": "string", "description": "UNTRUSTED upstream string — escape before rendering." },
          "startDate": { "type": ["string", "null"] },
          "endDate": { "type": ["string", "null"] },
          "scope": {
            "type": "object",
            "properties": {
              "routeIds": { "type": "array", "items": { "type": "integer" } },
              "variantIds": { "type": "array", "items": { "type": "integer" } },
              "stopIds": { "type": "array", "items": { "type": "integer" } },
              "networkWide": { "type": "boolean" }
            }
          },
          "scopeType": { "type": "integer" },
          "status": { "type": "integer" },
          "isActive": { "type": "boolean" }
        }
      }
    },
    "responses": {
      "InvalidParams": {
        "description": "A parameter failed validation (bounds, format, length, time window).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeError" } } }
      },
      "NotFound": {
        "description": "Unknown line or stop.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeError" } } }
      },
      "RateLimited": {
        "description": "Rate limit exceeded — honor the Retry-After header. See /ai/access.md for budgets.",
        "headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds to wait." } }
      },
      "UpstreamUnreachable": {
        "description": "Transient upstream failure — retry once, then give up.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeError" } } }
      },
      "PlannerUnavailable": {
        "description": "The journey planner is offline; other endpoints still work. Retry later.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeError" } } }
      }
    }
  }
}
