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
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.
- A request without a current key gets HTTP 403 from Cloudflare's edge: a plain HTML page, not JSON, and without CORS headers, so a browser reports it as a network error.
- CORS is open (
Access-Control-Allow-Origin: *); preflight requests need no key. - Keep keys out of shared code and logs. A key built into a web page is visible to its users.
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.
| Name | Required | Values | Meaning |
|---|---|---|---|
lat, lon | yes | decimal degrees | The point. Latitude ±90, longitude ±180 (west is negative). |
datum | yes | wgs84, nad83 | The 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. |
height | no | geoid18 (default), egm96,
ellipsoid | Which 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_datum | no | wgs84, nad83; default
datum | The ellipsoid frame for height=ellipsoid. Ignored (with a note) for geoid
heights. |
epoch | no | decimal year (2022.5) or date
(2022-07-01), 1990–2100; default now | When the WGS 84 coordinates were observed. Used only when WGS 84 is involved. See epochs. |
key | yes | your API key | Always in the query string. |
Response fields
| Field | Meaning |
|---|---|
height | Metres, to the millimetre (accuracy is coarser). |
height_type | geoid18, egm96 or ellipsoid, as asked. |
vertical_reference | The height's reference in words: NAVD88 (GEOID18),
EGM96 geoid, from WGS 84 (G2296) ellipsoid heights, NAD83(2011) ellipsoid or
WGS 84 (G2296) ellipsoid. |
input | The request as understood: lat, lon (GET only),
datum, epoch (when WGS 84 is involved) and output_datum (ellipsoid heights
only). |
sampled_at | The NAD83(2011) position where the terrain was sampled. Equal to the input
for datum=nad83. |
source | Where the height comes from: usgs-3dep-1m,
usgs-3dep-10m, copernicus-glo90 (outside USGS data) or ocean. |
source_date | For 1 m data: the date in the USGS file name. |
in_us | Present, 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. |
zoom | The terrain tile level that answered: 17 for 1 m data, 15 (or 13–16) for 10 m. |
notes | Present when a parameter was ignored, e.g. epoch with
datum=nad83. |
Choosing datum and height
Which datum are my coordinates in?
| Coordinates from | Use |
|---|---|
| Google Maps, raw GNSS / phone GPS fixes, ITRF data | datum=wgs84 |
| kpterrain tiles, Cesium on kpterrain, Katapult LiDAR | datum=nad83 |
| NAD83 survey data, state plane, USGS lidar, GIS data labelled EPSG:4326 that is really NAD83 | datum=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 want | Ask for | Computed as |
|---|---|---|
| Elevation for survey, engineering, US maps (NAVD88) | height=geoid18 | NAD83(2011) ellipsoid height − GEOID18 |
| Values comparable with Google's Elevation API | height=egm96 | WGS 84 ellipsoid height − EGM96 |
| Height above the NAD83(2011) ellipsoid | height=ellipsoid&output_datum=nad83 | The terrain's own height |
| Height above the WGS 84 ellipsoid (GNSS-style) | height=ellipsoid&output_datum=wgs84 | The 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
- Where: the contiguous US and its borders. Outside USGS data (most of Canada and Mexico near the
border, the Canadian halves of the Great Lakes) heights come from Copernicus GLO-90 (90 m); the sea is at sea
level. Elsewhere the API answers 404
no_terrain. - What: the height of the terrain mesh Cesium draws, interpolated within its triangles. It follows the source data closely: about 4 cm RMS (9 cm at the 99th percentile) from 1 m lidar, and about 0.4 m RMS from 10 m data. The lidar itself is typically accurate to about 10 cm.
- Checked against USGS: against USGS's own Elevation Point Query Service, answers differ by 2–14 cm on 1 m data and 13–25 cm on 10 m data at test points.
- Bare earth: heights are of the ground (USGS DEMs), not buildings or trees.
- Edges: near the edges of 1 m lidar coverage and its gaps the height may come from the
10 m data;
sourceandzoomsay which. - Freshness: new and updated USGS data is picked up within a day.
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.
| Status | error | When |
|---|---|---|
| 400 | invalid_parameter | A missing or invalid parameter or point (detail says which). |
| 400 | invalid_json | A POST body that isn't the expected JSON object. |
| 403 | (not JSON) | Missing, wrong or revoked key: Cloudflare's block page. |
| 404 | no_terrain | No terrain at the point (outside coverage). |
| 404 | not_found | Any path other than /v1/elevation. |
| 405 | method_not_allowed | Methods other than GET, POST and OPTIONS. |
| 413 | too_many_points | More than 1,000 points. |
| 413 | body_too_large | A body over 128 KB. |
| 422 | geoid_not_valid_here | A geoid height where that geoid doesn't apply:
geoid18 off US land, either geoid over the ocean. Ask for ellipsoid (or egm96 on
land). |
| 502 | upstream | Terrain data couldn't be read. Retry after a moment. |
Limits and caching
- One point per GET; up to 1,000 points or 128 KB per POST. Points close together are faster: they share terrain tiles.
- GET responses may be cached by the browser for 5 minutes (
Cache-Control: private, max-age=300); POST responses are not cached. - Coordinates to 7 decimal places (about 1 cm) are plenty; more digits don't change the answer.
- The API is versioned in its path (
/v1/). Fields may be added to responses; existing ones won't change meaning within v1.
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.