cloudiusz Back to the map

Open weather API

Version 1 · no key, no sign-up · JSON and PNG tiles · data CC BY 4.0
The short version

Everything the map shows, as an API: hour-by-hour DWD ICON-EU forecasts for all of Europe at a point, folded into days, as map tiles, plus the catalogue behind them. No API key. Each address may make 5 requests a minute (tiles: 300 a minute, counted apart). Show the DWD attribution that comes with every answer, and you are done.

Base URL and versioning

All endpoints live under https://www.cloudiusz.pl/api/v1. The version is in the path; v1 will not rename or remove a field — new fields may appear, so parse what you know and ignore the rest. Should a v2 ever be needed, v1 keeps answering for at least a year after and says so in the changelog.

Answers are JSON (UTF-8) with an ETag; send If-None-Match and you get a 304 instead of the body. Cache-Control says how long an answer may be reused: an hour for the catalogue, a minute for forecasts (they change only when a new model run is published), longer for tiles with a release id. Cross-origin requests are allowed from any origin (GET, HEAD), so a browser page can call the API directly.

Times are ISO-8601 instants in UTC (2026-09-16T12:00:00Z). Coordinates are decimal degrees, WGS 84. Units are metric: °C, km/h, hPa, km, mm.

Quick start

The next 78 hours in Warsaw. The first hour in hours is the run time of the model; insideServeWindow marks the hours the map itself would show as "current".

curl
curl -s "https://www.cloudiusz.pl/api/v1/forecast?lat=52.23&lon=21.01"
JavaScript
const r = await fetch('https://www.cloudiusz.pl/api/v1/forecast?lat=52.23&lon=21.01');
if (r.status === 429) throw new Error('slow down; retry in ' + r.headers.get('Retry-After') + ' s');
const { source, location, hours } = await r.json();
const next = hours.find(h => h.insideServeWindow);
console.log(location.place, next.time, next.weather.tempC, '°C', next.weather.description);
console.log('Data:', source.releaseId, '—', (await r.clone().json()).meta.attribution);
Python
import requests

r = requests.get("https://www.cloudiusz.pl/api/v1/forecast", params={"lat": 52.23, "lon": 21.01}, timeout=10)
if r.status_code == 429:
    raise SystemExit(f"slow down; retry in {r.headers['Retry-After']} s")
r.raise_for_status()
body = r.json()
for h in body["hours"][:6]:
    w = h["weather"]
    print(h["time"], w["tempC"], "°C", w["precipitation"], "mm", w["description"])
print(body["meta"]["attribution"])
Trimmed answer
{
  "meta": { "api": "v1", "attribution": "Dane: Deutscher Wetterdienst (DWD), ICON-EU, CC BY 4.0", "licenseUrl": "https://creativecommons.org/licenses/by/4.0/", "docs": "…/api" },
  "source": { "model": "icon-eu", "releaseId": "icon-eu-2026091606-r1", "runTime": "2026-09-16T06:00:00Z", "publishedAt": "2026-09-16T09:12:40Z", "stale": false, "current": true },
  "location": { "lat": 52.23, "lon": 21.01, "place": "Warsaw, PL" },
  "serveWindow": { "start": "2026-09-16T12:00:00Z", "end": "2026-09-18T12:04:11Z", "windowHours": 48, "pastGraceHours": 6 },
  "hours": [
    { "lead": 6, "time": "2026-09-16T12:00:00Z", "insideServeWindow": true,
      "weather": { "tempC": 18.4, "feelsLikeC": 18.1, "humidity": 61, "windSpeedKmh": 14.2, "windGustKmh": 27.3, "windDirectionDeg": 251,
                   "weatherCode": 802, "wwCode": 2, "description": "scattered clouds", "pressure": 1017.3, "visibility": 24.1,
                   "precipitation": 0.0, "snowPrecipitation": 0.0, "precipitationType": "NONE", "cloudsPercent": 41, "chanceOfRain": null },
      "raw": { "PRECIP_MM": 0.0, "RAIN_MM": 0.0, "SNOW_MM": 0.0, "T2M_C": 18.41, "RH": 61.2, "U10": -13.4, "V10": 4.6, "GUST_KMH": 27.3, "CLCT": 41.0, "VIS_KM": 24.1, "WW": 2, "PMSL_HPA": 1017.3 },
      "conditions": { "wet": false, "dry": true, "heavy-rain": false, "freezing": false, "strong-wind": false, "snow": false } },
    …one object per hour of the release, up to 78
  ]
}

Rate limits

Limits are per client address, over a sliding 60-second window, and set by the operator — the numbers below are read live. JSON calls and tiles have separate budgets, so drawing a map does not eat your forecast calls.

JSON requests per address
5 per 60 s
Tiles per address
300 per 60 s
Not counted
/api/v1/openapi.json, 304 answers still count

Every answer tells you where you stand:

