API Documentation

A stateless HTTP API over every published model + forecast cycle. One data endpoint/v1/{model}/{cycle}/forecast — serves every timeseries shape, dispatched on parameters (point, region, daily, journey) with multi-variable vars=; …/grid serves 2D map fields (JSON or compact binary); a few helpers describe what exists. Try any of these live in the playground.

Overview

The base URL is your service endpoint, e.g. https://api.weatherlabs.io. One service serves every retained cycle of every published model: pick yours in the path — {model} is e.g. gfs (discover via /v1/models) and {cycle} is either the literal latest (tracks the newest published cycle, re-resolved server-side every ~30 s) or a pinned compact init id (YYYYMMDDHH, e.g. 2026060312 — immutable, maximally cacheable). Coordinates are decimal degrees: latitude -90…90, longitude accepts both -180…180 and 0…360. The forecast is a series over lead-time steps aligned to the valid_times array returned by every series endpoint; every data response echoes model and the resolved compact cycle.

Two models are published. gfs is the deterministic GFS at 0.25°. gefs is the 31-member GFS ensemble at 0.25°: the plain variable name (t2m, tp, …) is the ensemble mean, and <var>_p10 / <var>_p90 are the pointwise 10th/90th-percentile confidence bounds across the members — request mean and bounds together in one call: /v1/gefs/latest/forecast?vars=t2m,t2m_p10,t2m_p90&lat=…&lon=…. The gefs catalog is surface-only (no pressure levels, no prate), 3-hourly to +240 h. Ensemble access is a paid capability; model discovery stays open to every key.

⚠ Weather data — not for safety-of-life use

The data this API returns is automatically processed numerical weather-model output, provided "as is". It may be inaccurate, incomplete, delayed or unavailable, and it is not suitable as the sole basis for any decision involving risk to life or property — including aviation, marine navigation or emergency management. See the full disclaimer in our Terms.

Authentication

Requests are authenticated with an API key passed as a bearer token in the Authorization header. Create and manage keys (up to three) on your Account page; each request counts against your plan's quota.

curl -H "Authorization: Bearer $WEATHERLABS_API_KEY" \
  "$BASE/v1/gfs/latest/forecast?lat=51.5074&lon=-0.1278&vars=t2m"

Never put a key in the URL. The server rejects keys passed as a query parameter (?api_key=…) with a 400 (api_key_in_query) — a key in a URL leaks into logs, history and referers. Always use the header.

