Elevation API

Ground heights at points from the same USGS 3DEP terrain kpterrain serves to Cesium: 1 m lidar where USGS has it, 10 m everywhere else in the contiguous US. Ask in WGS 84 or NAD83(2011) and get NAVD88, EGM96 or ellipsoid heights back, with every reference spelled out.

Contents

  1. Quick start
  2. Access and keys
  3. One point: GET
  4. Many points: POST
  5. Parameters
  6. Response fields
  7. Choosing datum and height
  8. Coverage, sources and accuracy
  9. Comparing with Google
  10. Errors
  11. Limits and caching
  12. Code examples

Quick start

curl "https://api.kpterrain.com/v1/elevation?lat=40.1093&lon=-77.0275&datum=wgs84&key=YOUR_API_KEY"
{
  "height": 173.486,
  "height_type": "geoid18",
  "vertical_reference": "NAVD88 (GEOID18)",
  "input": { "lat": 40.1093, "lon": -77.0275, "datum": "WGS 84 (G2296)", "epoch": 2026.7618 },
  "sampled_at": { "lat": 40.109290684, "lon": -77.027491805, "datum": "NAD83(2011)" },
  "source": "usgs-3dep-1m",
  "source_date": "2025-08-05",
  "zoom": 17
}

That is the NAVD88 elevation, in metres, of the ground at a WGS 84 point in Dillsburg, PA. The point was moved 1.25 m to its NAD83(2011) position before the terrain was sampled (sampled_at), and the height comes from USGS 1 m lidar published on 2025-08-05.

Access and keys

The API takes the same API keys as the kpterrain tiles. Send the key as the key query parameter on every request, POST included. Katapult LiDAR includes access; anyone else can request a key from Katapult Engineering.

GETOne point

GET https://api.kpterrain.com/v1/elevation?lat=LAT&lon=LON&datum=wgs84|nad83
    [&height=geoid18|egm96|ellipsoid] [&output_datum=wgs84|nad83] [&epoch=2026.5|2026-07-01]
    &key=YOUR_API_KEY

Answers with the object shown in the quick start, or an error.

POSTMany points

Up to 1,000 points in one request, all with the same options. The body is JSON; the key stays in the URL.

POST https://api.kpterrain.com/v1/elevation?key=YOUR_API_KEY
Content-Type: application/json

{
  "datum": "wgs84",
  "height": "egm96",
  "points": [
    { "lat": 40.1093, "lon": -77.0275 },
    { "lat": 39.3, "lon": -116.2 },
    { "lat": 30.0, "lon": -140.0 }
  ]
}
{
  "height_type": "egm96",
  "vertical_reference": "EGM96 geoid, from WGS 84 (G2296) ellipsoid heights",
  "input": { "datum": "WGS 84 (G2296)", "epoch": 2026.7618 },
  "results": [
    { "lat": 40.1093, "lon": -77.0275, "height": 172.86,
      "sampled_at": { "lat": 40.109290684, "lon": -77.027491805, "datum": "NAD83(2011)" },
      "source": "usgs-3dep-1m", "source_date": "2025-08-05", "zoom": 17 },
    { "lat": 39.3, "lon": -116.2, "height": 1973.031,
      "sampled_at": { "lat": 39.299997385, "lon": -116.199982507, "datum": "NAD83(2011)" },
      "source": "usgs-3dep-10m", "zoom": 15 },
    { "lat": 30, "lon": -140, "height": null, "error": "no_terrain", "detail": "no terrain here" }
  ]
}

results are in the order of points. A point that can't be answered gets "height": null with an error code and detail; the rest of the batch is still answered and the response is HTTP 200. A bad request as a whole (an invalid parameter or point, malformed JSON, too many points) fails with a 4xx error instead.

Parameters

Query parameters for GET; JSON fields of the body for POST (where lat/lon go in points). An empty value counts as not given.

NameRequiredValuesMeaning
lat, lonyesdecimal degreesThe point. Latitude ±90, longitude ±180 (west is negative).
datumyeswgs84, nad83The datum of lat/lon: WGS 84 (its current realization, G2296) or NAD83(2011). See choosing a datum; getting it wrong samples ground 1–1.7 m away.
heightnogeoid18 (default), egm96, ellipsoidWhich height. geoid18: NAVD88 orthometric height, US land only. egm96: height above the EGM96 geoid, land only. ellipsoid: height above the ellipsoid of output_datum.
output_datumnowgs84, nad83; default datumThe ellipsoid frame for height=ellipsoid. Ignored (with a note) for geoid heights.
epochnodecimal year (2022.5) or date (2022-07-01), 1990–2100; default nowWhen the WGS 84 coordinates were observed. Used only when WGS 84 is involved. See epochs.
keyyesyour API keyAlways in the query string.

Response fields

FieldMeaning
heightMetres, to the millimetre (accuracy is coarser).
height_typegeoid18, egm96 or ellipsoid, as asked.
vertical_referenceThe height's reference in words: NAVD88 (GEOID18), EGM96 geoid, from WGS 84 (G2296) ellipsoid heights, NAD83(2011) ellipsoid or WGS 84 (G2296) ellipsoid.
inputThe request as understood: lat, lon (GET only), datum, epoch (when WGS 84 is involved) and output_datum (ellipsoid heights only).
sampled_atThe NAD83(2011) position where the terrain was sampled. Equal to the input for datum=nad83.
sourceWhere the height comes from: usgs-3dep-1m, usgs-3dep-10m, copernicus-glo90 (outside USGS data) or ocean.
source_dateFor 1 m data: the date in the USGS file name.
in_usPresent, as false, where USGS 10 m data lies outside the US (it reaches a little into Canada and Mexico). No GEOID18 height is given there.
zoomThe terrain tile level that answered: 17 for 1 m data, 15 (or 13–16) for 10 m.
notesPresent when a parameter was ignored, e.g. epoch with datum=nad83.