HeaderMeaning
RateLimit-LimitRequests allowed per window for this budget (JSON or tiles).
RateLimit-RemainingWhat is left in the trailing window after this request.
RateLimit-ResetSeconds until the current minute ends. The window slides, so you may get some budget back before then.
RateLimit-Policy5;w=60 — the limit and the window length, machine-readable.
Retry-AfterOnly on 429: how many seconds to wait. Please honour it; a client that keeps hammering does not make progress and may be blocked.
Over the limit
HTTP/1.1 429 Too Many Requests
Retry-After: 37
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 37
Content-Type: application/json

{ "timestamp": "2026-09-16T12:04:23Z", "status": 429, "error": "Too many requests",
  "message": "Limit is 5 requests per minute per address; retry after 37 s",
  "path": "/api/v1/forecast", "requestId": "5d1c…", "details": { "code": "RATE_LIMITED", "retryAfterSeconds": "37" }, "retryAfterSeconds": 37 }

Need more? Cache on your side (the ETag and Cache-Control are there for that), ask for the release once and reuse its id, or talk to Pakumi — the same engine can run for your product with limits that fit.

Errors

Every error is the same JSON envelope, whatever produced it. details.code is the stable part; message is for humans and may change.

{ "timestamp": "…", "status": 422, "error": "Unprocessable Entity", "message": "Point 64.18, -51.69 is outside the ICON-EU domain",
  "path": "/api/v1/forecast", "requestId": "…", "details": { "code": "NO_COVERAGE" } }
StatusCodeWhen
400A parameter is missing or malformed (a non-numeric lat, a time that is not ISO-8601, a zoom above 18).
400TIME_OUT_OF_RANGEThe requested hour is before or after what the release holds.
403IP_BANNEDThe address was blocked by an operator (see fair use).
404RELEASE_UNAVAILABLENo release with that id is readable any more.
404FRAME_UNAVAILABLEThe release has no frame at that exact hour.
404LAYER_UNAVAILABLEUnknown tile layer, or a zoom above the served maximum.
404PLACE_NOT_FOUND/places/near found nothing within 30 km.
422NO_COVERAGEThe point is outside the ICON-EU domain (see the domain).
429RATE_LIMITEDOver the per-address budget; wait Retry-After seconds.
503RELEASE_UNAVAILABLENo forecast is published yet (first run after a fresh deployment); comes with Retry-After.
503API_DISABLEDAn operator switched the API off; Retry-After suggests when to look again.

Endpoints

All GET, all under /api/v1. Optional parameters are marked. Every JSON answer starts with a meta block (version, attribution, licence, link to this page); answers about a forecast also carry source — which model run answered and whether it is stale.

GET/api/v1

Discovery: the endpoint URLs, the limits in force right now, the attribution. A good first call.

GET/api/v1/status

What is being served: the current release (or null before the first ingest), the serve window (48 h from the top of this hour), the ICON-EU domain bounds, the server's clock, and whether the API is switched on.

GET/api/v1/releases

Every readable model run — the current one and, for a while, the one before it — with its state, run time, and first and last frame. /api/v1/releases/{id} adds every frame time the release holds. Pin a release id in your calls to get answers that never change (and tiles that cache for 6 hours).

GET/api/v1/layers

The tile layers, the stored field and unit behind each, and the maximum zoom (8). Currently:

precipitationPRECIP_MM · mmtemperatureT2M_C · degCwindU10+V10 · km/hcloudsCLCT · %pressurePMSL_HPA · hPasnowSNOW_MM · mm
GET/api/v1/legends

The colour stops of each layer's default palette, in the layer's unit, as rgba() — to draw the same legend the map shows.

GET/api/v1/conditions

The condition highlights: a threshold on one field each. Each forecast hour carries a verdict per condition under conditions, and each condition's palette can be passed to the tile endpoint (with fill_bound=true) to paint where it holds.

wetPRECIP_MM ≥ 0.1 mm/hdryPRECIP_MM < 0.1 mm/hheavy-rainPRECIP_MM ≥ 2.0 mm/hfreezingT2M_C < 0.0 degCstrong-windU10+V10 ≥ 40.0 km/hsnowSNOW_MM ≥ 0.1 mm/h
GET/api/v1/places?q=

Up to 8 matches from the bundled GeoNames gazetteer (European places by population); q may also be lat, lon. This is the same index the map's search box uses. The map's "search wider" fallback (OpenStreetMap Nominatim) is not proxied here — call Nominatim yourself under its usage policy.

curl -s "https://www.cloudiusz.pl/api/v1/places?q=Krak"
→ { "meta": …, "query": "Krak", "places": [ { "id": 3094802, "name": "Kraków", "country": "PL", "admin1": "Lesser Poland", "population": 755050, "lat": 50.06143, "lon": 19.93658, "source": "gazetteer" }, … ] }
GET/api/v1/places/near?lat=&lon=

The nearest known place within 30 km, for labelling a point; 404 PLACE_NOT_FOUND when there is none.

GET/api/v1/forecast?lat=&lon=

