GeoidLife API

The GeoidLife API

Coordinate transformation with real datum grids, geoid heights, CRS lookup and geospatial file conversion as one JSON API, backed by PROJ and GDAL. The same engines the browser tools use, without the browser.

check_circlev1 live vpn_keyFree key, no card credit_cardPaid plans via Stripe publicNADCON5 · NTv2 · HPGN grids terrainGEOID18 · EGM2008 · EGM96 descriptionOpenAPI 3.0
1Get a key — enter your email below; the key arrives within a minute. 100 requests a day, free.
2Call an endpoint — send JSON with an x-api-key header. Points are [x, y] or [x, y, z], longitude first.
3Go bigger when you need to — pick a plan, pay by card through Stripe, and the same key gets the larger monthly quota within seconds. Cancel any time.

vpn_keyGet a free API key

One key per email address. We send the key by email so the address has to be real; posting the same address again re-sends it.

By requesting a key you agree to fair use: no scraping the service, one key per person or project, and we may email you about changes to the API. Nothing else, no spam.

terminalQuick start

Base URL https://api.geoidlife.com/v1

# WGS 84 lon/lat → UTM zone 11N (NAD83), two points, one with a height
curl -s -X POST $BASE/transform \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{"from":"EPSG:4326","to":"EPSG:26911",
       "points":[[-118.2437,34.0522],[-118.4,33.9,100]]}'
import requests

BASE = "https://api.geoidlife.com/v1"
H = {"x-api-key": "YOUR_KEY"}

r = requests.post(f"{BASE}/transform", headers=H, json={
    "from": "EPSG:4326", "to": "EPSG:26911",
    "points": [[-118.2437, 34.0522], [-118.4, 33.9, 100]],
})
print(r.json()["points"])   # [[385213.89, 3768641.79], [370556.99, 3751950.49, 100.0]]
const BASE = "https://api.geoidlife.com/v1";

const res = await fetch(`${BASE}/geoid`, {
  method: "POST",
  headers: { "x-api-key": "YOUR_KEY", "Content-Type": "application/json" },
  body: JSON.stringify({ points: [[-118.2437, 34.0522, 100]] }),   // lon, lat, ellipsoidal h
});
const { points } = await res.json();
console.log(points[0].H, points[0].N);   // 135.1082 (NAVD88), -35.1082 (GEOID18 N)

Response

{
  "ok": true,
  "from": "OGC:CRS84", "to": "EPSG:26911",
  "operation": "Inverse of NAD83 to WGS 84 (54) + UTM zone 11N",
  "accuracy_m": 2.0,
  "grids": [{"name": "us_noaa_cshpgn.tif", "available": true, "url": "https://cdn.proj.org/us_noaa_cshpgn.tif"}],
  "candidates": 3, "count": 2, "failed": 0,
  "points": [[385213.8945, 3768641.79], [370556.9895, 3751950.49, 100.0]]
}

Every response tells you which PROJ operation ran, its published accuracy and the grid files involved, so the number is auditable. Compare that with a browser library that silently applies a null transform.

play_circleTry it here

Paste your key, edit the request, run. The call goes straight from your browser to the API (CORS is on); the key is kept only in this tab.

Response

(nothing yet)

POST/transform

Transform coordinates between any two coordinate reference systems. PROJ picks the most accurate operation it knows for the area your points are in, fetching the grid on demand: NADCON5 and HPGN in the US, NTv2 in Canada, Australia and Europe, geocentric and time-dependent Helmert transformations between modern frames.

FieldTypeDescription
from *stringSource CRS: EPSG:4326, 4326, ESRI:102645, WKT or a PROJ string.
to *stringTarget CRS, same forms.
points *arrayUp to 1,000 points as [x, y] or [x, y, z], or {"x","y","z"} objects. x is easting or longitude, y is northing or latitude, z is metres. Null is returned for a point that cannot be transformed.
epochnumberDecimal year for time-dependent frames, e.g. ITRF2014 → NAD83(2011) at 2020.5.
only_bestbooleanDefault true: fail with 422 rather than silently fall back to a less accurate operation when the best grid cannot be fetched.

Request

{
  "from": "EPSG:4267",   // NAD27
  "to": "EPSG:4269",     // NAD83
  "points": [[-118.2437, 34.0522]]
}

Response

{
  "ok": true, "from": "OGC:CRS27", "to": "OGC:CRS83",
  "operation": "NAD27 to NAD83 (7)",
  "accuracy_m": 0.15,
  "grids": [{"name": "us_noaa_nadcon5_nad27_nad83_1986_conus.tif", "available": true, …}],
  "candidates": 2, "count": 1, "failed": 0,
  "points": [[-118.24460641, 34.052212394]]
}

