Your first API call in minutes
This guide walks you through a complete working workflow: obtain an API key, resolve a location into spatial context, trigger a plume simulation, and receive the result via webhook. By the end you'll have made four real API calls and understand the core geog.ai request/response pattern.
What you'll build
The geog.ai workflow for any physics-based decision is always the same four steps:
All examples use a test key (geog_test_sk_…), which returns realistic synthetic data and never charges your account. Swap to a live key when you're ready to go to production.
requests, or Node.js fetch all work. No SDK installation required.
pip install geog-ai, npm install @geog-ai/sdk, or the Go preview. The same four steps below collapse into about a dozen lines. See the Language SDKs page for install commands and a 3-line hello-world per language.
Email hello@geog.ai and ask for a test key. You'll receive a string that looks like this:
Test keys (geog_test_sk_…) return realistic synthetic responses for any coordinate and are free. Live keys (geog_live_sk_…) run real models and charge against your plan.
Store your key as an environment variable so it never appears in source code:
.env file (excluded from git), or a secrets manager.
/contextEvery geog.ai workflow starts with GET /context. Pass a coordinate and the API returns a fully resolved SpatialState — terrain, meteorology, nearby assets, jurisdiction, and calibration state — everything the simulation engine needs to run accurate physics.
Request
Response
The response is the standard geog.ai envelope. The result field contains the resolved SpatialState. Note the meta.confidence field — values above 0.85 mean the model has site-calibrated data for this location.
"ok": true,
"result": {
"location": { "lat": 31.9686, "lon": -99.9018, "h3_index": "8944c0ba857ffff" },
"terrain": { "elevation_m": 547.2, "slope_deg": 2.1, "roughness_class": "D" },
"met": {
"wind_speed_ms": 5.2, "wind_dir_deg": 315,
"stability_class": "C", "temp_c": 24.1, "humidity_pct": 38
},
"jurisdiction": { "primary": "TCEQ", "permit_zone": "tx-permian-basin" },
"nearby_assets": [{ "id": "wellpad_07", "type": "well_pad", "dist_m": 212 }]
},
"meta": {
"confidence": 0.91, "model_version": "context-v3.2.1",
"provenance": ["terrain:USGS-3DEP-1m", "met:NOAA-HRRR-2026-04-21T03Z"],
"request_id": "req_01hx29sknd4bqyt8r", "latency_ms": 83
}
}
met block directly into the plume simulation in step 3, so the simulation runs with the same meteorological snapshot.
/simulate/plumePOST /simulate/plume runs a Gaussian dispersion model for atmospheric pollutant propagation. The endpoint is asynchronous — it immediately returns a job_id, and the completed result is sent to your webhook URL once the simulation finishes (typically 3–15 seconds).
Key request fields
| Field | Type | Required | Description |
|---|---|---|---|
| source_lat / source_lon | number | Required | Emission source coordinates |
| emission_rate_gs | number | Required | Emission rate in grams per second |
| species | string | Required | Gas species: H2S, CH4, SO2, NH3, or PM2_5 |
| duration_min | number | Required | Simulation window in minutes (1–180) |
| met_snapshot | object | Optional | Pass the met block from /context to pin meteorology. Omit to use latest HRRR data. |
| webhook_url | string | Optional | HTTPS URL to receive the completed result. Recommended over polling. |
Request
Immediate response
The endpoint returns immediately with a job ID and estimated completion time. The simulation result arrives at your webhook URL once complete.
"ok": true,
"result": {
"job_id": "job_8xkd92mzp1",
"status": "queued",
"estimated_ms": 8000,
"poll_url": "https://api.geog.ai/v1/jobs/job_8xkd92mzp1"
},
"meta": { "request_id": "req_01hx2asknd9cqyt8r", "latency_ms": 41 }
}
GET /jobs/{job_id} until result.status is "complete". For production workloads, webhooks are strongly preferred — they eliminate polling latency and reduce API calls.
When the simulation completes, geog.ai sends a POST request to your webhook_url with the full result payload. Your server must respond with HTTP 200 within 10 seconds; unacknowledged webhooks are retried up to three times with exponential backoff.
Webhook payload
"event": "simulation.complete",
"job_id": "job_8xkd92mzp1",
"ok": true,
"result": {
"plume_geojson": { "type": "FeatureCollection", "features": [ /* … */ ] },
"max_concentration_ppm": 47.3,
"downwind_km": 2.1,
"erpz_polygon": { /* Emergency Response Planning Zone */
"type": "Polygon", "coordinates": [ /* … */ ]
},
"peak_time_min": 7,
"affected_assets": [{ "id": "wellpad_07", "exposure_ppm": 12.8 }]
},
"meta": {
"confidence": 0.91,
"model_version": "plume-v4.1.2",
"site_calibrated": true,
"calibration_age_days": 12,
"provenance": ["terrain:USGS-3DEP-1m", "met:NOAA-HRRR-2026-04-21T03Z"]
}
}
Minimal webhook handler
Here's the simplest server that receives and acknowledges the webhook, then processes the result:
X-Geog-Signature header (HMAC-SHA256 of the raw body using your signing secret). Always validate the signature before processing the payload — accept requests only when the header matches. Your signing secret is returned once when you register a webhook via POST /webhooks — store it in a secret manager immediately, as it cannot be re-fetched later. To exercise the full register → deliver → verify loop in 30 seconds without waiting for a real simulation, follow the webhook verification recipe in the API reference (Python and Node).
/simulate/flood, /simulate/rf_coverage, /simulate/fire, and more.
Troubleshooting
Hit a snag on your first run? Here are the four issues developers report most often, the exact error envelope you'll see, and how to fix each one. Every error response uses the same shape — an ok: false envelope with a machine-readable error.code, a human-readable error.message, and a request_id you can quote when emailing support.
401 Unauthorized — INVALID_API_KEY
The API key is missing, malformed, expired, or revoked. The most common cause is forgetting the Bearer prefix or shell-quoting issues that strip the variable.
{
"ok": false,
"error": {
"code": "INVALID_API_KEY",
"message": "API key missing, expired, or revoked. Check the Authorization header is 'Bearer geog_(test|live)_sk_…'.",
"request_id": "req_01hx2bsknd4bqyt8r"
}
}
Fix checklist:
- Confirm the header is
Authorization: Bearer geog_test_sk_…— the literal wordBearerfollowed by a single space, then the key. - Echo the variable before sending:
echo "$GEOG_API_KEY". An empty value usually means the env var was set in a different shell session — re-source~/.zshrcor restart your terminal. - Test keys must start with
geog_test_sk_and live keys withgeog_live_sk_. Anything else (including a leftover placeholder) returnsINVALID_API_KEY. - Rotated keys are revoked instantly. If you regenerated the key in the dashboard, copy the new one — the old value will keep returning 401.
422 Validation error — INVALID_PARAMS
The request was authenticated but the body or query string failed validation. The most common trigger on first run is malformed coordinates: latitude and longitude swapped, sent as strings, or out of range. error.message always names the offending field.
{
"ok": false,
"error": {
"code": "INVALID_PARAMS",
"message": "Field 'source_lat' must be a number between -90 and 90 (got 315.0). Did you swap lat and lon?",
"request_id": "req_01hx2csknd4bqyt8r"
}
}
Fix checklist:
- Order matters: latitude first (range
-90 … 90), longitude second (range-180 … 180). Most US coordinates have a positive lat and a negative lon — if your lat is bigger than 90, you've swapped them. - Send numbers, not strings. JSON
"31.9686"(with quotes) fails validation — use31.9686. In Python,float()any values you read from form data; in shells,--data-urlencodehandles encoding but the value itself must be numeric. radius_mbounds: 50 – 50 000 meters. Anything outside returnsINVALID_PARAMSwithfield: "radius_m".speciesis an enum: onlyH2S,CH4,SO2,NH3,PM2_5are accepted. Casing matters.- Coordinates outside US/CA/AU/EU return a separate code,
OUTSIDE_COVERAGE_AREA— see the full error reference.
Webhook never fires
The /simulate/plume call returned 202 Accepted with a job_id, but nothing arrives at your handler. The job is almost always succeeding — the delivery is failing because your URL isn't reachable from the public internet, or your server isn't acknowledging within the 10-second window.
Inspect delivery attempts directly to confirm what geog.ai saw — see GET /webhooks/{id}/deliveries for the full schema:
A typical failed delivery for an unreachable receiver looks like this — response_code is null and error names the transport-layer failure:
"ok": true,
"result": [{
"id": "whd_01hx2g4n8p7rqzm3k",
"webhook_id": "whk_01hx2f8q7n3tdvm5j",
"event": "job.complete",
"job_id": "job_8xkd92mzp1",
"status": "failed",
"attempt_count": 3,
"response_code": null,
"response_ms": null,
"response_body_excerpt": null,
"error": "connection_timeout",
"signature_timestamp": 1745211262,
"created_at": "2026-04-21T03:14:22Z",
"last_attempt_at": "2026-04-21T03:24:51Z",
"delivered_at": null,
"next_attempt_at": "2026-04-21T03:34:51Z"
}],
"meta": { "request_id": "req_01hx2esknd4bqyt8r", "next_cursor": null }
}
Common values you'll see in error: connection_timeout (host unreachable / firewall), dns_failure (URL no longer resolves — typical after an ngrok restart), tls_handshake_failed (bad or self-signed cert). When the receiver did answer but with a non-2xx, error is null and response_code holds the status (e.g. 502, 500).
Local development with ngrok:
- geog.ai cannot POST to
http://localhost:3000. Expose your local server with a tunnel:ngrok http 3000. - Use the HTTPS forwarding URL from ngrok (e.g.
https://abcd-1234.ngrok-free.app/hooks/geog) aswebhook_url. Plainhttp://URLs are rejected at registration. - The free ngrok URL changes every time you restart the tunnel — re-register the webhook (or update
webhook_urlon the next simulate call) after each restart, or you'll keep seeingdns_failurein the deliveries log. - Open the ngrok inspector at
http://127.0.0.1:4040to watch the raw incoming POST in real time. If nothing shows up there, the problem is upstream (URL not registered, firewall blocking ngrok). If it shows up but your handler returns non-200, the problem is in your code.
Other common causes:
- Acknowledgement timeout: your handler must return HTTP
200within 10 seconds. Heavy work (DB writes, model inference) belongs in a background job — acknowledge first, process second. Aresponse_timeouterror in the deliveries log means you took too long. - Self-signed TLS certs trigger
tls_handshake_failed. Use a real cert (Let's Encrypt) or stick with ngrok for dev. - Signature mismatch in your handler: if you're verifying
X-Geog-Signature, compute the HMAC over the raw request body bytes, not the parsed JSON. Re-serializing the JSON changes whitespace and the signature will never match. - For testing without a public URL, skip the webhook entirely and poll
GET /jobs/{job_id}untilresult.status === "complete".
Low confidence warning — meta.confidence < 0.6
This isn't an error — the request succeeded with HTTP 200 — but the model is signalling that it doesn't have enough site-calibrated data to stand behind the result. Treat the simulation as directional, not actionable, and don't trigger automated alerts on it.
{
"ok": true,
"result": { /* … plume payload … */ },
"meta": {
"confidence": 0.42,
"model_version": "plume-v4.1.2",
"site_calibrated": false,
"calibration_age_days": null,
"provenance": ["terrain:USGS-3DEP-1m", "met:NOAA-HRRR-2026-04-20T23Z"],
"request_id": "req_01hx2fsknd4bqyt8r"
}
}
What the score means:
≥ 0.85— site-calibrated, safe for automated decisions and alerting.0.60 – 0.85— uncalibrated but well-supported by terrain and met data; surface to humans, gate auto-actions.< 0.60— directional only; show a "low confidence" badge in your UI and require manual review.
How to raise confidence:
- Register the site as a device and run the one-time site-calibration job.
site_calibrated: falsewithcalibration_age_days: null(as above) is the single biggest driver of a low score — most locations move from ~0.4to0.9+after a single calibration pass. - Pin fresh meteorology by passing the
metblock from a recentGET /contextcall intomet_snapshot. Theprovenancearray tells you the timestamp of the met source actually used (e.g.met:NOAA-HRRR-2026-04-20T23Z) — if that's hours old, fetch/contextagain first. - Check coverage: if the location is on the edge of a supported region, parts of the SpatialState fall back to coarser data and confidence drops accordingly. Coordinates wholly outside US/CA/AU/EU return
OUTSIDE_COVERAGE_AREAinstead — see the full error reference. - In code, gate on confidence explicitly — e.g.
if meta["confidence"] < 0.6: queue_for_review()— rather than treating every200 OKas actionable.
request_id from the response — it lets us pull the full trace for your call in one query.
Next steps
Now that you've made your first calls, here's where to go next: