Quickstart Guide

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.

Estimated time: 5–10 minutes. You only need an HTTP client — cURL, Python requests, or Node.js fetch all work. No SDK installation required.
Prefer an SDK? Skip the raw HTTP and grab the official client for your language — 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.
1
Get your API key
~1 min

Email hello@geog.ai and ask for a test key. You'll receive a string that looks like this:

Your API key format TEXT
geog_test_sk_a1b2c3d4e5f6g7h8i9j0

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:

# Add to ~/.zshrc or ~/.bashrc export GEOG_API_KEY="geog_test_sk_a1b2c3d4e5f6g7h8i9j0"
import os API_KEY = os.getenv("GEOG_API_KEY") # set GEOG_API_KEY in your environment BASE_URL = "https://api.geog.ai/v1" HEADERS = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json", }
// Node.js — set GEOG_API_KEY in your environment first const API_KEY = process.env.GEOG_API_KEY; const BASE_URL = "https://api.geog.ai/v1"; const headers = { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json", };
Never commit your API key to source control. Use environment variables, a .env file (excluded from git), or a secrets manager.
2
Resolve spatial context with /context
~2 min

Every 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

curl -G https://api.geog.ai/v1/context \ -H "Authorization: Bearer $GEOG_API_KEY" \ --data-urlencode "lat=31.9686" \ --data-urlencode "lon=-99.9018" \ --data-urlencode "radius_m=500"
import requests resp = requests.get( f"{BASE_URL}/context", headers=HEADERS, params={ "lat": 31.9686, "lon": -99.9018, "radius_m": 500, }, ) resp.raise_for_status() ctx = resp.json() print(ctx["result"]["met"]) # meteorological conditions at this location
const params = new URLSearchParams({ lat: 31.9686, lon: -99.9018, radius_m: 500 }); const res = await fetch(`${BASE_URL}/context?${params}`, { headers }); const ctx = await res.json(); console.log(ctx.result.met); // meteorological conditions at this location
Edit the request, hit Send, see a live or simulated response.

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.

Example response JSON
{
  "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
  }
}
Save the context. You'll pass the met block directly into the plume simulation in step 3, so the simulation runs with the same meteorological snapshot.
3
Trigger a plume simulation with /simulate/plume
~3 min

POST /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

FieldTypeRequiredDescription
source_lat / source_lonnumberRequiredEmission source coordinates
emission_rate_gsnumberRequiredEmission rate in grams per second
speciesstringRequiredGas species: H2S, CH4, SO2, NH3, or PM2_5
duration_minnumberRequiredSimulation window in minutes (1–180)
met_snapshotobjectOptionalPass the met block from /context to pin meteorology. Omit to use latest HRRR data.
webhook_urlstringOptionalHTTPS URL to receive the completed result. Recommended over polling.

Request

curl -X POST https://api.geog.ai/v1/simulate/plume \ -H "Authorization: Bearer $GEOG_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "source_lat": 31.9686, "source_lon": -99.9018, "emission_rate_gs": 12.5, "species": "H2S", "duration_min": 30, "met_snapshot": { "wind_speed_ms": 5.2, "wind_dir_deg": 315, "stability_class": "C", "temp_c": 24.1, "humidity_pct": 38 }, "webhook_url": "https://your-app.example.com/hooks/geog" }'
import requests # ctx was obtained from GET /context in step 2 met = ctx["result"]["met"] payload = { "source_lat": 31.9686, "source_lon": -99.9018, "emission_rate_gs": 12.5, "species": "H2S", "duration_min": 30, "met_snapshot": met, "webhook_url": "https://your-app.example.com/hooks/geog", } resp = requests.post(f"{BASE_URL}/simulate/plume", headers=HEADERS, json=payload) resp.raise_for_status() job = resp.json() print("Job ID:", job["result"]["job_id"]) # "job_8xkd92mzp1"
// ctx was obtained from GET /context in step 2 const met = ctx.result.met; const res = await fetch(`${BASE_URL}/simulate/plume`, { method: "POST", headers, body: JSON.stringify({ source_lat: 31.9686, source_lon: -99.9018, emission_rate_gs: 12.5, species: "H2S", duration_min: 30, met_snapshot: met, webhook_url: "https://your-app.example.com/hooks/geog", }), }); const job = await res.json(); console.log("Job ID:", job.result.job_id); // "job_8xkd92mzp1"
Tweak the source, species, or wind — then Send.

