Get started
Three APIs, one key: weather forecasts from ECMWF and national high-resolution models, heating and cooling degree days, and 87 years of daily climate. This page is everything you need before the first call. The endpoints themselves are on the three reference pages.
What this is#
Climate Memory serves three products from two hosts, on a single API key.
| Product | Host | Answers | Reference |
|---|---|---|---|
| Weather | https://api.climatememory.com | What the weather will do — forecast, current conditions, ensembles, air quality, sea state, rivers | Weather API |
| Degree days | https://api.climatememory.com | How much a building had to be heated or cooled — from 1950, anywhere | Degree Days API |
| Climate | https://api.climatememory.com | What is normal here — 87 years of daily climate, WMO normals, trends | Climate API |
Two hosts rather than one because degree days read a different archive with a different cost profile, and separating them lets one be slow without making the other slow. You do not have to care beyond copying the right base URL.
Every endpoint is a GET. There is no request
body anywhere in this API, no pagination cursor and no session. A call is a URL
plus a header, which means you can test any of it in a browser address bar with
a key in a query-string-free tool like curl, and cache any of it in
front of us with no special handling.
Your first call#
Three steps. The whole thing takes about a minute.
1. Get a key
Create an account at developers.climatememory.com/signin. The
free plan takes no card, gives you 10 000 credits a month, and your key is on
screen immediately. Keep it in an environment variable — the examples below all
read $API_KEY.
export API_KEY="wd_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
2. Make the call
curl -sH "X-API-Key: $API_KEY" \
"https://api.climatememory.com/v1/forecast?lat=48.85&lon=2.35&days=3"
import os, httpx
r = httpx.get(
"https://api.climatememory.com/v1/forecast",
params={"lat": 48.85, "lon": 2.35, "days": 3},
headers={"X-API-Key": os.environ["API_KEY"]},
timeout=30,
)
r.raise_for_status()
data = r.json()
print(data["daily"]["temperature_2m_max"])
const res = await fetch(
"https://api.climatememory.com/v1/forecast?lat=48.85&lon=2.35&days=3",
{ headers: { "X-API-Key": process.env.API_KEY } },
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const data = await res.json();
console.log(data.daily.temperature_2m_max);
req, _ := http.NewRequest("GET",
"https://api.climatememory.com/v1/forecast?lat=48.85&lon=2.35&days=3", nil)
req.Header.Set("X-API-Key", os.Getenv("API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil { return err }
defer res.Body.Close()
var out struct {
Daily struct {
TemperatureMax []float64 `json:"temperature_2m_max"`
} `json:"daily"`
}
json.NewDecoder(res.Body).Decode(&out)
3. Read the answer
Every series comes back column-oriented — parallel arrays
sharing one time index, not a list of objects. See
response conventions for why, and for how to read it.
Next: the same call by city name is usually better —
https://api.climatememory.com/v1/forecast/city/fr/paris carries the city's true elevation
and time zone, which a bare coordinate cannot. See
forecast by city.
Authentication#
Send your key in the X-API-Key header on every request. There is
no OAuth flow, no bearer token to refresh and no signature to compute.
X-API-Key: wd_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys are stored hashed. If you lose one it is rotated, not recovered — we cannot show it to you again, because we do not have it. Rotation is in the console and issues a new key immediately.
Scopes
A key carries scopes, and a plan decides which. Calling an endpoint outside
your scopes returns 403 scope_denied — which is a plan problem, not
a key problem, and the message says so.
| Scope | Unlocks |
|---|---|
meteo | The whole Weather API, including the extras |
dju | Degree days and the hourly history |
climate | The daily climate archive and its aggregates |
normals | WMO normals and the normals comparison |
The geocoding endpoints require none of them. Any valid key resolves a place name, on any plan, because every product here needs a coordinate before it can answer anything. See geocoding.
Never put your key in a browser. Call the API from your server and pass the result to the client. A key in front-end JavaScript is a key anyone can read from the network tab and spend against your quota — and because it is your key, the usage is indistinguishable from yours.
If you need weather in a mobile app, the mobile endpoints exist for exactly that: they issue a per-install token rather than embedding your key.
Geocoding#
Resolve a place name to coordinates, elevation and time zone.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | — | Place name, in any language. Accents optional. Required. |
lang | ISO-639-1 | en | Display and ranking only — never what is eligible. An unknown code falls back to local names rather than failing. |
near | lat,lon | — | The caller's position. The strongest tiebreaker there is. |
country | ISO-3166-1 alpha-2 | — | Restrict to one country. |
limit | int | 10 | 1–50. |
The catalogue is GeoNames feature class P in full — every populated place on earth, down to hamlets of a few dozen people, not a population-filtered extract. A village of 528 inhabitants is in it.
It costs no credits, on any plan, and needs no scope. A key sold for degree days resolves names too, because degree days cannot be requested without a coordinate. It carries a ceiling of its own instead — five geocoding requests per credit in your plan — which is deliberately generous enough that a debounced type-ahead search box is the intended use. See rate limits and quota.
Matching is never restricted by language
The query is tested against the local name, its ASCII transliteration and
every localised alias, whatever lang says. That matters
more than it sounds: 98 % of places have no localised name at all — French
covers 1.84 % of the catalogue — so a language-filtered search finds capitals
and nothing else. lang selects which name comes back and influences
ranking; it never decides what is eligible.
GET /v1/geocode?q=ramillies&lang=fr
GET /v1/geocode?q=ramillies&lang=fr&near=50.63,3.06 # from Lille
GET /v1/geocode?q=bruxelles&lang=fr # exonym, via the alias index
GET /v1/geocode?q=zuesch&lang=de # umlaut written out
How results are ranked
Each result carries a score and, when near was
given, a distance_km. Ranking combines four signals: how well the
name matched, how prominent the place is, how near it is to near,
and whether the matched name was in the requested language.
Why near exists. There are two Ramillies —
5 749 inhabitants in Walloon Brabant, 528 in the Hauts-de-France, 126 km apart.
Neither is the right answer in the abstract. Without near the larger
one is returned; from Lille, the French one is.
Prominence is not population alone. 90.7 % of the catalogue has no population recorded — GeoNames publishes none whatsoever for some countries — so the place type (national capital, seat of an administrative division, ordinary place, neighbourhood) carries the ranking wherever population is absent.
Umlauts are written out, not dropped. A German or Danish
keyboard without diacritics writes Zuesch for Züsch and
Koeln for Köln. GeoNames does not carry that spelling —
its ASCII form is Zusch, with the umlaut removed rather than
expanded — so it is generated here. Both spellings reach the place, and the
result shows the real name either way.
City lookup#
Older name search. Matches the ASCII name only, orders by population.
The nearest catalogued city to a coordinate.
/v1/cities/search predates /v1/geocode and keeps its
contract for the callers already built on it: it matches the ASCII name only and
orders by population.
Prefer /v1/geocode for anything
user-facing. The older route cannot match an exonym — Bruxelles
will not find Brussel — and its ordering is undefined for the nine
places in ten that have no population recorded.
/v1/cities/nearest is the reverse direction, and is what turns a
GPS fix into a city slug you can then pass to the city endpoints. It takes
lat, lon and an optional max_km.
Both count against the same geocoding ceiling as /v1/geocode, and
neither costs credits.
Response conventions#
Six rules hold across every endpoint. Learn them once and the rest of this documentation is just field names.
1. Series are columns, not rows
Every time series is a set of parallel arrays sharing one time
index, rather than an array of objects:
"hourly": {
"time": ["2026-08-01T00:00:00+00:00", "2026-08-01T01:00:00+00:00"],
"temperature_2m": [18.4, 18.1],
"precipitation": [0.0, 0.2]
}
Index i of every array describes the same instant. This is three
to five times smaller on the wire than the row form, it decodes straight into a
dataframe or a plotting library without a transform, and it is what
Open-Meteo-shaped clients already expect. To walk it as rows:
rows = zip(h["time"], h["temperature_2m"], h["precipitation"])
2. Times are ISO 8601 with an explicit offset
Always. 2026-08-01T00:00:00+00:00, never a bare local string and
never a Unix integer. Where you pass a timezone, the offset in the
response is that zone's — so the string alone is unambiguous and you never have
to know what we assumed.
3. Units are SI, fixed, and never negotiated
°C, mm, m/s, hPa, W/m², metres. There is no units=imperial
parameter, on purpose: a unit switch is a field whose meaning depends on another
field, and it is how a caller ends up plotting Fahrenheit on a Celsius axis after
a config change nobody reviewed. Convert at your edge, where the reader is.
Endpoints that carry unusual units ship a units block in the
response naming them explicitly, rather than expecting you to remember.
4. null means "not known", never "zero"
A missing hour is null in the array, holding its position so the
indices stay aligned. It is never silently filled with 0 — for precipitation the
two are opposite claims, and one of them is a lie about a drought.
5. Every response says where it came from
A source block (weather) or a quality block
(archives) travels with the data: which model or run answered, how old it is,
how complete the period was. You never have to infer freshness from the clock.
6. Stale beats absent
If our newest model run is older than expected, we still answer — with
"stale": true and data_age_hours in the
source block — rather than returning 503. A forecast eight hours old
is more useful than an error. Check the flag if freshness is load-bearing for
you; ignore it if it is not.
There is no pagination anywhere. A request returns its whole answer or fails with a range error telling you the maximum. Spans are bounded per endpoint instead — which means retry logic never has to handle a half-read result set.
Rate limits, credits and quota#
Three independent limits apply, and they fail differently on purpose. Every successful response reports where you stand:
| Header | Meaning |
|---|---|
X-RateLimit-Remaining | Requests left in the current sliding minute |
X-Quota-Remaining | Credits left this month |
X-Quota-Resets-At | ISO timestamp of the next monthly reset |
X-Geocode-Remaining | Geocoding requests left this month. Only on the catalogue endpoints, which are the only ones it bounds. |
Credits, not requests
A cached city forecast is a 3 ms read. Ten years of hourly degree days across five hundred sites is not. Pricing in requests would let a caller sit inside their quota and cost more than they pay, entirely legitimately — so what a call costs depends on how much archive it moves.
| Endpoint | Credits |
|---|---|
/v1/forecast, /v1/current, and the city forms | 1 |
/v1/probability, /v1/air-quality, /v1/marine, /v1/hydrology | 1 |
/v1/cells/resolve, /v1/climate/cells/resolve | 1 |
/v1/climate/normals | 3 |
/v1/climate/normals/compare | 6 — it answers two periods |
/v1/climate/daily, /v1/climate/monthly | 2, +1 per complete 365.25-day period in the span |
/v1/climate/summary | 3, +1 per complete 365.25-day period in the span — about 89 over the full archive |
/v1/degree-days and its city and cell forms | 2, +1 per complete 365.25-day period in the span |
/v1/degree-days/monthly | 2 |
/v1/historical | (years + 1) × (variables ÷ 2), rounded down, minimum 1 |
Any of the above with format=csv | 4× the JSON cost |
/v1/geocode, /v1/cities/search, /v1/cities/nearest | 0 — bounded separately |
/v1/models, /v1/methods, /v1/coverage, /v1/climate/coverage, /v1/climate/fields, /v1/historical/variables, /v1/attribution, /v1/licensing | 1, and no key needed |
Geocoding costs no credits. Every product here needs a coordinate before it can answer, so resolving a name is included rather than sold. A type-ahead search box is the intended use: debounce it and let it run.
It is bounded, though, and by its own ceiling rather than by your credits:
five geocoding requests per credit in your plan. On the 50 000
credit plan that is 250 000 searches a month, and a debounced search box spends
around six of them per place found. Exhausting it returns
429 geocode_quota_exceeded and leaves your credits untouched —
which is also why the two are separate codes.
What to do when you hit each one
| Limit | Response | Handling |
|---|---|---|
| Rate (per minute) | 429 rate_limited + Retry-After | Sleep for Retry-After seconds and retry. This one is transient by construction. |
| Credits (per period) | 429 quota_exceeded + X-Quota-Resets-At | Retrying will not help until the reset. Upgrade, or degrade your feature. |
| Geocoding ceiling | 429 geocode_quota_exceeded | Your credits are intact and every other endpoint still works. Debounce harder. |
Your quota period runs from your subscription date to the same day of the following month — the period Stripe invoices — and the counter starts again whole each time. X-Quota-Resets-At carries that date on every response, so read it rather than assuming the first of the month. Accounts with no subscription follow the calendar month.
Cache aggressively — we do not mind, and it is free quota. A forecast run changes 2 to 4 times a day; a degree-day total for a past month never changes at all. Nothing here is user-specific, so an ordinary HTTP cache in front of us is safe. The archive endpoints in particular are worth caching forever below the last five days.
Errors#
Every error has the same shape, on every host:
{
"error": {
"code": "range_too_long",
"message": "Requested span is 14.0 years; the maximum per request is 10.",
"max_years": 10
}
}
Build your retry logic on error.code, not on the HTTP
status. Three different conditions return 429 and they need
three different reactions: one wants a short sleep, one wants a plan change, and
one should not stop your application at all. The status alone cannot tell them
apart. Extra keys — max_years above — carry the limit you crossed,
so your client can adapt rather than guess.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_latitude, invalid_longitude | Fix the coordinates. Latitude is −90…90, longitude −180…180. |
| 400 | range_too_long | Split into several requests. max_years says the cap. |
| 400 | bad_request | The generic form, when a check has no more specific code of its own. Treat it as permanent — the request will not become valid by being retried. |
| 400 | invalid_coordinates | The pair is out of range or not a point on Earth. |
| 400 | invalid_method, invalid_base | See calculation methods. |
| 400 | invalid_breakdown | Not one of daily, weekly, monthly, yearly. |
| 400 | invalid_date | A date that is not ISO YYYY-MM-DD. |
| 400 | invalid_range | end precedes start. |
| 400 | invalid_near | near is not lat,lon. |
| 400 | unknown_field, unknown_variable | Ask /v1/fields, /v1/climate/fields or /v1/historical/variables what that product holds — none of the three needs a key. On /v1/forecast the message names the closest match to what you asked for. |
| 400 | unknown_period | Not a WMO reference period. See normals. |
| 400 | period_not_covered | The archive does not span that reference period at this point. |
| 401 | missing_api_key | The X-API-Key header was absent. |
| 401 | invalid_api_key | The key is unknown or rotated. |
| 401 | unauthorized | The generic form, when nothing more specific applies. |
| 402 | export_not_in_plan | CSV export is a paid-plan feature. |
| 403 | scope_denied | Your plan does not include this API. See scopes. |
| 403 | key_inactive | The key exists but has been revoked or disabled. Rotating a key does this to the old one — check the console before assuming an outage. |
| 404 | city_not_found | The response includes a suggestions array — show it. |
| 404 | cell_not_found | A pinned cell that no longer exists. See pinning a cell. |
| 404 | not_at_sea | A marine request over land. The wave model has no value there. |
| 404 | not_found | The generic form. As above: permanent. |
| 404 | no_city_nearby | Nothing catalogued within max_km of that point. |
| 404 | outside_archive | The dates are outside what the archive holds. /v1/climate/coverage says what it holds, and needs no key. |
| 429 | rate_limited | Back off for Retry-After seconds. |
| 429 | quota_exceeded | Wait for the reset or upgrade. Retrying sooner cannot succeed. |
| 429 | geocode_quota_exceeded | The geocoding ceiling only. Your credits are intact; debounce your search box. |
| 503 | data_unavailable | Transient. Retry with exponential backoff. |
| 503 | load_shed | Deliberately refused to protect the service under load. Your plan's criticality decides who is shed first. Retry with backoff; it clears in seconds. |
| 503 | catalogue_unavailable | The city catalogue is briefly out of reach. Coordinates still work — fall back to them rather than failing the request. |
That is the whole list, and it is checked against the source on every build: a code published here that no handler raises fails the test suite, and so does a handler raising one this table does not carry. If you meet a code that is not above, it is a bug in this page and not a value you should special-case.
A retry policy that works
RETRY = {"rate_limited", "data_unavailable"}
def should_retry(status, code, attempt):
if code == "quota_exceeded":
return False # nothing changes until the reset
if code == "geocode_quota_exceeded":
return False # and do not stop: credits still work
if code in RETRY:
return attempt < 5
return status >= 500
Accuracy — what to expect#
We would rather set expectations correctly than have you discover the limits in production.
Where the forecast is strong
Temperature, pressure, synoptic wind, humidity, sunshine. Typical 2 m temperature error at 24 hours is around 1–1.5 °C. ECMWF is the best global deterministic model in the world and it is what you are getting.
Where it is weaker
Convective storms — a 28 km cell cannot resolve a thunderstorm, and even 3 km only hints at one. Exact placement and timing of showers. Coastal and mountain microclimate below the model's resolution. Sea breezes and valley cold pools, which the terrain correction does not model.
Geographic honesty
In France, Germany, the USA and Canada we serve 1.3–3 km national models, and a small village genuinely gets its own grid cell. Elsewhere in Europe you get 6.5 km. Across Africa, the Middle East and most of Asia the best available public model is 13–28 km, so you get the weather of the area rather than of the street. The terrain correction narrows that gap but does not close it.
Degree days
Gridded reanalysis covers everywhere, including locations with no weather station within 60 km — that is its advantage over station-based providers. Its weakness is city centres, which reanalysis under-reads; the city endpoints apply a calibrated correction, coordinate endpoints do not.
Two fields are estimates, and we would rather say so
uv_index is derived from solar elevation and broadband radiation,
not from an ozone column — accurate to roughly ±1 unit.
precipitation_probability_proxy is an estimate, not a probability;
the genuine one is at /v1/probability. Both are
explained in full in the field reference.
Plans#
| Plan | Price | Credits / month | Rate | Keys | Scopes |
|---|---|---|---|---|---|
| Free | €0 | 10 000 | 20 / min | 1 | weather, plus degree days and climate for 30 days |
| Starter | €19 / month | 50 000 | 60 / min | 3 | weather + degree days + climate |
| Pro | €79 / month | 400 000 | 300 / min | 10 | weather + degree days + climate + normals |
| Business | from €299 / month | Agreed in contract | Agreed in contract | 50 | weather + degree days + climate + normals |
Geocoding sits outside this table: it costs no credits on any plan, and carries its own ceiling of five requests per credit. See rate limits and quota.
Annual billing is two months free. The live catalogue is at developers.climatememory.com/pricing — that page reads the billing system directly, so if the two ever disagree, it wins.
There is no bulk export endpoint on any plan. CSV export exists on the degree-day and climate archives, is bounded to the same span as the equivalent JSON call, and returns aggregated rows only — never the hourly series.
Attribution and licensing#
The exact required wording, machine-readable. No key needed.
The licence table behind every dataset. No key needed.
Attribution is a condition of use, not a courtesy. The underlying data is licensed to us under terms that require it, and those terms pass to you. Display a notice wherever the data appears, including in derived products. A footer line is enough:
Weather data: ECMWF, DWD, NOAA, Météo-France · Climate data: Copernicus/ERA5-Land · Places: GeoNames
Both endpoints above need no key, so you can render the notice from the API
rather than hard-coding a string that goes stale when a source changes. The
attribution for the specific run that answered also travels on every data
response, in source.attribution and in the
X-Data-Attribution header.
You may state that you use this data. You may not imply that any of these organisations produced, approved or endorses your product.
Disclaimer
Forecasts are provided without warranty. Neither the European Commission, ECMWF, DWD, NOAA nor Météo-France is responsible for any use made of this information. Do not use it as the sole basis for decisions where life, safety or property are at risk.
One restriction is not ours to waive: the Copernicus licence reserves flood warnings to national and regional authorities. See rivers.