Every hour of a release at a point — the location card, the hour table and the meteogram in one answer (see the quick start).

ParameterMeaning
lat, lonThe point, decimal degrees. Must lie inside the ICON-EU domain.
release optionalA release id from /releases; default = the current release.
from optionalFirst hour to return (ISO-8601); default = the run time, i.e. the whole release.
hours optionalHow many hours, capped at 78.

Each hour holds three views of the same data: weather (the served values, rounded and described — weatherCode is an OpenWeatherMap-style condition code, wwCode the model's WMO code), raw (the twelve stored fields, unrounded; PRECIP_MM is the total for the hour ending at time, U10/V10 the wind components) and conditions (one verdict per condition, null when the data to decide is missing).

GET/api/v1/forecast/daily?lat=&lon=

The same forecast folded into UTC days, exactly as the Forecast view's day cards: high and low, precipitation and snow totals, the strongest gust, the worst daytime (06–18 UTC) condition as summary, and how many hours each condition highlight was on. Accepts release.

curl -s "https://www.cloudiusz.pl/api/v1/forecast/daily?lat=52.23&lon=21.01"
→ { "meta": …, "source": …, "location": …, "days": [
     { "date": "2026-09-16", "hours": 18, "tempMaxC": 21.3, "tempMinC": 11.8, "precipitationMm": 0.4, "snowMm": 0.0, "windGustMaxKmh": 31.2,
       "summary": "partly", "weatherCode": 802, "conditionHours": { "dry": 16, "wet": 2 } }, … ] }
GET/api/v1/weather?lat=&lon=

One hour at a point. time (optional, ISO-8601, an exact hour) defaults to the top of the current hour; release as above. The answer is a single hour in the /forecast shape plus lead, the hour's offset from the run.

curl -s "https://www.cloudiusz.pl/api/v1/weather?lat=48.86&lon=2.35&time=2026-09-17T09:00:00Z"
GET/api/v1/tiles/{layer}/{z}/{x}/{y}.png?time=

A weather tile; see Tiles below.

GET/api/v1/openapi.json

The machine-readable contract (OpenAPI 3.1). Not rate-limited; served even while the API is switched off.

Tiles

Web Mercator, 256 px, zoom 0–8, the same tiles the map draws — drop them into Leaflet, MapLibre or OpenLayers as an overlay on any basemap. time is required and must be an exact hour the release holds; add release to get a URL that never changes and caches for 6 hours. Outside the ICON-EU domain you get a transparent 1×1 PNG, never an error. The X-Tile-Source header says whether the tile was stored, rendered on demand or is outside-domain.

// Leaflet: the temperature layer for a given hour, on top of any basemap
L.tileLayer('https://www.cloudiusz.pl/api/v1/tiles/temperature/{z}/{x}/{y}.png?time=2026-09-16T15:00:00Z&release=icon-eu-2026091606-r1',
  { maxNativeZoom: 8, opacity: 0.75, attribution: 'Dane: DWD, ICON-EU, CC BY 4.0' }).addTo(map);

To paint a condition instead of a value, pass its palette from /conditions with fill_bound=true: the layer is drawn in one colour where the threshold holds and transparent elsewhere.

curl -s -o wet.png "https://www.cloudiusz.pl/api/v1/tiles/precipitation/5/17/10.png?time=2026-09-16T15:00:00Z&fill_bound=true&palette=0.1:FF3B30CC;0.1:FF3B3000"

Tiles count against the tile budget (300 a minute per address), not the JSON one. A map view at zoom 6 is about a dozen tiles; a full animation of the release at one zoom is hundreds, so play it from your own cache.

Data and attribution

The forecast is the ICON-EU model of the Deutscher Wetterdienst, ~7 km grid, published four times a day and ingested by cloudiusz within a couple of hours of each run. The domain is Europe and its surroundings — roughly 23.5° W to 62.5° E and 29.5° N to 70.5° N; /status gives the exact bounds. Each release holds up to 78 hourly frames from its run time; the map shows the next 48 hours as "current", and so does insideServeWindow.

The data is licensed CC BY 4.0. The one condition: show the attribution — every answer carries it in meta.attribution with meta.licenseUrl, so you can print it verbatim: Dane: Deutscher Wetterdienst (DWD), ICON-EU, CC BY 4.0. Place names come from GeoNames (CC BY 4.0).

A forecast is a forecast. cloudiusz passes the model through unchanged — no post-processing, no blending — which is the point of it, and also why you should not run anything safety-critical on it.

Fair use and privacy

The API is open so that people can build on it, not so that one script can pull the whole grid every minute. Honour Retry-After, cache, and send a User-Agent that says who you are. An address that keeps hitting the limit or scrapes at scale may be blocked (403 IP_BANNED); write to Pakumi if that happened to you by mistake.

What we keep: for each API call, the client address, the endpoint template (never the coordinates), the status and the duration, for 30 days, to run the service and see how it is used. No cookies are set by the API. The full policy is on the privacy page.

Changelog