That is a 100 m shift the old datum needs in Los Angeles, applied with the NADCON5 grid. The first call for a new datum pair in a cold container may take one to three seconds while the grid tiles are fetched; after that it is tens of milliseconds.

POST/geoid

Turn ellipsoidal heights (what GNSS gives you) into orthometric heights (what plans, benchmarks and flood maps use), or go the other way, and get the geoid undulation N at each point. GEOID18 and GEOID12B over the contiguous US, EGM2008 and EGM96 worldwide.

FieldTypeDescription
points *arrayUp to 1,000 [lon, lat, height] triples, height in metres.
modelstringauto (default: GEOID18 inside CONUS, EGM2008 elsewhere), GEOID18, GEOID12B, EGM2008, EGM96.
heightstringellipsoidal (default) — you send h and get H back, or orthometric — you send H and get h back.
input_crsstringHorizontal frame of your lon/lat. Defaults to NAD83(2011) for the GEOID models and WGS 84 for the EGM models, which is what the models are defined on.

Request

{ "points": [[-118.2437, 34.0522, 100.0]] }

Response

{
  "ok": true, "model": "GEOID18", "grid": "us_noaa_g2018u0.tif",
  "operation": "NAD83(2011) to NAVD88 height (3)",
  "units": "metres", "count": 1, "failed": 0,
  "points": [{"lon": -118.2437, "lat": 34.0522,
              "h": 100.0, "H": 135.1082, "N": -35.1082}]
}

h = ellipsoidal height, H = orthometric height (NAVD88 for the GEOID models, EGM height for the EGM models), N = h − H.

GET/crs/{code}

Describe a coordinate reference system: name, datum, ellipsoid, projection, axis order and units, area of use, and on request the WKT2, ESRI WKT1 and PROJJSON definitions. Handy for populating a CRS picker or validating user input before a transform.

ParameterInDescription
code *path26911, EPSG:26911, ESRI:102645, IGNF:LAMB93…
formatquerywkt adds WKT2, wkt1 adds ESRI WKT1, projjson adds PROJJSON, all adds all three.
GET /crs/2229?format=wkt

{
  "ok": true, "code": "EPSG:2229", "name": "NAD83 / California zone 5 (ftUS)",
  "type": "Projected CRS", "datum": "North American Datum 1983", "ellipsoid": "GRS 1980",
  "projection": "Lambert Conic Conformal (2SP)", "units": "US survey foot",
  "axes": [{"name": "Easting", "direction": "east", …}, {"name": "Northing", …}],
  "area_of_use": {"name": "United States (USA) - California - counties Kern; Los Angeles; …", "bounds": [-121.42, 32.76, -114.12, 35.81]},
  "proj4": "+proj=lcc +lat_0=33.5 +lon_0=-118 +lat_1=35.4666666666667 …",
  "wkt2": "PROJCRS[\"NAD83 / California zone 5 (ftUS)\", …"
}

POST/convert

Convert a geospatial file with GDAL. KMZ, KML, zipped Shapefile, GeoJSON, GPX, CSV and DBF in; Shapefile, KML, GeoJSON or CSV out. You get a download link to a zip, valid for one hour, plus a GeoJSON preview of the input.

FieldTypeDescription
file *stringThe file, base64-encoded. Up to 6 MB decoded on the Free plan.
filename *stringOriginal name with extension; the extension drives auto-detection.
conversionstringauto (default) or one of kmz-to-shp, kml-to-shp, kml-to-geojson, shp-to-kmz, shp-to-geojson, geojson-to-shp, geojson-to-kml, gpx-to-shp, gpx-to-geojson, csv-to-shp, dbf-to-csv.
# bash: base64 the file inline
curl -s -X POST $BASE/convert -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d "{\"file\":\"$(base64 < parcels.kmz)\",\"filename\":\"parcels.kmz\",\"conversion\":\"kmz-to-shp\"}"

{
  "success": true, "conversion_type": "kmz-to-shp", "output_file": "parcels_shapefile.zip",
  "download_url": "https://…amazonaws.com/output/d5ac96f6_parcels_shapefile.zip?X-Amz-…",
  "files_created": ["parcels.shp", "parcels.shx", "parcels.dbf", "parcels.prj"],
  "preview_type": "map", "preview_data": { "type": "FeatureCollection", … }
}

This endpoint shares its engine with converter.geoidlife.com, so its response shape is the converter's (success rather than ok). GeoPackage, FileGDB and raster formats are on the list for v1.1.

POST/keys