Demo key. The public explorer and playground call the API with a shared demo key that has its own low limits, so they work without signing in. The demo key is for trying the API — for anything real, get your own free key from the Account page (it's £0 and takes a minute).

Rollout note. Authentication is being rolled out. Until enforcement is switched on server-side, requests without a key continue to work; once enforced, a missing or invalid key returns 401. Add the header now and your integration keeps working through the switch.

Pricing & metering

Plans differ by data allowance, available endpoints, grid resolution, forecast horizon, the variables you can query and the model cycles you can reach. Prices are in GBP and billing is handled by Paddle (our merchant of record). The Service is in beta — there is no SLA.

PlanPriceDataEndpointsResolutionHorizon
Free£010 MB / dayPoint forecasts5 days
Developer£3 / mo¹50 MB / day+ grid & region1.0°5 days
Hobby£29 / mo500 MB / moAll endpoints0.5°10 days
Starter£250 / mo5 GB / moAll endpoints0.25° (native)16 days
Growth£750 / mo50 GB / moAll endpoints0.25° (native)16 days
Scale£1,500 / moUnlimited²All endpoints0.25° (native)16 days

¹ Developer includes a 30-day free trial — try grids and regions for a month before the first charge.
² Scale is unlimited under fair use and adds bespoke perils and meteorology consultancy — contact sales.

The Free plan covers the basic perils (t2m temperature, tp precipitation, u10/v10 wind) on the 00z cycle only. Every paid plan — from Developer up — includes all variables (gust, pressure levels, and more) on every cycle (00/06/12/18z).

You may hold a maximum of three API keys per account; quotas are per customer (shared across your keys). Free/Developer quotas reset daily; Hobby and above reset monthly with the billing period.

How usage is metered

Usage is billed as decoded data volume: the number of forecast values a response returns × 4 bytes each, with a 32 KiB floor per request. For convenience we express this in call-equivalents: 1 call = 32 KiB. A small point lookup is 1 call; a big global grid is many. Honest examples:

RequestRoughly
Point forecast (…/forecast, full series)1 call
Global grid, one step, 1.0°≈ 8 calls
Global grid, one step, 0.5°≈ 32 calls
Global grid, one step, native 0.25°≈ 127 calls

So a £29 Hobby month (500 MB) is roughly 16,000 native point reads, or a few thousand half-degree global frames — region aggregates and journeys fall in between. Every data response also reports the exact bytes_read it touched, and responses carry quota headers so you never have to guess:

HeaderMeaning
X-Quota-LimitYour allowance for the current window (bytes).
X-Quota-RemainingBytes left before requests are rejected.
X-Quota-ResetWhen the window resets (epoch seconds).
X-Billed-BytesWhat this request counted against your quota (≥ 32 KiB).

When your quota is exhausted, further requests return 429 with Retry-After — there are no surprise overage charges; you upgrade or wait for the reset. You can read your live limits without spending much quota:

GET/v1/limits
curl -H "Authorization: Bearer $WEATHERLABS_API_KEY" "$BASE/v1/limits"
# → {"tier":"hobby","status":"active",
#     "quota":{"window":"month","limit_bytes":500000000,"used_bytes":12345678,
#              "remaining_bytes":487654322,"resets_at":"2026-07-01T00:00:00Z",
#              "call_equivalents":{"limit":15258,"used":376}},
#     "endpoints":{"point":true,"daily":true,"region":true,"grid":true,"journey":true},
#     "max_lead_hours":240,"min_resolution_deg":0.5,
#     "fair_use_rate_per_sec":10,"models":["gfs"],"max_keys":3}

Fair use. Within your data allowance, requests are also subject to reasonable rate limits to keep the Service responsive for everyone — smooth automated workloads to a steady rate rather than bursting at the cap. Sustained load far above fair-use rates may be throttled (429 + Retry-After); see the Acceptable Use Policy.

Variables & levels

Surface variables are 2-D; pressure variables take a level in hPa. The exact set is whatever the served cycle contains — query /v1/{model}/{cycle}/vars to enumerate it.

NameUnitsDescription
t2mK2 m temperature
u10 / v10m/s10 m wind components
gustm/sWind gust
pratekg/m²/sPrecipitation rate (instantaneous)
tpkg/m²Total precipitation (accumulated)
capeJ/kgConvective available potential energy
msl / spPaMean-sea-level / surface pressure
t, u, v, z, q, rhPressure-level fields (e.g. 850, 500 hPa)

Units are returned as-is from the source (Kelvin, Pascals, etc.) — convert client-side. The explorer and playground show the conversions used.

Time selection

Every /forecast shape (point, region, daily, journey) accepts ONE of three mutually-exclusive selections; omitted → all steps. The selection resolves once per request — every variable in vars= subsets by the same indices, so each response carries ONE shared valid_times.

FormMeaning
time=2026-06-04T12:00ZThe single valid time nearest the instant — must lie within the cycle's range (outside → 400).
start=…&end=…Inclusive valid-time range; either bound optional.
steps=0,6,12Explicit lead hours — each must exist in the cycle (unknown hour → 400 listing the available hours).
steps=0-48Every stored lead hour within the inclusive range.

Accepted datetime forms: RFC 3339 (2026-06-04T12:00:00Z, offsets applied: …T13:00:00+01:00), the no-seconds form 2026-06-04T12:00Z (UTC), and date-only 2026-06-04 (as start → 00:00:00, as end → 23:59:59; ambiguous as time=400).

steps= are lead HOURS, never array indices. GFS steps are hourly to h120 and 3-hourly after, so steps=123 works but steps=121 is a 400. The one index-typed parameter in the API is /grid's step=. Daily aggregates over a partial selection produce partial days, but precip totals stay exact — increments are computed over the full series, then subset.

GET /v1/models

Discovery: every model this deployment publishes.

GET/v1/models
curl "$BASE/v1/models"
# → [{"name":"gfs","description":"…","grid":{"nlat":721,"nlon":1440,…},
#      "latest":"2026060312","cycles_count":4}]

GET /v1/models/{model}

One model plus its latest-cycle layout: n_steps, step_hours, vars, the init date_range and grid.

GET/v1/models/{model}
curl "$BASE/v1/models/gfs"
# → {"name":"gfs","latest":"2026060312","cycles_count":4,
#     "date_range":["2026060212","2026060312"],"n_steps":209,
#     "step_hours":[0,1,…],"vars":["cape","gust","t2m",…],"grid":{…}}

GET /v1/models/{model}/cycles

Every retained cycle — pin one as /v1/{model}/{compact}/… for immutable URLs.

GET/v1/models/{model}/cycles
curl "$BASE/v1/models/gfs/cycles"
# → {"model":"gfs","latest":"2026060312",
#     "cycles":["2026060212","2026060300","2026060306","2026060312"],
#     "date_range":["2026060212","2026060312"]}

GET /v1/{model}/{cycle}/cycle

Metadata for the scoped cycle: init time, step count, step hours and valid-times.

GET/v1/{model}/{cycle}/cycle
curl "$BASE/v1/gfs/latest/cycle"
# → {"model":"gfs","cycle":"2026060312","init_time":"…","init_compact":"2026060312",
#     "n_steps":209,"step_hours":[0,1,2,…],"valid_times":["…Z",…],"tile":16}

GET /v1/{model}/{cycle}/vars

Every variable in the cycle, with kind, units, long name and pressure levels.

GET/v1/{model}/{cycle}/vars
curl "$BASE/v1/gfs/latest/vars"
# → [{"name":"t2m","kind":"surface","units":"K","long_name":"…","levels":[]},
#     {"name":"t","kind":"pressure","units":"K","levels":[850,500]}, …]

GET /v1/{model}/{cycle}/forecast — the endpoint

One endpoint for every timeseries shape, dispatched on parameters. Give it lat+lon for a point, or region=/a bbox for an aggregate — both or neither is a clear 400. vars= takes a comma-separated list (up to 8 per request; duplicates or one unknown name fail the whole request) and the response keys per-variable payloads under "vars" with ONE shared valid_times. A point's entire series lives in a single tile, so a point call is one object fetch and decode per variable — and the 32 KiB billing floor applies once per request, so multi-var calls are the cheap way to fetch several fields.

GET/v1/{model}/{cycle}/forecast
ParamTypeDescription
varsstringComma-separated variable names, e.g. t2m,tp. Required.
lat, lonfloatPoint shape: latitude −90…90, longitude −180…180 or 0…360. Mutually exclusive with region/bbox.
region / lat_s…lon_eRegion shape — see region aggregates.
granularitystringhourly (default) or daily — see daily aggregates.
levelfloatPressure level in hPa — request-global (applies to every pressure var in vars=; surface vars ignore it).
interpstringnearest (default) or bilinear (point shape).
time / start+end / stepsOptional time selection; steps= are lead hours.
curl "$BASE/v1/gfs/latest/forecast?lat=51.5074&lon=-0.1278&vars=t2m,tp&interp=bilinear&steps=0-48"
# → {"model":"gfs","cycle":"2026060312","granularity":"hourly","valid_times":[…],
#     "vars":{"t2m":{"units":"K","values":[…]},"tp":{"units":"kg m**-2","values":[…]}},
#     "tiles_read":2,"bytes_read":…}

Daily aggregates — granularity=daily

The same endpoint with granularity=daily: per-variable daily aggregates derived from the native step series — min/max/mean for instantaneous fields, trapezoidal integrals for rates (prate), and accumulation-differenced totals for tp — each variable picks its own physically-correct reducer in the same request. With a time selection, only the selected steps contribute (partial days are partial — but precip totals are always computed on the full series first, so they never inflate). Daily is point-only for now: daily region/journey returns 400 unsupported.

GET/v1/{model}/{cycle}/forecast?granularity=daily
curl "$BASE/v1/gfs/latest/forecast?lat=40.71&lon=-74.01&vars=t2m&granularity=daily"
# → {"model":"gfs","cycle":"…","granularity":"daily",
#     "vars":{"t2m":{"units":"K","daily":[{"date":"2026-06-03","min":…,"max":…,"mean":…}, …]}}}

GET /v1/{model}/{cycle}/regions

The named regions this service can aggregate over — continents, countries, and sub-national areas like Greater London. Each is a true polygon boundary (Natural Earth), precomputed onto the forecast grid: "mean over the UK" aggregates only grid cells whose centers fall inside the UK polygon, not a bounding box that would also cover Ireland and northern France.

GET/v1/{model}/{cycle}/regions
curl "$BASE/v1/gfs/latest/regions"
# → [{"name":"africa","display_name":"Africa","kind":"continent",
#      "bbox":[-34.75,37.25,-25.25,51.25],"n_cells":40926}, …]

bbox is [lat_s, lat_n, lon_w, lon_e]; lon_w > lon_e means the region crosses the antimeridian (e.g. usa, russia). n_cells is the number of grid cells inside the polygon.

Region aggregates — region= or a bbox (single step)

The same endpoint with a spatial selection instead of lat+lon: aggregate each variable over a named region polygon (region=) or a raw bounding box, at exactly one forecast step. Region requests are single-step by design — each one is served from a single step-major frame object (cold in tens of milliseconds) and is independently cacheable at the edge. Select the step with steps= (one lead hour) or time=; a request resolving to more than one step (including no selector at all) returns 400 region_single_step. For a series over a region, issue one request per step — note each request bills the 32 KiB minimum. Named-region means are cos-latitude area-weighted (a 0.25° cell at 70°N covers ~⅓ the area of an equatorial one); bbox means remain plain unweighted cell means. Large areas auto-coarsen (cell striding) — the response reports stride and coarsened.

GET/v1/{model}/{cycle}/forecast?region= | bbox
ParamTypeDescription
regionstringNamed region (see …/regions). Mutually exclusive with the bbox params (and with lat+lon).
lat_s, lat_nfloatSouth / north latitude bounds. Required without region.
lon_w, lon_efloatWest / east longitude bounds. Required without region.
varsstringComma-separated variable names. Required.
levelfloatPressure level (pressure vars).
aggstringmean (default), min, or max.
steps / timeRequired: a time selection resolving to exactly one stepsteps=24 (one lead hour) or time= (nearest datetime). Anything else is 400 region_single_step.
curl "$BASE/v1/gfs/latest/forecast?region=uk&vars=gust&agg=max&steps=24"
# → {"model":"gfs","cycle":"…","agg":"max","region":"uk","bbox":[50.25,60.5,-8,1.75],
#     "valid_times":["…T00:00:00Z"],"vars":{"gust":{"units":"m s**-1","values":[18.7]}},"n_cells":535,…}

curl "$BASE/v1/gfs/latest/forecast?lat_s=49&lat_n=59&lon_w=-8&lon_e=2&vars=gust&agg=max&time=2026-06-08T12:00Z"
# → {"agg":"max","region":null,"bbox":[…],"vars":{"gust":{"values":[…1 value…]}},"n_cells":…,"stride":1,"coarsened":false}

POST /v1/{model}/{cycle}/forecast — journey

The same endpoint, POSTed with a waypoint body: one forecast series per waypoint per variable, fetched concurrently — waypoints that share a tile coalesce. Useful for routes, fleets, or a set of stations (max 512 waypoints). Optional "time"/"start"/ "end"/"steps" body fields apply a time selection, resolved once for all waypoints and variables. Units are hoisted to a top-level "vars" map; points carry values only.

POST/v1/{model}/{cycle}/forecast
curl -X POST "$BASE/v1/gfs/latest/forecast" -H "Content-Type: application/json" -d '{
  "vars": "t2m", "interp": "bilinear", "steps": "0-48",
  "points": [{"lat":51.51,"lon":-0.13},{"lat":53.48,"lon":-2.24},{"lat":55.95,"lon":-3.19}]
}'
# → {"model":"gfs","cycle":"…","valid_times":[…],"vars":{"t2m":{"units":"K"}},
#     "points":[{"lat":…,"lon":…,"vars":{"t2m":{"values":[…]}}}, …]}

