climatememory developers

API reference

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.

ProductHostAnswersReference
Weatherhttps://api.climatememory.comWhat the weather will do — forecast, current conditions, ensembles, air quality, sea state, riversWeather API
Degree dayshttps://api.climatememory.comHow much a building had to be heated or cooled — from 1950, anywhereDegree Days API
Climatehttps://api.climatememory.comWhat is normal here — 87 years of daily climate, WMO normals, trendsClimate 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.

ScopeUnlocks
meteoThe whole Weather API, including the extras
djuDegree days and the hourly history
climateThe daily climate archive and its aggregates
normalsWMO 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#

GEThttps://api.climatememory.com/v1/geocodeany key0 credits

Resolve a place name to coordinates, elevation and time zone.

Parameters

ParameterTypeDefaultDescription
qstringPlace name, in any language. Accents optional. Required.
langISO-639-1enDisplay and ranking only — never what is eligible. An unknown code falls back to local names rather than failing.
nearlat,lonThe caller's position. The strongest tiebreaker there is.
countryISO-3166-1 alpha-2Restrict to one country.
limitint101–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#

GEThttps://api.climatememory.com/v1/cities/searchany key0 credits

Older name search. Matches the ASCII name only, orders by population.

GEThttps://api.climatememory.com/v1/cities/nearestany key0 credits

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:

HeaderMeaning
X-RateLimit-RemainingRequests left in the current sliding minute
X-Quota-RemainingCredits left this month
X-Quota-Resets-AtISO timestamp of the next monthly reset
X-Geocode-RemainingGeocoding 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.

EndpointCredits
/v1/forecast, /v1/current, and the city forms1
/v1/probability, /v1/air-quality, /v1/marine, /v1/hydrology1
/v1/cells/resolve, /v1/climate/cells/resolve1
/v1/climate/normals3
/v1/climate/normals/compare6 — it answers two periods
/v1/climate/daily, /v1/climate/monthly2, +1 per complete 365.25-day period in the span
/v1/climate/summary3, +1 per complete 365.25-day period in the span — about 89 over the full archive
/v1/degree-days and its city and cell forms2, +1 per complete 365.25-day period in the span
/v1/degree-days/monthly2
/v1/historical(years + 1) × (variables ÷ 2), rounded down, minimum 1
Any of the above with format=csv4× the JSON cost
/v1/geocode, /v1/cities/search, /v1/cities/nearest0 — bounded separately
/v1/models, /v1/methods, /v1/coverage, /v1/climate/coverage, /v1/climate/fields, /v1/historical/variables, /v1/attribution, /v1/licensing1, 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

LimitResponseHandling
Rate (per minute)429 rate_limited + Retry-AfterSleep for Retry-After seconds and retry. This one is transient by construction.
Credits (per period)429 quota_exceeded + X-Quota-Resets-AtRetrying will not help until the reset. Upgrade, or degrade your feature.
Geocoding ceiling429 geocode_quota_exceededYour 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.

StatusCodeWhat to do
400invalid_latitude, invalid_longitudeFix the coordinates. Latitude is −90…90, longitude −180…180.
400range_too_longSplit into several requests. max_years says the cap.
400bad_requestThe 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.
400invalid_coordinatesThe pair is out of range or not a point on Earth.
400invalid_method, invalid_baseSee calculation methods.
400invalid_breakdownNot one of daily, weekly, monthly, yearly.
400invalid_dateA date that is not ISO YYYY-MM-DD.
400invalid_rangeend precedes start.
400invalid_nearnear is not lat,lon.
400unknown_field, unknown_variableAsk /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.
400unknown_periodNot a WMO reference period. See normals.
400period_not_coveredThe archive does not span that reference period at this point.
401missing_api_keyThe X-API-Key header was absent.
401invalid_api_keyThe key is unknown or rotated.
401unauthorizedThe generic form, when nothing more specific applies.
402export_not_in_planCSV export is a paid-plan feature.
403scope_deniedYour plan does not include this API. See scopes.
403key_inactiveThe key exists but has been revoked or disabled. Rotating a key does this to the old one — check the console before assuming an outage.
404city_not_foundThe response includes a suggestions array — show it.
404cell_not_foundA pinned cell that no longer exists. See pinning a cell.
404not_at_seaA marine request over land. The wave model has no value there.
404not_foundThe generic form. As above: permanent.
404no_city_nearbyNothing catalogued within max_km of that point.
404outside_archiveThe dates are outside what the archive holds. /v1/climate/coverage says what it holds, and needs no key.
429rate_limitedBack off for Retry-After seconds.
429quota_exceededWait for the reset or upgrade. Retrying sooner cannot succeed.
429geocode_quota_exceededThe geocoding ceiling only. Your credits are intact; debounce your search box.
503data_unavailableTransient. Retry with exponential backoff.
503load_shedDeliberately refused to protect the service under load. Your plan's criticality decides who is shed first. Retry with backoff; it clears in seconds.
503catalogue_unavailableThe 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#

PlanPriceCredits / monthRateKeysScopes
Free€010 00020 / min1weather, plus degree days and climate for 30 days
Starter€19 / month50 00060 / min3weather + degree days + climate
Pro€79 / month400 000300 / min10weather + degree days + climate + normals
Businessfrom €299 / monthAgreed in contractAgreed in contract50weather + 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#

GEThttps://api.climatememory.com/v1/attributionno key needed0 credits

The exact required wording, machine-readable. No key needed.

GEThttps://api.climatememory.com/v1/licensingno key needed0 credits

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.