{
  "openapi": "3.1.0",
  "info": {
    "title": "Transit Lab",
    "version": "1.0.0",
    "summary": "Open data and small web API for Transit Lab, independent research on New York City transit.",
    "description": "Transit Lab publishes independent research and open data on New York City transit. This description covers four things: the nightly JSON files behind the site's trackers (transitlab.nyc/data), the open data catalog and Parquet files (data.transitlab.nyc, a separate server given per path), the SQL API (api.transitlab.nyc, also given per path), and the form endpoints. The files and feeds are read-only and need no key. The SQL API runs read-only DuckDB SQL on our servers and needs a free key (Authorization: Bearer tl_live_…) from https://transitlab.nyc/api-keys. Data is CC BY 4.0: credit Transit Lab and link back. Docs on how each number is made: https://docs.transitlab.nyc.",
    "contact": {
      "name": "Transit Lab",
      "email": "hello@transitlab.nyc",
      "url": "https://transitlab.nyc/contact"
    },
    "license": {
      "name": "CC BY 4.0",
      "identifier": "CC-BY-4.0"
    }
  },
  "externalDocs": {
    "description": "How each number is made",
    "url": "https://docs.transitlab.nyc"
  },
  "servers": [
    {
      "url": "https://transitlab.nyc",
      "description": "The website"
    }
  ],
  "tags": [
    {
      "name": "Site data",
      "description": "Nightly JSON behind the trackers."
    },
    {
      "name": "Open data",
      "description": "Catalog, Parquet files and DuckDB database on data.transitlab.nyc."
    },
    {
      "name": "SQL API",
      "description": "Read-only SQL on every open table, on api.transitlab.nyc. Free key required for SQL."
    },
    {
      "name": "Forms",
      "description": "Mailing list and get-involved forms."
    }
  ],
  "paths": {
    "/data/slow-zones.json": {
      "get": {
        "operationId": "getSlowZones",
        "summary": "Subway slow zones",
        "description": "Stretches of subway track where trains run well over their usual time for days on end, with the time lost each week, the zones active now and the biggest since 2022. Estimated from the MTA's train feed; rebuilt every night.",
        "tags": [
          "Site data"
        ],
        "responses": {
          "200": {
            "description": "The JSON file.",
            "headers": {
              "Cache-Control": {
                "description": "public, max-age=300",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlowZones"
                }
              }
            }
          },
          "404": {
            "description": "The file has not been built yet.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/data/bus-speeds.json": {
      "get": {
        "operationId": "getBusSpeeds",
        "summary": "Bus speeds",
        "description": "Bus speeds by route, borough and month since 2019, with the congestion zone's inside and outside speeds. Rebuilt every night from MTA data and our own bus-position archive.",
        "tags": [
          "Site data"
        ],
        "responses": {
          "200": {
            "description": "The JSON file.",
            "headers": {
              "Cache-Control": {
                "description": "public, max-age=300",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusSpeeds"
                }
              }
            }
          },
          "404": {
            "description": "The file has not been built yet.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/data/bus-lanes.json": {
      "get": {
        "operationId": "getBusLanes",
        "summary": "Bus lanes and lost bus time",
        "description": "Where buses lose time to traffic, by street and by stop-to-stop stretch, and which blocks have a bus lane. Rebuilt every night. About 1.7 MB.",
        "tags": [
          "Site data"
        ],
        "responses": {
          "200": {
            "description": "The JSON file.",
            "headers": {
              "Cache-Control": {
                "description": "public, max-age=300",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BusLanes"
                }
              }
            }
          },
          "404": {
            "description": "The file has not been built yet.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/config": {
      "get": {
        "operationId": "getFormsConfig",
        "summary": "Form bot-check settings",
        "description": "Returns the Turnstile site key that the site's forms use for their invisible bot check.",
        "tags": [
          "Forms"
        ],
        "responses": {
          "200": {
            "description": "The site key, or null when the check is off.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormsConfig"
                }
              }
            }
          }
        }
      }
    },
    "/api/subscribe": {
      "post": {
        "operationId": "subscribeToMailingList",
        "summary": "Join the mailing list",
        "description": "Subscribes an email address to Transit Lab's mailing list: new research by email, a few times a year, with a one-click unsubscribe in every email. Subscribing is immediate and sends one welcome email. The reply is the same whether or not the address was already on the list. Bots are screened by a Turnstile token and an empty honeypot field; each IP address may post 5 times a minute.",
        "tags": [
          "Forms"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to subscribe."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from, e.g. /research. Stored with the entry."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token. The site's pages fetch one with an invisible widget (site key from GET /api/config). Required: a request without a token, or with one Cloudflare rejects, gets a 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty. Anything in it is treated as a bot and the request is silently dropped."
                  }
                },
                "required": [
                  "email"
                ]
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to subscribe."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from, e.g. /research. Stored with the entry."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token. The site's pages fetch one with an invisible widget (site key from GET /api/config). Required: a request without a token, or with one Cloudflare rejects, gets a 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty. Anything in it is treated as a bot and the request is silently dropped."
                  }
                },
                "required": [
                  "email"
                ]
              }
            },
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to subscribe."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from, e.g. /research. Stored with the entry."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token. The site's pages fetch one with an invisible widget (site key from GET /api/config). Required: a request without a token, or with one Cloudflare rejects, gets a 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty. Anything in it is treated as a bot and the request is silently dropped."
                  }
                },
                "required": [
                  "email"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Subscribed (or silently ignored as a bot).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormOk"
                }
              }
            }
          },
          "400": {
            "description": "The form is incomplete or an address is not valid. `error` names the problem and `field` the field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "403": {
            "description": "Cloudflare Turnstile rejected the `token` (`error` is `bot_check`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "429": {
            "description": "More than 5 posts a minute from this IP address to this form (`error` is `rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "500": {
            "description": "Server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          }
        }
      }
    },
    "/api/contact": {
      "post": {
        "operationId": "sendContactMessage",
        "summary": "Send a get-involved message",
        "description": "Sends a message to the Transit Lab team from the Get involved page: who you are, what you would like to help with (data, design, writing, organizing) and a note. The team replies by email. Bots are screened by a Turnstile token and an empty honeypot field; each IP address may post 5 times a minute.",
        "tags": [
          "Forms"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 120,
                    "minLength": 1,
                    "description": "Your name."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to reply to."
                  },
                  "interests": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "data",
                        "design",
                        "writing",
                        "organizing",
                        "other"
                      ]
                    },
                    "description": "What you would like to help with. Repeat the field for each choice in a form-encoded body."
                  },
                  "message": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 5000,
                    "description": "The message."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from, e.g. /research. Stored with the entry."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token. The site's pages fetch one with an invisible widget (site key from GET /api/config). Required: a request without a token, or with one Cloudflare rejects, gets a 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty. Anything in it is treated as a bot and the request is silently dropped."
                  }
                },
                "required": [
                  "name",
                  "email",
                  "message"
                ]
              }
            },
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 120,
                    "minLength": 1,
                    "description": "Your name."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to reply to."
                  },
                  "interests": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "data",
                        "design",
                        "writing",
                        "organizing",
                        "other"
                      ]
                    },
                    "description": "What you would like to help with. Repeat the field for each choice in a form-encoded body."
                  },
                  "message": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 5000,
                    "description": "The message."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from, e.g. /research. Stored with the entry."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token. The site's pages fetch one with an invisible widget (site key from GET /api/config). Required: a request without a token, or with one Cloudflare rejects, gets a 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty. Anything in it is treated as a bot and the request is silently dropped."
                  }
                },
                "required": [
                  "name",
                  "email",
                  "message"
                ]
              }
            },
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 120,
                    "minLength": 1,
                    "description": "Your name."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to reply to."
                  },
                  "interests": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "data",
                        "design",
                        "writing",
                        "organizing",
                        "other"
                      ]
                    },
                    "description": "What you would like to help with. Repeat the field for each choice in a form-encoded body."
                  },
                  "message": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 5000,
                    "description": "The message."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from, e.g. /research. Stored with the entry."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token. The site's pages fetch one with an invisible widget (site key from GET /api/config). Required: a request without a token, or with one Cloudflare rejects, gets a 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty. Anything in it is treated as a bot and the request is silently dropped."
                  }
                },
                "required": [
                  "name",
                  "email",
                  "message"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Message received (or silently ignored as a bot).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormOk"
                }
              }
            }
          },
          "400": {
            "description": "The form is incomplete or an address is not valid. `error` names the problem and `field` the field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "403": {
            "description": "Cloudflare Turnstile rejected the `token` (`error` is `bot_check`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "429": {
            "description": "More than 5 posts a minute from this IP address to this form (`error` is `rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "500": {
            "description": "Server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/catalog.json": {
      "servers": [
        {
          "url": "https://data.transitlab.nyc",
          "description": "Open data downloads"
        }
      ],
      "get": {
        "operationId": "getDataCatalog",
        "summary": "Open data catalog",
        "description": "Lists every open table: description, source, license, row count, size, columns with types and descriptions, and the URL of each Parquet file. Rebuilt every night. Start here to find files.",
        "tags": [
          "Open data"
        ],
        "responses": {
          "200": {
            "description": "The catalog.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Catalog"
                }
              }
            }
          }
        }
      }
    },
    "/v1/{table}.parquet": {
      "servers": [
        {
          "url": "https://data.transitlab.nyc",
          "description": "Open data downloads"
        }
      ],
      "get": {
        "operationId": "getSmallTableParquet",
        "summary": "Download a small table as one Parquet file",
        "description": "The whole of a small table in a single Parquet file. The catalog lists the same URL in the table's files. Supports HTTP Range requests, so a client such as DuckDB can read only the parts it needs.",
        "tags": [
          "Open data"
        ],
        "parameters": [
          {
            "name": "table",
            "in": "path",
            "required": true,
            "description": "Table name.",
            "schema": {
              "type": "string",
              "enum": [
                "subway_slow_zones",
                "subway_slow_zones_weekly",
                "bus_route_speeds",
                "bus_lanes",
                "bus_lane_streets"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The Parquet file.",
            "headers": {
              "Accept-Ranges": {
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/vnd.apache.parquet": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "No such table."
          }
        }
      }
    },
    "/v1/{table}/year={year}/month={month}/{file}.parquet": {
      "servers": [
        {
          "url": "https://data.transitlab.nyc",
          "description": "Open data downloads"
        }
      ],
      "get": {
        "operationId": "getPartitionedTableParquet",
        "summary": "Download one month or day of a large table",
        "description": "Large tables are split by time: one file a month (`2026-08.parquet`) or one a day for the busiest feeds (`2026-10-05.parquet`). A month that would make a file over 450 MB (`fhv_trips`) comes as two parts, `2025-04.part-0.parquet` (days 1 to 15) and `2025-04.part-1.parquet` (day 16 on). Take the exact URLs from the catalog's files list. Supports HTTP Range requests, so a client such as DuckDB can read only the parts it needs.",
        "tags": [
          "Open data"
        ],
        "parameters": [
          {
            "name": "table",
            "in": "path",
            "required": true,
            "description": "Table name.",
            "schema": {
              "type": "string",
              "enum": [
                "subway_segment_times",
                "subway_stop_events",
                "bus_vehicle_positions",
                "bus_predictions",
                "transit_alerts",
                "elevator_outages",
                "elevator_equipment",
                "taxi_yellow_trips",
                "taxi_green_trips",
                "fhv_trips"
              ]
            }
          },
          {
            "name": "year",
            "in": "path",
            "required": true,
            "description": "Four-digit year.",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}$"
            }
          },
          {
            "name": "month",
            "in": "path",
            "required": true,
            "description": "Two-digit month.",
            "schema": {
              "type": "string",
              "pattern": "^(0[1-9]|1[0-2])$"
            }
          },
          {
            "name": "file",
            "in": "path",
            "required": true,
            "description": "File name without extension: YYYY-MM for a month, YYYY-MM-DD for a day, YYYY-MM.part-N for one part of a split month.",
            "schema": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}((-\\d{2})|(\\.part-\\d+))?$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The Parquet file.",
            "headers": {
              "Accept-Ranges": {
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/vnd.apache.parquet": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "No such file."
          }
        }
      }
    },
    "/v1/transitlab.duckdb": {
      "servers": [
        {
          "url": "https://data.transitlab.nyc",
          "description": "Open data downloads"
        }
      ],
      "get": {
        "operationId": "getDuckDbDatabase",
        "summary": "DuckDB database with a view per table",
        "description": "A small DuckDB file with one view per table over the Parquet URLs. Attach it read-only from DuckDB with `ATTACH 'https://data.transitlab.nyc/v1/transitlab.duckdb' AS transitlab (READ_ONLY);` and query every table without downloading it. Supports HTTP Range requests, so a client such as DuckDB can read only the parts it needs.",
        "tags": [
          "Open data"
        ],
        "responses": {
          "200": {
            "description": "The DuckDB file.",
            "headers": {
              "Accept-Ranges": {
                "schema": {
                  "type": "string"
                }
              },
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        }
      }
    },
    "/v1/health": {
      "servers": [
        {
          "url": "https://api.transitlab.nyc",
          "description": "SQL API (needs a free key for SQL)"
        }
      ],
      "get": {
        "operationId": "getApiHealth",
        "summary": "SQL API health",
        "tags": [
          "SQL API"
        ],
        "description": "Says whether the API is up and which catalog it serves. Needs no key and does not start the query engine, which sleeps when idle; the first query after a quiet spell takes a few seconds longer.",
        "security": [],
        "responses": {
          "200": {
            "description": "Up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiHealth"
                }
              }
            }
          },
          "503": {
            "description": "The catalog could not be read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/tables": {
      "servers": [
        {
          "url": "https://api.transitlab.nyc",
          "description": "SQL API (needs a free key for SQL)"
        }
      ],
      "get": {
        "operationId": "listApiTables",
        "summary": "List tables",
        "tags": [
          "SQL API"
        ],
        "description": "Every table you can query: name, description, rows, size, number of files and the days it covers. From the open data catalog. A key is optional here; without one, requests count per IP address against the same 60-a-minute limit.",
        "security": [
          {},
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The tables.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TableList"
                }
              }
            }
          },
          "401": {
            "description": "A key was sent but is not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Too many requests.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/tables/{name}": {
      "servers": [
        {
          "url": "https://api.transitlab.nyc",
          "description": "SQL API (needs a free key for SQL)"
        }
      ],
      "get": {
        "operationId": "describeApiTable",
        "summary": "Describe a table",
        "tags": [
          "SQL API"
        ],
        "description": "One table's columns (name, type, meaning), source, license and file URLs. Partitioned tables also list the year and month columns to filter on. A key is optional.",
        "security": [
          {},
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "description": "A table name from GET /v1/tables.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9_]+$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The table.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TableDetail"
                }
              }
            }
          },
          "404": {
            "description": "No such table.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Too many requests.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/sql": {
      "servers": [
        {
          "url": "https://api.transitlab.nyc",
          "description": "SQL API (needs a free key for SQL)"
        }
      ],
      "post": {
        "operationId": "runSql",
        "summary": "Run a SQL query",
        "tags": [
          "SQL API"
        ],
        "description": "Runs one read-only DuckDB query (SELECT or WITH) on the open tables and returns the rows as JSON, CSV or Parquet. Needs a key. Limits per key: 60 requests a minute, 1,000 queries and 10 minutes of query time a day (UTC), two queries at a time; each query stops after 30 seconds and returns at most 10,000 rows. The same query (ignoring spacing and comments) within 10 minutes comes from a cache and costs no quota. Functions that read files or other servers are refused. A plain-text body (Content-Type: text/plain) is taken as the SQL itself.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SqlRequest"
              },
              "example": {
                "sql": "select route_id, count(*) as stops from subway_stop_events where year = 2026 and month = 10 group by 1 order by 2 desc limit 5"
              }
            },
            "text/plain": {
              "schema": {
                "type": "string",
                "description": "The SQL itself."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The answer. X-Cache says whether it came from the cache.",
            "headers": {
              "RateLimit-Policy": {
                "description": "The key's limits: requests a minute and queries a day (IETF RateLimit headers).",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit": {
                "description": "Queries left today and seconds until the count resets.",
                "schema": {
                  "type": "string"
                }
              },
              "X-Rows": {
                "description": "Rows returned.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Truncated": {
                "description": "true if cut at the limit.",
                "schema": {
                  "type": "string"
                }
              },
              "X-Elapsed-Ms": {
                "description": "Engine time in milliseconds.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Bytes-Scanned": {
                "description": "Bytes read from the files.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-Cache": {
                "description": "hit or miss.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SqlResult"
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string",
                  "description": "CSV with a header row."
                }
              },
              "application/vnd.apache.parquet": {
                "schema": {
                  "type": "string",
                  "format": "binary",
                  "description": "A Parquet file."
                }
              }
            }
          },
          "400": {
            "description": "The query is not one read-only statement, reads something other than the tables, or has an error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "No key, or the key is unknown or revoked. The body links to https://transitlab.nyc/api-keys.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            },
            "headers": {
              "WWW-Authenticate": {
                "description": "Bearer.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "408": {
            "description": "The query ran longer than 30 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "description": "The body is over 64 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "415": {
            "description": "The body is not JSON or plain text.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "The query needed more memory than the engine allows.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Over the per-minute limit, the daily quota, or the running-query limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before trying again.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Policy": {
                "description": "The key's limits: requests a minute and queries a day (IETF RateLimit headers).",
                "schema": {
                  "type": "string"
                }
              },
              "RateLimit": {
                "description": "Queries left today and seconds until the count resets.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "The engine is busy or starting, or the service's daily budget is spent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before trying again.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          }
        }
      }
    },
    "/mcp": {
      "servers": [
        {
          "url": "https://api.transitlab.nyc",
          "description": "SQL API (needs a free key for SQL)"
        }
      ],
      "post": {
        "operationId": "mcpEndpoint",
        "summary": "MCP server for AI agents",
        "tags": [
          "SQL API"
        ],
        "description": "A Model Context Protocol server over streamable HTTP (JSON responses, no event stream). Tools: list_tables, describe_table and run_sql, the same as the local `transitlab mcp`. Send the same key as for /v1/sql. Queries count against the same limits.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "description": "A JSON-RPC 2.0 message (or a batch)."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The JSON-RPC answer.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "A JSON-RPC 2.0 response."
                }
              }
            }
          },
          "202": {
            "description": "A notification was accepted."
          },
          "401": {
            "description": "No key, or a bad one.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/keys": {
      "post": {
        "operationId": "requestApiKey",
        "summary": "Get an API key by email",
        "tags": [
          "Forms"
        ],
        "description": "Emails a free key for the SQL API (api.transitlab.nyc) from hello@transitlab.nyc. We store only a SHA-256 of the key, so a lost key can't be sent again; ask for a new one. One new key per address every 10 minutes and three working keys per address. The reply is the same whether or not a key went out. Bots are screened by a Turnstile token and a honeypot field; each IP address may post 5 times a minute.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to send the key to."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token (site key from GET /api/config). Required: without one, or with one Cloudflare rejects, the answer is 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty."
                  }
                },
                "required": [
                  "email",
                  "token"
                ]
              }
            },
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254,
                    "description": "Address to send the key to."
                  },
                  "page": {
                    "type": "string",
                    "pattern": "^/[\\w\\-./]*$",
                    "maxLength": 200,
                    "description": "Path of the page the form was sent from."
                  },
                  "token": {
                    "type": "string",
                    "maxLength": 2048,
                    "description": "Cloudflare Turnstile response token (site key from GET /api/config). Required: without one, or with one Cloudflare rejects, the answer is 403."
                  },
                  "website": {
                    "type": "string",
                    "description": "Honeypot: leave empty."
                  }
                },
                "required": [
                  "email",
                  "token"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Subscribed (or silently ignored as a bot).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormOk"
                }
              }
            }
          },
          "400": {
            "description": "The form is incomplete or an address is not valid. `error` names the problem and `field` the field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "403": {
            "description": "Cloudflare Turnstile rejected the `token` (`error` is `bot_check`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "429": {
            "description": "More than 5 posts a minute from this IP address to this form (`error` is `rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          },
          "500": {
            "description": "Server error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FormError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "SlowZoneRow": {
        "type": "object",
        "properties": {
          "segment": {
            "type": "string",
            "description": "Two stations in a row, e.g. \"Queensboro Plaza → 60 St\"."
          },
          "routes": {
            "type": "string",
            "description": "Routes that run the segment, space separated."
          },
          "start": {
            "type": "string",
            "format": "date",
            "description": "First day of the slow zone."
          },
          "end": {
            "type": "string",
            "format": "date",
            "description": "Last day seen slow."
          },
          "slowDays": {
            "type": "integer",
            "description": "Weekdays the segment ran slow."
          },
          "baselineS": {
            "type": "number",
            "description": "Usual run time, seconds."
          },
          "extraS": {
            "type": "number",
            "description": "Extra seconds a train takes."
          },
          "extraPct": {
            "type": "number",
            "description": "Extra time as a percent of the usual time."
          },
          "riders": {
            "type": "integer",
            "description": "Riders affected on a typical weekday."
          },
          "riderHoursLost": {
            "type": "integer",
            "description": "Rider-hours lost in total over the zone's life."
          },
          "trainHoursLost": {
            "type": "number",
            "description": "Train-hours lost in total."
          }
        },
        "required": [
          "segment",
          "routes",
          "start",
          "end",
          "slowDays",
          "baselineS",
          "extraS",
          "extraPct",
          "riders",
          "riderHoursLost",
          "trainHoursLost"
        ]
      },
      "SlowZones": {
        "type": "object",
        "properties": {
          "updated": {
            "type": "string",
            "format": "date",
            "description": "Day the file was built."
          },
          "dataThrough": {
            "type": "string",
            "format": "date",
            "description": "Last day of train data."
          },
          "repo": {
            "type": "string",
            "description": "Address of the code repository (older builds only)."
          },
          "summary": {
            "type": "object",
            "properties": {
              "active": {
                "type": "integer",
                "description": "Slow zones active now."
              },
              "riderHoursPerWeekday": {
                "type": "integer",
                "description": "Rider-hours lost each weekday."
              },
              "trainHoursPerWeekday": {
                "type": "integer",
                "description": "Train-hours lost each weekday."
              }
            },
            "required": [
              "active",
              "riderHoursPerWeekday",
              "trainHoursPerWeekday"
            ]
          },
          "weekly": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "week": {
                  "type": "string",
                  "format": "date",
                  "description": "Monday of the week."
                },
                "riderHours": {
                  "type": "integer"
                },
                "trainHours": {
                  "type": "number"
                }
              },
              "required": [
                "week",
                "riderHours",
                "trainHours"
              ]
            },
            "description": "Extra time per week since 2022."
          },
          "active": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SlowZoneRow"
            },
            "description": "Zones active now."
          },
          "biggest": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SlowZoneRow"
            },
            "description": "The 25 zones with the most rider-hours lost since 2022."
          }
        },
        "required": [
          "updated",
          "dataThrough",
          "summary",
          "weekly",
          "active",
          "biggest"
        ],
        "description": "Subway slow zones, rebuilt each night."
      },
      "BusRouteSpeed": {
        "type": "object",
        "properties": {
          "route": {
            "type": "string",
            "description": "Route name, e.g. M50."
          },
          "borough": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "Service type, e.g. LCL/LTD."
          },
          "mph": {
            "type": "number",
            "description": "Average speed in the latest period."
          },
          "mphYearAgo": {
            "type": "number",
            "description": "Average speed a year earlier."
          },
          "changePct": {
            "type": "number",
            "description": "Change from a year earlier, percent."
          },
          "ours": {
            "oneOf": [
              {
                "type": "number",
                "description": "Speed from our own bus-position archive, when we have it."
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "route",
          "borough",
          "type",
          "mph",
          "mphYearAgo",
          "changePct",
          "ours"
        ]
      },
      "BusSpeeds": {
        "type": "object",
        "properties": {
          "updated": {
            "type": "string",
            "format": "date"
          },
          "mtaThrough": {
            "type": "string",
            "description": "Last month of MTA's published speeds, YYYY-MM."
          },
          "oursThrough": {
            "type": "string",
            "format": "date",
            "description": "Last day of our own archive."
          },
          "periods": {
            "type": "object",
            "properties": {
              "routes": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "routesYearAgo": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            },
            "required": [
              "routes",
              "routesYearAgo"
            ],
            "description": "Months each route's speed averages."
          },
          "summary": {
            "type": "object",
            "properties": {
              "citywideMph": {
                "type": "number"
              },
              "citywideYearAgo": {
                "type": "number"
              },
              "cbdLocalChange": {
                "type": "number",
                "description": "Change in local bus speed inside the congestion zone, percent."
              },
              "outsideLocalChange": {
                "type": "number",
                "description": "Same, outside the zone."
              },
              "slowest": {
                "$ref": "#/components/schemas/BusRouteSpeed"
              }
            },
            "required": [
              "citywideMph",
              "citywideYearAgo",
              "cbdLocalChange",
              "outsideLocalChange",
              "slowest"
            ]
          },
          "monthly": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "month": {
                  "type": "string",
                  "pattern": "^\\d{4}-\\d{2}$"
                },
                "citywide": {
                  "type": "number"
                },
                "Bronx": {
                  "type": "number"
                },
                "Brooklyn": {
                  "type": "number"
                },
                "Manhattan": {
                  "type": "number"
                },
                "Queens": {
                  "type": "number"
                },
                "Staten Island": {
                  "type": "number"
                }
              },
              "required": [
                "month",
                "citywide",
                "Bronx",
                "Brooklyn",
                "Manhattan",
                "Queens",
                "Staten Island"
              ]
            },
            "description": "Average mph by month and borough since 2019."
          },
          "cbd": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "month": {
                  "type": "string"
                },
                "inside": {
                  "type": "number"
                },
                "outside": {
                  "type": "number"
                }
              },
              "required": [
                "month",
                "inside",
                "outside"
              ]
            },
            "description": "Bus speed inside and outside the congestion zone by month."
          },
          "daily": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "weekday": {
                  "type": "boolean"
                },
                "mph": {
                  "type": "number"
                }
              },
              "required": [
                "date",
                "weekday",
                "mph"
              ]
            },
            "description": "Recent days from our own archive."
          },
          "routes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BusRouteSpeed"
            },
            "description": "Every route."
          }
        },
        "required": [
          "updated",
          "mtaThrough",
          "oursThrough",
          "periods",
          "summary",
          "monthly",
          "cbd",
          "daily",
          "routes"
        ],
        "description": "Bus speeds, rebuilt each night."
      },
      "LonLat": {
        "type": "array",
        "items": {
          "type": "number"
        },
        "description": "[longitude, latitude]",
        "minItems": 2,
        "maxItems": 2
      },
      "BusLanes": {
        "type": "object",
        "properties": {
          "updated": {
            "type": "string",
            "format": "date"
          },
          "period": {
            "type": "object",
            "properties": {
              "start": {
                "type": "string",
                "description": "YYYY-MM"
              },
              "end": {
                "type": "string",
                "description": "YYYY-MM"
              }
            },
            "required": [
              "start",
              "end"
            ]
          },
          "summary": {
            "type": "object",
            "properties": {
              "busHoursLost": {
                "type": "integer"
              },
              "withoutLane": {
                "type": "integer"
              },
              "withLane": {
                "type": "integer"
              },
              "routeMilesWithout": {
                "type": "integer"
              },
              "routeMilesWith": {
                "type": "integer"
              }
            },
            "required": [
              "busHoursLost",
              "withoutLane",
              "withLane",
              "routeMilesWithout",
              "routeMilesWith"
            ]
          },
          "streets": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "street": {
                  "type": "string"
                },
                "borough": {
                  "type": "string"
                },
                "routes": {
                  "type": "string",
                  "description": "Routes on the street, space separated."
                },
                "lost": {
                  "type": "integer",
                  "description": "Bus-hours lost each weekday."
                },
                "mph": {
                  "type": "number"
                },
                "lane": {
                  "type": "number",
                  "description": "Share of route-miles on a bus lane."
                },
                "riders": {
                  "type": "integer"
                },
                "routeMiles": {
                  "type": "number"
                }
              },
              "required": [
                "street",
                "borough",
                "routes",
                "lost",
                "mph",
                "lane",
                "riders",
                "routeMiles"
              ]
            },
            "description": "The 25 streets where buses lose the most time."
          },
          "stretches": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "r": {
                  "type": "string",
                  "description": "Route"
                },
                "a": {
                  "type": "string",
                  "description": "From stop"
                },
                "b": {
                  "type": "string",
                  "description": "To stop"
                },
                "mph": {
                  "type": "number"
                },
                "best": {
                  "type": "number",
                  "description": "Speed in the fastest hour."
                },
                "delay": {
                  "type": "number"
                },
                "buses": {
                  "type": "integer",
                  "description": "Buses a weekday."
                },
                "lost": {
                  "type": "number",
                  "description": "Bus-hours lost a weekday."
                },
                "perMile": {
                  "type": "number"
                },
                "lane": {
                  "type": "number",
                  "description": "Share of the stretch on a bus lane."
                },
                "c": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LonLat"
                  },
                  "description": "Path of the stretch."
                }
              },
              "required": [
                "r",
                "a",
                "b",
                "mph",
                "best",
                "delay",
                "buses",
                "lost",
                "perMile",
                "lane",
                "c"
              ]
            },
            "description": "Stop-to-stop stretches."
          },
          "lanes": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/LonLat"
              },
              "minItems": 2,
              "maxItems": 2
            },
            "description": "Bus lane segments as pairs of points."
          }
        },
        "required": [
          "updated",
          "period",
          "summary",
          "streets",
          "stretches",
          "lanes"
        ],
        "description": "Bus lanes and where buses lose time, rebuilt each night. About 1.7 MB."
      },
      "CatalogColumn": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "DuckDB type, e.g. VARCHAR, DATE, BIGINT, DOUBLE."
          },
          "description": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "type",
          "description"
        ]
      },
      "CatalogTable": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "Table name."
          },
          "description": {
            "type": "string"
          },
          "source": {
            "type": "string"
          },
          "license": {
            "type": "string",
            "description": "License of the table, e.g. CC BY 4.0."
          },
          "rows": {
            "type": "integer"
          },
          "bytes": {
            "type": "integer"
          },
          "columns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CatalogColumn"
            }
          },
          "files": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "Parquet file URLs for the table."
          }
        },
        "required": [
          "name",
          "description",
          "source",
          "license",
          "rows",
          "bytes",
          "columns",
          "files"
        ]
      },
      "Catalog": {
        "type": "object",
        "properties": {
          "updated": {
            "type": "string",
            "format": "date-time"
          },
          "tables": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CatalogTable"
            }
          }
        },
        "required": [
          "updated",
          "tables"
        ],
        "description": "Every open table, with columns, row counts and Parquet file URLs."
      },
      "FormOk": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "ok",
          "message"
        ]
      },
      "FormError": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "string",
            "description": "Machine-readable code: bad_request, invalid_email, invalid_name, invalid_message, bot_check, rate_limited or server."
          },
          "message": {
            "type": "string",
            "description": "Text to show a person."
          },
          "field": {
            "type": "string",
            "description": "The form field at fault, when there is one."
          }
        },
        "required": [
          "ok",
          "error",
          "message"
        ]
      },
      "FormsConfig": {
        "type": "object",
        "properties": {
          "turnstileSiteKey": {
            "oneOf": [
              {
                "type": "string",
                "description": "Site key for the Turnstile widget."
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "turnstileSiteKey"
        ]
      },
      "ApiError": {
        "type": "object",
        "description": "An error from the SQL API.",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "A short code: unauthorized, rate_limited, daily_queries, daily_compute, concurrency, busy, bad_sql, not_read_only, sql_error, not_allowed, timeout, out_of_memory, bad_format, bad_limit, bad_date, no_such_table, not_found."
          },
          "message": {
            "type": "string",
            "description": "What went wrong and what to do, in plain words."
          },
          "get_a_key": {
            "type": "string",
            "format": "uri",
            "description": "On 401: where to get a key."
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "On 401: the API guide."
          }
        }
      },
      "SqlRequest": {
        "type": "object",
        "required": [
          "sql"
        ],
        "properties": {
          "sql": {
            "type": "string",
            "maxLength": 20000,
            "description": "One DuckDB SELECT or WITH statement. Name tables as /v1/tables lists them; they resolve to the public Parquet files. Big tables have year and month columns: filter on them to read fewer files."
          },
          "format": {
            "type": "string",
            "enum": [
              "json",
              "csv",
              "parquet"
            ],
            "default": "json",
            "description": "Answer format. CSV and Parquet carry the row count, truncation, time and bytes read in X- headers."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 10000,
            "default": 1000,
            "description": "Most rows to return. Larger values are cut to 10,000."
          },
          "from": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}(-\\d{2})?$",
            "description": "Optional. Skip files that end before this day (YYYY-MM-DD) or month (YYYY-MM). Picks files only; add a WHERE to trim rows."
          },
          "to": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}(-\\d{2})?$",
            "description": "Optional. Skip files that start after this day or month."
          }
        }
      },
      "SqlColumn": {
        "type": "object",
        "required": [
          "name",
          "type"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Column name."
          },
          "type": {
            "type": "string",
            "description": "DuckDB type, e.g. BIGINT, DOUBLE, VARCHAR, TIMESTAMP."
          }
        }
      },
      "SqlResult": {
        "type": "object",
        "required": [
          "columns",
          "rows",
          "row_count",
          "truncated",
          "elapsed_ms",
          "bytes_scanned"
        ],
        "properties": {
          "columns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SqlColumn"
            },
            "description": "The answer's columns, in order."
          },
          "rows": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {}
            },
            "description": "One array per row, values in column order. Dates and times are ISO 8601 strings; decimals are numbers."
          },
          "row_count": {
            "type": "integer",
            "description": "Rows in `rows`."
          },
          "truncated": {
            "type": "boolean",
            "description": "True if the answer had more rows than the limit (or than 10 MB) and was cut."
          },
          "elapsed_ms": {
            "type": "integer",
            "description": "Time the query took in the engine, in milliseconds."
          },
          "bytes_scanned": {
            "type": "integer",
            "description": "Bytes the engine read from the Parquet files for this query. Parts it already had in memory from earlier queries count as zero."
          }
        }
      },
      "TableSummary": {
        "type": "object",
        "required": [
          "name",
          "description",
          "rows",
          "bytes",
          "files",
          "partitioned_by_year_month"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Table name, as written in SQL."
          },
          "description": {
            "type": "string",
            "description": "What a row is."
          },
          "rows": {
            "type": "integer",
            "description": "Row count."
          },
          "bytes": {
            "type": "integer",
            "description": "Total size of the table's Parquet files."
          },
          "files": {
            "type": "integer",
            "description": "Number of Parquet files."
          },
          "partitioned_by_year_month": {
            "type": "boolean",
            "description": "True if the table is one file a month or a day, with year and month columns to filter on."
          },
          "first_day": {
            "type": "string",
            "format": "date",
            "description": "First day covered (partitioned tables only)."
          },
          "last_day": {
            "type": "string",
            "format": "date",
            "description": "Last day covered (partitioned tables only)."
          }
        }
      },
      "TableList": {
        "type": "object",
        "required": [
          "tables"
        ],
        "properties": {
          "updated": {
            "type": "string",
            "description": "When the catalog was last rebuilt (ISO 8601 UTC)."
          },
          "tables": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TableSummary"
            },
            "description": "Every table."
          }
        }
      },
      "TableDetail": {
        "type": "object",
        "required": [
          "name",
          "columns",
          "file_urls"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Table name, as written in SQL."
          },
          "description": {
            "type": "string",
            "description": "What a row is."
          },
          "rows": {
            "type": "integer",
            "description": "Row count."
          },
          "bytes": {
            "type": "integer",
            "description": "Total size of the table's Parquet files."
          },
          "files": {
            "type": "integer",
            "description": "Number of Parquet files."
          },
          "partitioned_by_year_month": {
            "type": "boolean",
            "description": "True if the table is one file a month or a day, with year and month columns to filter on."
          },
          "first_day": {
            "type": "string",
            "format": "date",
            "description": "First day covered (partitioned tables only)."
          },
          "last_day": {
            "type": "string",
            "format": "date",
            "description": "Last day covered (partitioned tables only)."
          },
          "source": {
            "type": "string",
            "description": "Where the data comes from."
          },
          "license": {
            "type": "string",
            "description": "License of the table."
          },
          "columns": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CatalogColumn"
            },
            "description": "Columns with types and meanings, plus year and month on partitioned tables."
          },
          "file_urls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "The table's Parquet files on data.transitlab.nyc."
          }
        }
      },
      "ApiHealth": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "True when the API is up and can read the catalog."
          },
          "service": {
            "type": "string",
            "description": "Always sql-api."
          },
          "catalog_updated": {
            "type": "string",
            "description": "When the catalog the API serves was built."
          },
          "tables": {
            "type": "integer",
            "description": "Number of tables."
          },
          "time": {
            "type": "string",
            "description": "Server time, ISO 8601 UTC."
          }
        }
      }
    },
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "tl_live_ followed by 32 letters and digits",
        "description": "A free API key from https://transitlab.nyc/api-keys, sent as `Authorization: Bearer tl_live_…`."
      }
    }
  }
}