GET /v1/{model}/{cycle}/grid

A full lat/lon field for one forecast step — the data the explorer renders. Returns JSON by default, or a compact little-endian binary with format=bin (a 72-byte header followed by row-major float32 values, north row first; X-Model/X-Cycle response headers carry the scope). Defaults to native resolution; pass max_cells to coarsen the output (note: a global slice reads the same tiles either way, so coarsening reduces transfer/CPU, not I/O).

step= is an ARRAY INDEX into the cycle's steps — NOT a lead hour. For all-hourly products index == hour, which hides the difference until the 3-hourly tail: index 130 is lead-hour 150 on a 209-step GFS cycle. To pick a datetime use time= instead (nearest single step; mutually exclusive with step=). Multi-step selections (steps=, start=, end=) are a 400 here.

GET/v1/{model}/{cycle}/grid
ParamTypeDescription
varstringVariable name. Required.
stepintForecast step index (default 0) — not a lead hour.
timestringDatetime → nearest single step. Mutually exclusive with step.
levelfloatPressure level (pressure vars).
regionstringNamed region (see …/regions): crops to the region's bbox and returns NaN outside the polygon. Mutually exclusive with the bbox params.
lat_s, lat_n, lon_w, lon_efloatBounding box (default global).
max_cellsintCoarsen so output cells ≤ this.
formatstringjson (default) or bin.
curl "$BASE/v1/gfs/latest/grid?var=t2m&step=8"
# JSON → {"model":"gfs","cycle":"…","nlat":721,"nlon":1440,"lat0":90,"dlat":-0.25,
#         "lon0":0,"dlon":0.25,"vmin":…,"vmax":…,"valid_time":"…Z","values":[…]}

