{"openapi":"3.1.0","info":{"title":"Smart Site Plan Open Data API","version":"1.0.0","description":"Public API for searching and rendering the Smart Site Plan Open Data catalog (20M+ spatial sources). Authenticate with a secret key for server-to-server calls, or exchange a publishable key for a short-lived session token for browser use. Utility networks (water, sewer, storm, gas, electric) and sources whose publisher restricts distribution or commercial use are not served. Every tile and feature response carries the publisher's credit (`credit`, or the X-Source-Credit header on an image); display it with the data.","contact":{"name":"SSP API support","email":"api-support@smartsiteplan.com"},"license":{"name":"Commercial"}},"servers":[{"url":"https://api.staging.smartsiteplan.com/public/opendata/v1"}],"security":[{"bearerApiKey":[]},{"bearerSession":[]}],"x-mcp":{"description":"This API is also exposed over the Model Context Protocol (MCP) for AI agents. POST JSON-RPC 2.0 to /mcp (Streamable HTTP transport); fetch GET /mcp/info for full discovery.","endpoint":"https://api.staging.smartsiteplan.com/public/opendata/v1/mcp","info":"https://api.staging.smartsiteplan.com/public/opendata/v1/mcp/info","transport":"streamable-http","protocolVersions":["2025-06-18","2025-03-26","2024-11-05"],"latestProtocolVersion":"2025-06-18","tools":["get_catalog_overview","list_categories","search_open_data","list_sources","get_source","get_source_style","query_source_features","get_source_preview","get_source_tile","count_source_features","aggregate_source_features"]},"components":{"securitySchemes":{"bearerApiKey":{"type":"http","scheme":"bearer","bearerFormat":"sk_live_*, sk_test_*","description":"Secret API key, server-to-server only. Never embed in a browser. Each key carries one or more scopes; routes that require a specific scope are tagged with `x-required-scope`."},"bearerSession":{"type":"http","scheme":"bearer","bearerFormat":"JWT (HS256)","description":"Short-lived session token obtained via POST /sessions, valid for 15 minutes. Used by Web Component and iframe clients. A session does NOT carry the minting key's scope list (its access surface is narrowed by the key's allowedOrigins instead), but the token's own scope claim IS enforced: a session minted with scope \"read\" is refused with 403 scope_missing on any endpoint requiring a write scope, while scope \"full\" (the default) authorizes every session-callable endpoint."}},"x-api-key-scopes":{"*":"Full access (legacy / default for backwards compat).","searches:read":"POST /search.","sources:read":"GET /sources, /sources/{id}, /sources/{id}/style, /categories, /catalog.","features:read":"GET /sources/{id}/features, /features/count, and /features/statistics.","tiles:read":"GET /sources/{id}/tiles/{z}/{x}/{y}.","previews:read":"GET /sources/{id}/preview.","sessions:write":"POST /sessions, /sessions/revoke. Required on publishable keys to mint a session token."},"schemas":{"ApiError":{"type":"object","required":["success","error"],"properties":{"success":{"type":"boolean","enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable error code. See Error Codes in the docs."},"message":{"type":"string"},"details":{"type":"object","additionalProperties":true},"requestId":{"type":"string"}}}}},"CreateSessionRequest":{"type":"object","required":["publishableKey","origin"],"properties":{"publishableKey":{"type":"string","pattern":"^pk_(test|live)_","example":"pk_live_AbCd1234"},"origin":{"type":"string","format":"uri","example":"https://app.example.com"},"scope":{"type":"string","enum":["full","read"],"default":"full","description":"What the minted session may do. \"full\" authorizes every session-callable endpoint. \"read\" mints a session that is refused (403 scope_missing) on any endpoint requiring a write scope; every data endpoint on this API is a read, so a \"read\" session can still search, list sources, fetch tiles, features and previews, and is still billed for them."}}},"CreateSessionResponse":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["sessionToken","expiresAt","tokenType"],"properties":{"sessionToken":{"type":"string"},"expiresAt":{"type":"string","format":"date-time","description":"Absolute expiry. Tokens live 900 seconds (15 minutes) by default; always refresh from this field rather than a hardcoded interval."},"tokenType":{"type":"string","enum":["Bearer"]}}}}},"SearchRequest":{"type":"object","required":["q","bbox"],"properties":{"q":{"type":"string","minLength":1,"maxLength":500},"bbox":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Required. [west, south, east, north] WGS84 degrees; no global search."},"zoom":{"type":"integer","minimum":0,"maximum":24},"categories":{"type":"array","items":{"type":"string","maxLength":120},"maxItems":50,"description":"Restrict results to these categories (exact match on the source's category, the same value GET /sources?category= takes and aggregations.categories returns). Omit for no category restriction. Part of the query identity: a cursor minted for one category filter is rejected on a different one."},"cursor":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":50,"default":20}}},"SearchResponse":{"type":"object","required":["items","total","query","nextCursor","hasMore","aggregations"],"properties":{"items":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"Page of search hits."},"total":{"type":"integer","description":"Total match count across all pages."},"query":{"type":"string","description":"Echo of the resolved query."},"nextCursor":{"type":"string","nullable":true,"description":"Opaque cursor for the next page; null when the result set is exhausted."},"hasMore":{"type":"boolean"},"aggregations":{"type":"object","required":["categories"],"properties":{"categories":{"type":"array","description":"Top-20 category facets across the FULL match set (not just the current page). Empty on paged scans (offset > 0) - facets are stable across pages so the cost of recomputing is wasted work.","items":{"type":"object","required":["name","count"],"properties":{"name":{"type":"string"},"count":{"type":"integer","minimum":1}}}}}}}},"SourceDetail":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"geometryType":{"type":"string","nullable":true},"attribution":{"type":"string","nullable":true,"description":"Backwards-compat alias for attributionText. Pre-existing customers consume this field; new integrations should prefer attributionText + licenseType for full licensing context."},"licenseType":{"type":"string","description":"Curator-set license classification. Vocabulary: public_domain, cc_by, cc_by_sa, ogl, proprietary, restricted, uncategorised. Defaults to 'restricted' for un-curated rows."},"attributionRequired":{"type":"boolean","description":"If true, customers MUST display the attribution string when rendering features from this source."},"attributionText":{"type":"string","nullable":true,"description":"Plain-text attribution the customer should render verbatim. Distinct from copyright_text upstream so the curator can phrase it for end-user display."},"licenseUrl":{"type":"string","nullable":true,"format":"uri","description":"Optional URL to the upstream license document."},"publicRedistributionAllowed":{"type":"boolean","description":"Phase 5 catalog-audit gate. False until an admin explicitly confirms the source can be redistributed via the API."},"publisher":{"type":"string","nullable":true},"domain":{"type":"string","nullable":true},"fields":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"alias":{"type":"string"},"type":{"type":"string"}}}},"bbox":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"nullable":true},"minZoom":{"type":"integer","nullable":true},"maxZoom":{"type":"integer","nullable":true}}},"StyleSpec":{"type":"object","description":"MapLibre-shaped style envelope. Tile URLs always point at our proxy; no raw upstream URLs.","properties":{"version":{"type":"string","enum":["1"]},"type":{"type":"string","enum":["raster","vector","features"]},"sourceId":{"type":"string"},"source":{"type":"object","additionalProperties":true},"layers":{"type":"array","items":{"type":"object","additionalProperties":true}},"attribution":{"type":"string","nullable":true},"fieldSchema":{"type":"array","items":{"type":"object"}},"minzoom":{"type":"integer"},"maxzoom":{"type":"integer"}}},"GeoJsonFeatureCollection":{"type":"object","required":["type","features"],"properties":{"type":{"type":"string","enum":["FeatureCollection"]},"features":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["Feature"]},"geometry":{"type":"object","nullable":true},"properties":{"type":"object","additionalProperties":true}}}},"totalFeatures":{"type":"integer"},"exceededTransferLimit":{"type":"boolean","description":"True when the upstream truncated the page at the record limit. Page past it with the offset parameter."},"credit":{"$ref":"#/components/schemas/SourceCredit"}}},"SourceCredit":{"type":"object","description":"The publisher's credit. Carried by every feature response; display it wherever the data is shown.","properties":{"publisher":{"type":"string","nullable":true},"attribution":{"type":"string","nullable":true},"domain":{"type":"string","nullable":true}}}}},"paths":{"/mcp":{"post":{"summary":"MCP JSON-RPC endpoint (Streamable HTTP transport)","description":"Model Context Protocol server exposing the Open Data catalog as MCP tools, resources, and prompts for AI agents. Authenticate with a secret key or session token, exactly like the REST endpoints. Each request carries one JSON-RPC 2.0 message; the response is a single JSON-RPC result (or 202 with no body for notifications). Billing, scopes, and rate limits match the equivalent REST routes. Call GET /mcp/info for the full capability list.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jsonrpc","method"],"properties":{"jsonrpc":{"type":"string","enum":["2.0"]},"id":{"oneOf":[{"type":"string"},{"type":"number"}]},"method":{"type":"string","example":"tools/call"},"params":{"type":"object","additionalProperties":true}}}}}},"responses":{"200":{"description":"JSON-RPC response (result or error)"},"202":{"description":"Accepted (notification, no body)"},"401":{"description":"Missing or invalid credentials"}}},"get":{"summary":"Not allowed (this server offers no GET event stream)","security":[],"responses":{"405":{"description":"Use POST for MCP JSON-RPC. See /mcp/info for discovery."}}}},"/mcp/info":{"get":{"summary":"MCP server discovery (no auth)","security":[],"responses":{"200":{"description":"Server identity, transport, protocol versions, auth, and the tool/resource/prompt catalog with per-call cost."}}}},"/sessions":{"post":{"summary":"Exchange a publishable key for a session token","description":"The publishable key is read from the request body, not the Authorization header (so it is safe to embed in HTML). The key must (a) be type=publishable, (b) carry the `sessions:write` scope (or `*`), and (c) have the origin in its allowedOrigins list. New keys default to scope `*` so this gate is invisible until a customer narrows scopes explicitly.","x-required-scope":"sessions:write","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSessionRequest"}}}},"responses":{"200":{"description":"Session minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSessionResponse"}}}},"401":{"description":"Invalid key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Origin not in the key allowlist, or account suspended","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"Rate limit exceeded"},"503":{"description":"Metering unavailable, retry later"}}}},"/sources":{"get":{"summary":"List sources (paged, optional bbox + category filter)","description":"Required scope: `searches:read` is NOT enough; this list is gated by `sources:read` so a search-only key cannot enumerate the catalog. Billed as one `sourceMetadataFetches` unit per call (per PAGE, so paging a large catalog costs one unit per page).","x-required-scope":"sources:read","x-metered-as":"sourceMetadataFetches","parameters":[{"name":"bbox","in":"query","schema":{"type":"string"},"description":"Comma-separated west,south,east,north"},{"name":"category","in":"query","schema":{"type":"string","maxLength":120}},{"name":"limit","in":"query","description":"Rows per page. Omitting it yields 25, not the maximum.","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"cursor","in":"query","description":"Opaque pagination cursor from the previous response's nextCursor; omit for the first page. Do not parse or mutate it.","schema":{"type":"string","maxLength":512}}],"responses":{"200":{"description":"Paged list of sources"},"400":{"description":"Validation error"},"401":{"description":"Invalid key"},"403":{"description":"Key lacks sources:read scope"},"503":{"description":"The catalog took too long to answer; narrow the bbox or retry (Retry-After)"}}}},"/sessions/revoke":{"post":{"summary":"Revoke a session token (logout)","description":"Safe to call repeatedly: the FIRST revoke of a live token returns 200 with `revoked: true`, and every later call for that same token (now revoked, or expired, or malformed) returns 200 with `revoked: false` and `reason: \"already_invalid\"`. `revoked: false` is a SUCCESS, not a failure - it means the token is not usable, which is the outcome you asked for. Do not treat it as a retryable error. Not metered.","security":[{"bearerSession":[]}],"responses":{"200":{"description":"Revocation acknowledged","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"revoked":{"type":"boolean"},"reason":{"type":"string","enum":["already_invalid"],"description":"Only present when revoked=false."}}}}}}}},"401":{"description":"Missing or malformed Authorization header","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"Revocation store unavailable, retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/search":{"post":{"summary":"Search the catalog","x-required-scope":"searches:read","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchRequest"}}}},"responses":{"200":{"description":"Search results","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResponse"}}}},"400":{"description":"Validation error"},"401":{"description":"Invalid key"},"429":{"description":"Rate limit exceeded"},"500":{"description":"Internal error"},"503":{"description":"Metering unavailable"}}}},"/categories":{"get":{"summary":"List categories in a bbox (not billable)","x-required-scope":"sources:read","parameters":[{"name":"bbox","in":"query","required":true,"schema":{"type":"string"},"description":"Comma-separated west,south,east,north"}],"responses":{"200":{"description":"Category list"},"400":{"description":"Invalid bbox"}}}},"/catalog":{"get":{"summary":"Catalog overview (capability discovery, not billable)","description":"Returns approximate totals (totalSources, totalCategories) plus the top categories by source count. Powered by pg_stats so the response is sub-second; counts are accurate to within a few percent. Required scope: sources:read - a search-only key cannot enumerate the catalog overview.","x-required-scope":"sources:read","responses":{"200":{"description":"Catalog summary with top-N categories","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"type":"boolean","const":true},"data":{"type":"object","required":["totalSources","totalCategories","categories"],"properties":{"totalSources":{"type":"integer","description":"Approximate total source count."},"totalCategories":{"type":"integer","description":"Approximate distinct tag count."},"categories":{"type":"array","maxItems":50,"items":{"type":"object","required":["name","approxSourceCount"],"properties":{"name":{"type":"string"},"approxSourceCount":{"type":"integer"}}}}}}}}}}},"401":{"description":"Missing or invalid bearer"},"403":{"description":"Key lacks sources:read scope"},"429":{"description":"Rate limit exceeded"}}}},"/sources/{id}":{"get":{"summary":"Source metadata","x-required-scope":"sources:read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Source detail","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceDetail"}}}},"404":{"description":"Source not found"},"503":{"description":"The catalog took too long to answer; retry (Retry-After)"}}}},"/sources/{id}/style":{"get":{"summary":"MapLibre-shaped style spec","x-required-scope":"sources:read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Style envelope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StyleSpec"}}}},"404":{"description":"Source not found"}}}},"/sources/{id}/tiles/{z}/{x}/{y}":{"get":{"summary":"Proxied map tile bytes","x-required-scope":"tiles:read","description":"Returns PNG/JPEG bytes. MapServer sources serve cached tiles when available, dynamic /export rendering otherwise. ImageServer sources serve /exportImage. FeatureServer sources return 404; use /features instead. When the source publishes a maxZoom (see GET /sources/{id}), a request above it returns 404 tile_out_of_range BEFORE any upstream call and is NOT metered, so over-zooming a bounded source is free. A source with no published maxZoom makes no claim about its ceiling and is not gated.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"z","in":"path","required":true,"schema":{"type":"integer","minimum":0,"maximum":24}},{"name":"x","in":"path","required":true,"schema":{"type":"integer","minimum":0}},{"name":"y","in":"path","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Tile bytes","headers":{"X-Source-Credit":{"description":"The publisher's credit (attribution line, else publisher name), percent-encoded outside printable ASCII. Render it with the image.","schema":{"type":"string"}}}},"304":{"description":"Not modified (ETag match)"},"400":{"description":"Invalid tile coordinates"},"404":{"description":"Source not found (not_found), not a tile source (not_found), or the requested zoom is above the source's published maxZoom (tile_out_of_range). None of these are metered."},"503":{"description":"Upstream unavailable"}}}},"/sources/{id}/features":{"get":{"summary":"GeoJSON feature query (spatial + attribute filter)","description":"Query a FeatureServer source. Spatially filter by point (lat+lng) or bbox, and/or filter by attributes with `filter` (a JSON array of {field,operator,value} conditions compiled to a sanitized WHERE; read the source's `fields` first). `outFields` and `orderBy` are allowlisted against the source's real fields.","x-required-scope":"features:read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"lat","in":"query","schema":{"type":"number"}},{"name":"lng","in":"query","schema":{"type":"number"}},{"name":"bbox","in":"query","schema":{"type":"string"}},{"name":"tolerance","in":"query","schema":{"type":"number"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":2000}},{"name":"offset","in":"query","description":"Features to skip (ESRI resultOffset), for paging a large layer. Pair with limit; the response's exceededTransferLimit is true when more remain. Paging is stable by default: with no explicit orderBy the query is sorted by the layer's object-id field, so consecutive offset windows cannot overlap or skip rows. Pass orderBy to sort by something else (it must still be a real, unique-enough field to page safely).","schema":{"type":"integer","minimum":0,"maximum":100000}},{"name":"filter","in":"query","description":"JSON array of attribute conditions, e.g. [{\"field\":\"ACRES\",\"operator\":\">\",\"value\":\"5\"}]. Operators: = <> < <= > >= LIKE, NOT LIKE, BEGINS WITH, ENDS WITH.","schema":{"type":"string"}},{"name":"filterConnector","in":"query","schema":{"type":"string","enum":["AND","OR"]}},{"name":"spatialRel","in":"query","description":"Spatial predicate for the point/bbox filter: intersects (default), within, contains, crosses, overlaps, touches.","schema":{"type":"string","enum":["intersects","contains","within","crosses","overlaps","touches"]}},{"name":"outFields","in":"query","description":"Comma-separated field names (must exist on the source).","schema":{"type":"string"}},{"name":"orderBy","in":"query","description":"Sort spec, e.g. 'ACRES DESC'. Defaults to the layer's object-id ascending so offset paging is stable; an explicit value replaces that default.","schema":{"type":"string"}},{"name":"returnGeometry","in":"query","schema":{"type":"string","enum":["true","false"]}}],"responses":{"200":{"description":"Feature collection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GeoJsonFeatureCollection"}}}},"400":{"description":"Source is not a FeatureServer, or the filter is invalid"},"404":{"description":"Source not found"},"503":{"description":"Upstream feature service unavailable"}}}},"/sources/{id}/features/count":{"get":{"summary":"Count matching features (no fetch)","description":"Count how many features match an optional attribute filter and/or spatial filter, without fetching them. Billed as one feature query.","x-required-scope":"features:read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"lat","in":"query","schema":{"type":"number"}},{"name":"lng","in":"query","schema":{"type":"number"}},{"name":"bbox","in":"query","schema":{"type":"string"}},{"name":"tolerance","in":"query","schema":{"type":"number"}},{"name":"filter","in":"query","schema":{"type":"string"}},{"name":"filterConnector","in":"query","schema":{"type":"string","enum":["AND","OR"]}},{"name":"spatialRel","in":"query","description":"Spatial predicate for the point/bbox filter: intersects (default), within, contains, crosses, overlaps, touches.","schema":{"type":"string","enum":["intersects","contains","within","crosses","overlaps","touches"]}}],"responses":{"200":{"description":"Match count","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"count":{"type":"integer"},"credit":{"$ref":"#/components/schemas/SourceCredit"}}}}}}}},"400":{"description":"Source is not a FeatureServer, or the filter is invalid"},"404":{"description":"Source not found"},"503":{"description":"Upstream feature service unavailable"}}}},"/sources/{id}/features/statistics":{"get":{"summary":"Aggregate statistics over matching features","description":"Compute aggregate statistics (count, sum, avg, min, max) over the features matching an optional attribute and/or spatial filter, optionally grouped by one or more fields. Grouping by a field with a count also lists that field's distinct values. Statistic and group-by fields are allowlisted against the source's real fields. Billed as one feature query.","x-required-scope":"features:read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"statistics","in":"query","required":true,"description":"JSON array of aggregates, e.g. [{\"type\":\"sum\",\"field\":\"ACRES\",\"alias\":\"total_acres\"}]. type is one of count, sum, avg, min, max.","schema":{"type":"string"}},{"name":"groupBy","in":"query","description":"Comma-separated field names to group by.","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Max groups to return, for paging a high-cardinality group-by.","schema":{"type":"integer","minimum":1,"maximum":2000}},{"name":"offset","in":"query","description":"Groups to skip, to page past a truncated group-by (exceededTransferLimit). KNOWN LIMITATION: this endpoint exposes no ordering control, and the upstream service does not guarantee a stable group order, so consecutive offset windows can repeat or omit groups. Prefer narrowing the filter until the rollup fits one page (exceededTransferLimit false) over paging it.","schema":{"type":"integer","minimum":0,"maximum":100000}},{"name":"lat","in":"query","schema":{"type":"number"}},{"name":"lng","in":"query","schema":{"type":"number"}},{"name":"bbox","in":"query","schema":{"type":"string"}},{"name":"tolerance","in":"query","schema":{"type":"number"}},{"name":"filter","in":"query","schema":{"type":"string"}},{"name":"filterConnector","in":"query","schema":{"type":"string","enum":["AND","OR"]}},{"name":"spatialRel","in":"query","description":"Spatial predicate for the point/bbox filter: intersects (default), within, contains, crosses, overlaps, touches.","schema":{"type":"string","enum":["intersects","contains","within","crosses","overlaps","touches"]}}],"responses":{"200":{"description":"Aggregated group rows","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","enum":[true]},"data":{"type":"object","properties":{"groups":{"type":"array","items":{"type":"object","additionalProperties":true}},"exceededTransferLimit":{"type":"boolean","description":"True when the upstream truncated the grouped rows at the record limit (a high-cardinality group-by). The rollup is partial."},"credit":{"$ref":"#/components/schemas/SourceCredit"}}}}}}}},"400":{"description":"Source is not a FeatureServer, or the statistics request is invalid"},"404":{"description":"Source not found"},"503":{"description":"Upstream feature service unavailable"}}}},"/sources/{id}/preview":{"get":{"summary":"Single representative preview image","x-required-scope":"previews:read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"PNG bytes","headers":{"X-Source-Credit":{"description":"The publisher's credit (attribution line, else publisher name), percent-encoded outside printable ASCII. Render it with the image.","schema":{"type":"string"}}}},"404":{"description":"Source not found"},"503":{"description":"Upstream unavailable"}}}},"/health":{"get":{"summary":"Health check (no auth)","security":[],"responses":{"200":{"description":"Status + upstream report"}}}},"/openapi.json":{"get":{"summary":"This spec","security":[],"responses":{"200":{"description":"OpenAPI 3.1 document"}}}}}}