What the form at the top of this page calls. No key needed. Send {"email": "...", "name": "...", "use_case": "..."} and the key is emailed; the response is {"ok": true, "status": "created"} or "resent". Keys are never returned in the response body.

reportErrors, limits and fair use

Every error is JSON with the same shape: {"ok": false, "error": {"code": "...", "message": "..."}}.

StatusCodeMeaning
400invalid_json · missing_crs · bad_point · unknown_model …Something in the request body; the message says what.
403invalid_api_keyNo x-api-key header, or a key that is disabled or not on a plan.
404unknown_crs · not_foundCRS code PROJ does not know, or no such route.
413too_many_pointsMore than 1,000 points in one call. Batch them.
422no_transformation · transform_failedPROJ has no operation between those CRS, or the best one needs a grid it could not fetch (set only_best:false to accept a fallback).
429quota_exceeded · throttledQuota used (daily on Free, resets 00:00 UTC; monthly on paid plans, resets on the 1st) or more requests per second than the plan allows. Back off and retry, or move up a plan.
  • Quotas count requests, not points: one call with 1,000 points is one request.
  • Payloads up to 10 MB; /convert files up to 6 MB decoded on the Free plan.
  • Timeouts at 29 seconds (the HTTP gateway's limit). Large conversions will move to an asynchronous job endpoint in a later version.
  • Fair use: one key per person or project, no reselling, no bulk scraping of the CRS registry. Keys that hammer the service get disabled with an email first.
  • Status and versions: GET /health returns the PROJ and pyproj versions; breaking changes will ship as /v2.

sellPlans

Monthly plans, paid by card through Stripe. The key for your email moves to the new quota within seconds of checkout (if the address has no key yet, we create one and email it). Prices in USD before tax; cancel any time. Quotas are hard caps for now; metered overage at the schedule rates ($0.005 / $0.003 / $0.002 per extra request) comes next.

Free$0
  • check100 requests / day
  • check2 requests / s
  • check1,000 points per call
  • checkFiles ≤ 6 MB
  • checkAll endpoints
vpn_keyGet a key
Starter$9 / mo
  • check2,000 requests / month
  • check5 requests / s
  • check1,000 points per call
  • checkAll endpoints
  • checkEmail support
Pro$49 / mo
  • check20,000 requests / month
  • check10 requests / s
  • check1,000 points per call
  • checkAll endpoints
  • checkPriority email support
Team$199 / mo
  • check120,000 requests / month
  • check25 requests / s
  • check1,000 points per call
  • checkAll endpoints
  • checkPriority support, named contact

You go to Stripe Checkout to pay; your card never touches our servers. Already on a plan? Use the same email and we will email you a link to switch instead of charging you twice.

Manage subscription — change plan, card, invoices or cancel:

Need more than Team, an on-premise container, or a specific grid or datum (NATRF2022 is on the roadmap as soon as NGS publishes it)? Email us.

help_outlineFAQ

Why would I use this instead of proj4js or a library?

Libraries in the browser do not ship datum grids, so NAD27 → NAD83 or an ellipsoid-to-NAVD88 height silently comes back metres off, with no indication. This API runs full PROJ with the official grids and tells you in every response which operation it used and its accuracy. If you already run PROJ or GDAL server-side with grids, you do not need us.

Which coordinate order do I send?

Always x then y: longitude, latitude for geographic CRS, easting, northing for projected ones, regardless of the CRS's official axis order. Heights are metres.

How accurate are the geoid heights?

As accurate as the model: GEOID18 is quoted by NGS at a few centimetres over most of CONUS, EGM2008 at decimetres globally. We interpolate the official grids with PROJ; the model name and grid file are in every response so you can cite them.

Can I call it from a web page?

Yes, CORS is enabled. Keep in mind that a key in page source is public; for anything beyond a demo, proxy through your own server.

How does billing work?

Paid plans are monthly subscriptions through Stripe; we never see your card. After checkout, a webhook moves the key for your email to the plan's quota within seconds (if the address has no key yet we create one and email it). Manage subscription emails you a link to the Stripe customer portal to change plan (prorated), update the card, download invoices or cancel; a cancelled plan runs to the end of the paid month, then the key drops back to Free. Prices are in USD before tax; where we are registered to collect sales tax or VAT, Stripe adds it at checkout.

Where does my data go?

Transform, geoid and CRS requests are processed in memory and only the route, timing and your key id are logged. Converted files sit in a private bucket behind a one-hour link and are deleted after seven days. Everything runs in AWS us-east-1.

Is there an SLA?

Not yet; paid plans get email support and a written SLA is being drafted. The service runs on AWS Lambda and API Gateway with no single server to fall over, and the browser tools at geoidlife.com are always there as a fallback.