curl "$BASE/v1/gfs/latest/grid?var=t2m&step=8&format=bin" --output field.bin
# binary → headers X-Model, X-Cycle, X-Tiles-Read, X-Bytes-Read, X-Valid-Time + float32 payload

Removed routes & parameters

The pre-consolidation routes are gone (404): the unscoped legacy tree (/v1/cycle, /v1/vars, /v1/regions, /v1/forecast[…], /v1/region, /v1/grid, /v1/journey) and the per-shape scoped routes (…/forecast/hourly, …/forecast/daily, …/region, …/journey). Their shapes all live on the unified …/forecast via parameters. Two retired parameters return a hard 400 naming the replacement: var= (singular) → vars=, and the old cycle= query parameter → pin cycles in the path instead (/v1/gfs/2026060312/…).

Caching

Responses carry Cache-Control chosen by scope: pinned-compact URLs are public, max-age=31536000, immutable (a published cycle never changes under its id); latest-scoped URLs are public, max-age=30 (matching the server's own latest-resolution interval); discovery endpoints are public, max-age=60. Pin the compact id whenever you fan many requests over one cycle.

Response format

Every /forecast response shares a shape: model and the resolved compact cycle, granularity, ONE valid_times array (ISO-8601), and a "vars" object keyed by your requested names — each carrying units plus values (aligned to valid_times, subset together under a time selection) or daily. Key order inside "vars" is not significant — index by name. Responses also carry init_time and the tiles_read / bytes_read the request actually touched. Missing data is null/NaN, never silently zero.

Errors

Errors return the appropriate HTTP status with a JSON envelope:

# 429 example
{
  "error": "hobby plan quota exhausted for this month — resets at 2026-07-01T00:00:00Z; upgrade for a larger quota",
  "code": "quota_exceeded",
  "request_id": "8f2c…",
  "quota": {"limit": 500000000, "remaining": 0, "resets_at": "2026-07-01T00:00:00Z", "window": "month", "unit": "bytes"}
}

error is a human-readable message; code is a stable machine token you can branch on; request_id identifies the request in our logs (quote it in support requests); quota is included on quota/billing errors. Auth, quota and billing codes:

CodeHTTPMeaning & fix
no_api_key401No key sent. Add Authorization: Bearer <key>.
invalid_key401Key not recognised. Check it was copied in full from the Account page.
key_revoked401Key was revoked. Generate a new one and update your client.
tier_forbidden403Endpoint/resolution/horizon not in your plan. Upgrade to unlock it.
quota_exceeded429Data allowance used up. Wait for Retry-After/reset, or upgrade. No overage is billed.
payment_past_due403A renewal payment failed and the grace period ended. Update billing from the dashboard to restore access.
subscription_canceled403Subscription canceled. Resubscribe from the dashboard to restore access.
account_suspended403Account suspended (e.g. AUP breach or unpaid). Contact support.
model_forbidden403The requested model is not in your tier. Multi-model access is on the roadmap.
demo_disabled403The shared demo key is unavailable or rate-limited. Use your own key.
api_key_in_query400Key was passed as ?api_key=. Send it in the Authorization header instead.
origin_forbidden403Request origin not permitted for this key. Check any configured allowed origins.

Request-shape errors keep the same envelope and the existing 400/404 semantics: 400 for a missing/empty vars=, more than 8 names (too_many_vars), a duplicate or unknown variable (one bad name fails the whole request — no partial responses), both a point AND a region selection (or neither), granularity=daily on a region/journey shape (unsupported), an out-of-range step index, a pressure level the cycle doesn't carry, a malformed or out-of-range time selection, or the retired var=/cycle= parameters (each 400 names its replacement); 404 for an unknown model (the message lists the available models), an unknown or pruned cycle (the message lists the retained cycles and the latest), or an unknown route — including every pre-consolidation route.

API stability & deprecation

The API is versioned under /v1. Backwards-compatible additions — new endpoints, new optional parameters, new fields in a response — can happen at any time, so write clients that tolerate unknown fields. Breaking changes get either a new version prefix or at least 90 days' notice via email and the changelog before they take effect.

The legacy unscoped aliases (/v1/forecast, /v1/grid, etc.) remain supported as the deployment's default model at latest. Pinning a model and a compact cycle in the path (/v1/gfs/2026060312/…) is the stable, maximally-cacheable form and is recommended for production. Additional models are on the roadmap and are not part of the API until generally available.

Performance