Choosing datum and height

Which datum are my coordinates in?

Coordinates fromUse
Google Maps, raw GNSS / phone GPS fixes, ITRF datadatum=wgs84
kpterrain tiles, Cesium on kpterrain, Katapult LiDARdatum=nad83
NAD83 survey data, state plane, USGS lidar, GIS data labelled EPSG:4326 that is really NAD83datum=nad83

The terrain itself is NAD83(2011). Many tools label NAD83 coordinates "WGS84" without converting them (the EPSG:9774 convention); if your coordinates came from such a tool, they are NAD83. In CONUS the two datums differ by 1–1.7 m horizontally, so on a slope or next to a curb the wrong choice gives a different height.

Which height?

You wantAsk forComputed as
Elevation for survey, engineering, US maps (NAVD88)height=geoid18NAD83(2011) ellipsoid height − GEOID18
Values comparable with Google's Elevation APIheight=egm96WGS 84 ellipsoid height − EGM96
Height above the NAD83(2011) ellipsoidheight=ellipsoid&output_datum=nad83The terrain's own height
Height above the WGS 84 ellipsoid (GNSS-style)height=ellipsoid&output_datum=wgs84The 3-D point converted to WGS 84; 0.3–1.6 m below NAD83

A GEOID18 (NAVD88) height doesn't depend on the input datum: the same spot gives the same NAVD88 height whether you send its WGS 84 or its NAD83 coordinates. GEOID18 is defined against NAD83(2011) and EGM96 against WGS 84, and the API only ever applies each to its own ellipsoid.

GEOID18 is fitted to NAVD88 benchmarks in the contiguous US, so geoid18 heights are given only on US land; elsewhere the API answers 422 and suggests egm96 or ellipsoid. EGM96 is a global model, answered on any land. Over the ocean only ellipsoid heights are given.

Epochs

WGS 84 is tied to the Earth as a whole, NAD83 to the North American plate, so a fixed spot's WGS 84 coordinates drift about 2 cm a year against NAD83. epoch is when the coordinates were observed; the default, the time of the request, suits Google Maps and fresh GNSS fixes. For older GNSS data pass its date (epoch=2019-06-15); ten years off moves the sampled point about 20 cm. Intraplate motion near the Pacific plate boundary (a few cm a year in coastal California) is not modelled.

Coverage, sources and accuracy

Comparing with Google

Google documents its elevations only as "relative to local mean sea level". They appear to be EGM96 heights, and EGM96 sits 0.6 m below to 1.7 m above NAVD88 across CONUS. Call this API the way you call Google (datum=wgs84) with height=egm96 to compare like with like; differences that remain come from the source data. Use geoid18 for anything that has to match US survey data.

# Google-comparable                         # NAVD88, for survey work
...&datum=wgs84&height=egm96                  ...&datum=wgs84&height=geoid18

Errors

Errors are JSON, {"error": "code", "detail": "explanation"}, with CORS headers. In a batch, per-point errors use the same codes inside results.

StatuserrorWhen
400invalid_parameterA missing or invalid parameter or point (detail says which).
400invalid_jsonA POST body that isn't the expected JSON object.
403(not JSON)Missing, wrong or revoked key: Cloudflare's block page.
404no_terrainNo terrain at the point (outside coverage).
404not_foundAny path other than /v1/elevation.
405method_not_allowedMethods other than GET, POST and OPTIONS.
413too_many_pointsMore than 1,000 points.
413body_too_largeA body over 128 KB.
422geoid_not_valid_hereA geoid height where that geoid doesn't apply: geoid18 off US land, either geoid over the ocean. Ask for ellipsoid (or egm96 on land).
502upstreamTerrain data couldn't be read. Retry after a moment.

Limits and caching

Code examples

JavaScript (browser or Node 18+)

const API = "https://api.kpterrain.com/v1/elevation";

async function elevation(lat, lon, { datum = "wgs84", height = "geoid18", key }) {
  const url = new URL(API);
  url.search = new URLSearchParams({ lat, lon, datum, height, key });
  const response = await fetch(url);
  const body = await response.json();
  if (!response.ok) throw new Error(`${body.error}: ${body.detail}`);
  return body.height;
}

async function elevations(points, { datum = "wgs84", height = "geoid18", key }) {
  const response = await fetch(`${API}?key=${encodeURIComponent(key)}`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ datum, height, points }),   // points: [{ lat, lon }, …], at most 1,000
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`${body.error}: ${body.detail}`);
  return body.results.map((r) => r.height);   // null where a point has no answer (see r.error)
}

Python

import requests

API = "https://api.kpterrain.com/v1/elevation"

def elevations(points, key, datum="wgs84", height="geoid18"):
    """points: [(lat, lon), ...]; returns heights in metres (None where unavailable)."""
    out = []
    for i in range(0, len(points), 1000):
        body = {"datum": datum, "height": height,
                "points": [{"lat": lat, "lon": lon} for lat, lon in points[i:i + 1000]]}
        response = requests.post(API, params={"key": key}, json=body, timeout=60)
        response.raise_for_status()
        out += [r["height"] for r in response.json()["results"]]
    return out

Replacing Google's Elevation API

Google: …/maps/api/elevation/json?locations=40.1093,-77.0275&key=… returns results[0].elevation. Here: …/v1/elevation?lat=40.1093&lon=-77.0275&datum=wgs84&height=egm96&key=… returns height (or height=geoid18 for NAVD88). For many points, POST them in batches of up to 1,000 instead of Google's pipe-separated locations.