WTHRS API
A read-only JSON API for consensus weather forecasts, per-provider comparisons and accuracy rankings. Designed to be boring on purpose. Stable URLs, predictable JSON, aggressive caching, no authentication for the public tier.
https://wthrs.io/api/publicAPI version 2026-07-24JSON · CORS enabledOverview
The WTHRS API exposes the same consensus engine that powers wthrs.io. All requests are stateless HTTP GETs returning JSON.
The API is organized around four resources:
forecast. The WTHRS consensus for a citycompare. Per-provider breakdown next to the consensusrankings. MAE-baseline ordering per model trackcities. The supported location catalog
Every response includes a top-level generatedAt timestamp and is cacheable at the edge for 5 minutes.
# Production
curl https://wthrs.io/api/public/cities
# Response
{
"cities": [ ... ],
"generatedAt": "2026-07-24T12:00:00.000Z"
}Quickstart
Fetch a forecast in under a minute. No API key, no signup.
Point any HTTP client at https://wthrs.io/api/public. The API is read-only, soGET is the only method you'll need. All responses are JSON withContent-Type: application/json and permissive CORS (Access-Control-Allow-Origin: *).
For production use we strongly recommend caching responses for at least 5 minutes. That matches the upstream refresh cadence and dramatically reduces your latency.
curl -s https://wthrs.io/api/public/forecast/stockholm | jq .consensus.currentOpenAPI specification
Machine-readable OpenAPI 3.1 document. Import it into Postman, Insomnia, Stoplight, or generate a typed client.
The spec is served with a 1-hour cache. Pin to a specificversion from info.version if you generate clients from it.
npx openapi-typescript https://wthrs.io/api/openapi.json -o wthrs.d.ts
# Or a full SDK with Fern / openapi-generator
openapi-generator-cli generate \
-i https://wthrs.io/api/openapi.json \
-g typescript-fetch -o ./wthrs-sdkAuthentication
The public tier requires no authentication. Requests are open to anyone.
WTHRS' consensus API is a public read-only tier. No API key, no Authorization header. This is deliberate: the underlying model data is CC BY 4.0 and we want it to be as embeddable as a Google Font.
Attribution is appreciated but not required. Please cite wthrs.io and the underlying model track when displaying values in a UI.
A future private tier with per-key quotas and higher rate limits will use a bearer token in the Authorization header. The public endpoints documented here will remain unauthenticated.
# Just call the endpoint. No header needed
curl https://wthrs.io/api/public/forecast/stockholm
# For the (future) private tier:
# curl -H "Authorization: Bearer wthrs_sk_live_..." https://wthrs.io/api/v2/forecastRate limits
Soft-limited at the edge. Cache aggressively. You almost certainly do not need to poll.
The public tier has no per-key quota, but the edge applies fair-use throttling per IP. Bursts under 60 requests/minute per resource are effectively free; sustained higher volume may return 429 Too Many Requests with aRetry-After header (in seconds).
Because the upstream model data refreshes at most every 5-15 minutes, polling faster than max-age=300 gives you the same JSON back. Respect theCache-Control header and you'll never hit a limit.
For bulk or commercial usage, get in touch via Trust Center.
async function fetchWithBackoff(url, tries = 4) {
for (let i = 0; i < tries; i++) {
const res = await fetch(url);
if (res.status !== 429) return res;
const wait = Number(res.headers.get("retry-after") ?? 2 ** i) * 1000;
await new Promise(r => setTimeout(r, wait + Math.random() * 300));
}
throw new Error("rate_limited");
}Caching
Every endpoint sets a long, cacheable Cache-Control header. Use it.
All responses include Cache-Control: public, max-age=300 (openapi.json is max-age=3600). The edge honors it, and so should your client, CDN, or service worker.
- Client: use
fetch(url, { cache: "force-cache" })in the browser. - Server: wrap calls in a 5-minute in-memory or Redis cache keyed by URL.
- Edge: Cloudflare / Vercel / Fastly will cache automatically. No config.
The generatedAt field on every response tells you when the payload was materialized upstream, so you can display "as of HH:MM" in your UI.
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=300
Access-Control-Allow-Origin: *
X-WTHRS-Version: 2026-07-24
X-Request-Id: 01HW9R9T6EHK5P8T2Z9AXX3M0QPagination
Current list endpoints return complete result sets. Cursor-based pagination will be introduced when we ship the historical observations endpoints.
Because the public catalog (cities: ~15, providers: 4, ranking rows: ≤4) is small and bounded, list endpoints return the full array in one response. No pagination is required today.
When historical time-series endpoints ship, they will follow a cursor-based scheme identical to Stripe's:
limit. 1 to 100 (default 25)starting_after. Object id to start afterending_before. Object id to end before
Responses will include has_more and a data array, matching the shape below.
{
"object": "list",
"has_more": true,
"data": [
{ "id": "obs_01HW...", "observed_time": "2026-07-24T10:00:00Z", ... },
...
],
"next_cursor": "obs_01HW..."
}Errors
Standard HTTP status codes. JSON error bodies. Never HTML.
Codes you might encounter:
- 200 OK. Success
- 400 Bad Request. Malformed query parameter
- 404 Not Found. Unknown city slug or path
- 429 Too Many Requests. Rate limited, see
Retry-After - 502 / 503 / 504. Upstream provider or gateway error; safe to retry with backoff
Error bodies always contain an error string.
HTTP/1.1 404 Not Found
{
"error": "Unknown city: \"atlantis\""
}Missing-data contract
WTHRS never fabricates numeric values. When a field cannot be verified, the API returns null plus an explicit status.
Every metric may carry a dataStatus entry describing how much the value can be trusted. Only verified and verified_cached are safe to render as normal values. The remaining statuses must be surfaced to end users.
- verified. Direct, fresh value from a source we can verify.
- verified_cached. Last verified value, served from cache.
- degraded. Only one upstream source responded; comparison is limited.
- estimated_disclosed. WTHRS-derived. Always labelled as estimated.
- unavailable. No verifiable value exists; the field is
null.
Consumers must not substitute a null value with another field. In particular, never mirror wind into gust; the two represent different physical quantities and a distinct gust reading is only trusted when it is strictly greater than the sustained wind.
{
"consensus": {
"current": { "wind": 3.1, "gust": null }
},
"dataStatus": {
"gust": {
"status": "unavailable",
"reason": "no_verified_gust_field"
}
}
}Versioning
Date-based, additive-by-default. Breaking changes ship on a new version string.
The current API version is 2026-07-24. It appears in the OpenAPI document's info.version and in the X-WTHRS-Version response header.
Additive changes. New fields, new endpoints, new enum values. Are shipped continuously without a version bump. Backwards-incompatible changes get a new date-versioned base path (/api/v2026-12-01/...). Old versions remain callable for at least 12 months.
To pin, capture the version from a response header at build time and refuse to upgrade until you've validated against the new schema.
# Save the current spec into your repo
curl https://wthrs.io/api/openapi.json > vendor/wthrs-openapi.json
# CI will fail if the live version diverges from what you built against
test "$(jq -r .info.version vendor/wthrs-openapi.json)" = "2026-07-24"Deprecation policy
Fields are removed slowly, loudly, and predictably.
Our contract with integrators:
- 90 days notice on Trust Center and in the
Deprecationresponse header before any field or endpoint is removed. - Sunset header set to the removal date (RFC 8594).
- Deprecated fields keep working. They just also emit warnings in the response.
- New fields never break existing clients (additive JSON).
- Enum values can be added; removed values follow the same 90-day window.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Link: <https://wthrs.io/docs/api#ep-forecast>; rel="successor-version"
Warning: 299 - "field 'consensus.rain_mm' will be removed on 2026-11-01, use 'consensus.precipitation_mm'"Retrieve a forecast
Returns the consensus forecast for a supported city.
| Parameter | Type | Description |
|---|---|---|
| cityrequired | string | Slug or Swedish name (e.g. Stockholm, göteborg, malmo). See /cities for the full list. |
Returns a ForecastResponse object with location, consensus and generatedAt.
curl https://wthrs.io/api/public/forecast/stockholm{
"location": {
"name": "Stockholm",
"lat": 59.3293,
"lon": 18.0686
},
"consensus": {
"current": {
"temperature": 4.2,
"condition": "cloudy",
"wind": 3.1,
"humidity": 78
},
"confidence": 87,
"hourly": [
{ "time": "2026-07-24T14:00:00Z", "temperature": 4.6, "precipitation": 0.0 },
{ "time": "2026-07-24T15:00:00Z", "temperature": 4.8, "precipitation": 0.1 }
],
"daily": [
{ "date": "2026-07-24", "high": 6.1, "low": 1.4, "condition": "cloudy" }
]
},
"generatedAt": "2026-07-24T12:00:00.000Z"
}Compare providers
Returns each provider's current forecast alongside the WTHRS consensus. Useful for building side-by-side comparison UIs or auditing the weighting engine.
| Parameter | Type | Description |
|---|---|---|
| cityrequired | string | City slug or name. |
curl https://wthrs.io/api/public/compare/goteborg{
"location": { "name": "Göteborg", "lat": 57.7089, "lon": 11.9746 },
"providers": [
{ "id": "smhi", "label": "SMHI (SNOW1gv1)", "current": { "temperature": 5.1, "wind": 4.2 } },
{ "id": "yr", "label": "Yr (MetCoOp MEPS)", "current": { "temperature": 5.4, "wind": 4.0 } },
{ "id": "apple", "label": "Apple Weather (ECMWF IFS)", "current": { "temperature": 4.9, "wind": 4.5 } },
{ "id": "icon", "label": "DWD ICON", "current": { "temperature": 5.3, "wind": 4.1 } }
],
"consensus": { "current": { "temperature": 5.2, "wind": 4.2 }, "confidence": 91 },
"generatedAt": "2026-07-24T12:00:00.000Z"
}Accuracy rankings
Ordered baseline of model tracks by MAE (or hit rate for precipitation).
| Parameter | Type | Description |
|---|---|---|
| city | string | Defaults to Stockholm. |
| parameter | "temperature" | "rain" | "wind" | Defaults to temperature. |
| horizon | "24h" | "48h" | "7d" | Defaults to 24h. |
curl "https://wthrs.io/api/public/rankings?city=Stockholm¶meter=temperature&horizon=24h"{
"city": "Stockholm",
"parameter": "temperature",
"horizon": "24h",
"rankings": [
{ "providerId": "smhi", "mae": 1.02, "sampleSize": 512 },
{ "providerId": "yr", "mae": 1.11, "sampleSize": 512 },
{ "providerId": "apple", "mae": 1.24, "sampleSize": 512 },
{ "providerId": "icon", "mae": 1.31, "sampleSize": 512 }
],
"generatedAt": "2026-07-24T12:00:00.000Z"
}List supported cities
Returns the catalog of supported city slugs, names and coordinates. Use this to power a search field or validate user input before calling /forecast/{city}.
{
"cities": [
{ "slug": "stockholm", "name": "Stockholm", "region": "Svealand", "lat": 59.3293, "lon": 18.0686 },
{ "slug": "goteborg", "name": "Göteborg", "region": "Götaland", "lat": 57.7089, "lon": 11.9746 },
{ "slug": "malmo", "name": "Malmö", "region": "Götaland", "lat": 55.6050, "lon": 13.0038 }
],
"generatedAt": "2026-07-24T12:00:00.000Z"
}JavaScript / Node
Idiomatic fetch, retries, and typed helpers.
const BASE = "https://wthrs.io/api/public";
export async function getForecast(city) {
const res = await fetch(`${BASE}/forecast/${encodeURIComponent(city)}`, {
headers: { Accept: "application/json" },
cache: "force-cache",
});
if (!res.ok) throw new Error(`WTHRS ${res.status}: ${await res.text()}`);
return res.json();
}
// Usage
const { consensus } = await getForecast("stockholm");
console.log(consensus.current.temperature, "°C ·", consensus.confidence, "confidence");Python
import httpx
BASE = "https://wthrs.io/api/public"
def get_forecast(city: str) -> dict:
with httpx.Client(timeout=10.0, headers={"Accept": "application/json"}) as c:
r = c.get(f"{BASE}/forecast/{city}")
r.raise_for_status()
return r.json()
data = get_forecast("stockholm")
print(data["consensus"]["current"]["temperature"], "°C")Swift (iOS / macOS)
import Foundation
struct Consensus: Decodable {
struct Current: Decodable { let temperature: Double; let condition: String; let wind: Double }
let current: Current
let confidence: Int
}
struct Forecast: Decodable {
struct Location: Decodable { let name: String; let lat: Double; let lon: Double }
let location: Location
let consensus: Consensus
let generatedAt: String
}
enum WTHRSError: Error { case badStatus(Int) }
func getForecast(city: String) async throws -> Forecast {
let url = URL(string: "https://wthrs.io/api/public/forecast/\(city)")!
let (data, resp) = try await URLSession.shared.data(from: url)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
throw WTHRSError.badStatus((resp as? HTTPURLResponse)?.statusCode ?? -1)
}
return try JSONDecoder().decode(Forecast.self, from: data)
}
// Usage in a Task
Task {
let f = try await getForecast(city: "stockholm")
print(f.consensus.current.temperature, "°C · conf", f.consensus.confidence)
}Kotlin (Android / JVM)
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
@Serializable data class Current(val temperature: Double, val condition: String, val wind: Double)
@Serializable data class Consensus(val current: Current, val confidence: Int)
@Serializable data class Location(val name: String, val lat: Double, val lon: Double)
@Serializable data class ForecastResponse(val location: Location, val consensus: Consensus, val generatedAt: String)
val http = HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
suspend fun forecast(city: String): ForecastResponse =
http.get("https://wthrs.io/api/public/forecast/\$city").body()
// suspend fun main() {
// val f = forecast("stockholm")
// println("${f.consensus.current.temperature}°C · conf ${f.consensus.confidence}")
// }