Immediate response

The endpoint returns immediately with a job ID and estimated completion time. The simulation result arrives at your webhook URL once complete.

Accepted response (202) JSON
{
  "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 }
}
No webhook yet? You can poll GET /jobs/{job_id} until result.status is "complete". For production workloads, webhooks are strongly preferred — they eliminate polling latency and reduce API calls.
4
Receive the webhook result
~2 min

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

Incoming POST body JSON
{
  "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:

from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/hooks/geog", methods=["POST"]) def geog_webhook(): payload = request.get_json() if payload.get("event") == "simulation.complete": result = payload["result"] job_id = payload["job_id"] confidence = payload["meta"]["confidence"] print(f"Job {job_id} complete — confidence {confidence}") print(f"Max concentration: {result['max_concentration_ppm']} ppm") print(f"ERPZ area: {result['erpz_polygon']}") # Your logic here: alert, store, visualize … return jsonify({"ok": True}), 200 # Acknowledge within 10s
import express from "express"; const app = express(); app.use(express.json()); app.post("/hooks/geog", (req, res) => { const { event, job_id, result, meta } = req.body; if (event === "simulation.complete") { console.log(`Job ${job_id} complete — confidence ${meta.confidence}`); console.log(`Max concentration: ${result.max_concentration_ppm} ppm`); console.log(`Downwind reach: ${result.downwind_km} km`); // Your logic here: alert, store, visualize … } res.json({ ok: true }); // Acknowledge within 10s }); app.listen(3000);
# During development, use a tunnel like ngrok to expose localhost: ngrok http 3000 # Then use the ngrok URL as your webhook_url in the simulate call. # Alternatively, poll the job directly (no webhook required for testing): curl https://api.geog.ai/v1/jobs/job_8xkd92mzp1 \ -H "Authorization: Bearer $GEOG_API_KEY"
Verify webhook origin in production. geog.ai signs every webhook request with an 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).
You're done. You've just resolved a coordinate, triggered a physics simulation, and handled the result. This same four-step pattern works for every simulation endpoint — /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.

Error response JSON
HTTP/1.1 401 Unauthorized
{
  "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 word Bearer followed 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 ~/.zshrc or restart your terminal.
  • Test keys must start with geog_test_sk_ and live keys with geog_live_sk_. Anything else (including a leftover placeholder) returns INVALID_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.

Error response JSON
HTTP/1.1 422 Unprocessable Entity
{
  "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 — use 31.9686. In Python, float() any values you read from form data; in shells, --data-urlencode handles encoding but the value itself must be numeric.
  • radius_m bounds: 50 – 50 000 meters. Anything outside returns INVALID_PARAMS with field: "radius_m".
  • species is an enum: only H2S, CH4, SO2, NH3, PM2_5 are 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:

curl "https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j/deliveries?status=failed" \   -H "Authorization: Bearer $GEOG_API_KEY"

A typical failed delivery for an unreachable receiver looks like this — response_code is null and error names the transport-layer failure:

Failed delivery record JSON
{
  "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) as webhook_url. Plain http:// URLs are rejected at registration.
  • The free ngrok URL changes every time you restart the tunnel — re-register the webhook (or update webhook_url on the next simulate call) after each restart, or you'll keep seeing dns_failure in the deliveries log.
  • Open the ngrok inspector at http://127.0.0.1:4040 to 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 200 within 10 seconds. Heavy work (DB writes, model inference) belongs in a background job — acknowledge first, process second. A response_timeout error 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} until result.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.

Low-confidence response JSON
HTTP/1.1 200 OK
{
  "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: false with calibration_age_days: null (as above) is the single biggest driver of a low score — most locations move from ~0.4 to 0.9+ after a single calibration pass.
  • Pin fresh meteorology by passing the met block from a recent GET /context call into met_snapshot. The provenance array tells you the timestamp of the met source actually used (e.g. met:NOAA-HRRR-2026-04-20T23Z) — if that's hours old, fetch /context again 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_AREA instead — see the full error reference.
  • In code, gate on confidence explicitly — e.g. if meta["confidence"] < 0.6: queue_for_review() — rather than treating every 200 OK as actionable.
Need a code that isn't listed here? The complete catalogue — every HTTP status, error code, and retry guidance — is in the Errors section of the API reference. When emailing hello@geog.ai, always include the request_id from the response — it lets us pull the full trace for your call in one query.