Developer Documentation

geog.ai API Reference

Complete reference for the geog.ai Spatial Intelligence Engine. Covers context resolution, propagation simulation, RF optimization, jurisdiction mapping, graph analytics, and calibration state. All endpoints return a standardized response envelope with confidence scores and data provenance.

Open Interactive API Explorer Download OpenAPI 3.0 Spec Postman Collection Insomnia Export HAR Snapshot cURL Cheatsheet Stable URL: https://geog.ai/docs/openapi.yaml — import into Postman, Insomnia, Hoppscotch, Swagger UI, or VS Code REST Client (CORS-enabled, auto-syncs). Curated bundles — Postman, Insomnia, HAR & cURL — are committed in /docs/postman/.
New to geog.ai? Follow the Quickstart guide to make your first API call in under 10 minutes β€” API key, context call, plume simulation, and webhook, all in one place.
Read the Quickstart β†’

Overview

geog.ai turns coordinates into spatial decisions. Every API call resolves location into enriched context β€” terrain, jurisdiction, neighbors, atmospheric conditions β€” and optionally runs physics-based propagation models to answer "what happens next?"

The API has three response modes:

  • Synchronous β€” context queries and RF/graph lookups return immediately (typically <200ms)
  • Async jobs β€” propagation simulations (/simulate/*) return a job ID; results delivered via webhook or polled via /jobs/{id}
  • Streaming β€” real-time plume tracking via SSE (/simulate/plume/stream, available on Site Calibration plans)

Authentication

All requests must include your API key in the Authorization header using Bearer token format. API keys are scoped to a single organization and carry rate limits based on your plan tier.

Request header HTTP
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json
X-Site-ID: wellpad_07  # Optional β€” required for calibration-aware responses
Key types: Live keys (geog_live_sk_…) charge against your account. Test keys (geog_test_sk_…) return realistic synthetic responses and never charge. Request either type at hello@geog.ai.

API Key Header Reference

HeaderRequiredDescription
AuthorizationRequiredBearer token β€” your API key
X-Site-IDOptionalSite identifier for calibration-aware routing. When provided, responses include site-specific model parameters rather than regional defaults. Required for calibration state endpoints.
X-Idempotency-KeyOptionalUUID for POST requests. Retry-safe β€” duplicate requests with the same key return the cached result without charging.

Base URL

https://api.geog.ai/v1

All endpoints below are relative to this base. TLS required on all requests. IPv6 and HTTP/2 are supported.

Response Envelope

Every response β€” synchronous or async β€” is wrapped in a standard envelope. This guarantees that every consuming system receives confidence metadata, model provenance, and calibration age alongside the result.

Standard response envelope JSON
{
  "ok": true,
  "result": { /* endpoint-specific payload */ },
  "meta": {
    "confidence": 0.93,  // 0.0–1.0 β€” model certainty for this result
    "model_version": "plume-v4.1.2",
    "calibration_age_days": 47,  // days since last calibration update
    "site_calibrated": true,  // false = regional defaults used
    "provenance": [
      "terrain:USGS-3DEP-1m",
      "met:NOAA-HRRR-2026-04-21T03Z",
      "jurisdiction:TCEQ-2025-Q4"
    ],
    "request_id": "req_01hx29sknd4bqyt8r",
    "latency_ms": 87
  }
}
Confidence score: Values below 0.6 indicate that the result relies primarily on regional defaults and should not be used for regulatory reporting. Scores above 0.85 indicate site-calibrated models with recent meteorological inputs.

Rate Limits

Rate limits apply per API key and are reset on a rolling window. Simulation endpoints (POST /simulate/*) have separate concurrency limits β€” parallel jobs count against a concurrent-simulation cap, not a per-second rate.

Endpoint GroupRate LimitConcurrencyPlan
GET /context, /nearby, /within1,000 req/minβ€”All
GET /jurisdiction, /coverage, /gaps500 req/minβ€”All
POST /simulate/*60 req/min10 concurrentAll
POST /rf/*, /optimize/*30 req/min5 concurrentAll
GET /trajectory, /graph/*200 req/minβ€”All
GET /model/calibration60 req/minβ€”Site Calibration
POST /context/batch100 req/minmax 500 IDs/batchAll

Rate limit headers are included on every response:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1745208120  # Unix timestamp of next window reset

Errors

Errors return an ok: false envelope with a machine-readable code and a human-readable message.

Error response JSON
{
  "ok": false,
  "error": {
    "code": "OUTSIDE_COVERAGE_AREA",
    "message": "The requested coordinates fall outside the current coverage area. Terrain and jurisdiction data available for: US, CA, AU, EU.",
    "request_id": "req_01hx29sknd4bqyt8r"
  }
}
HTTP StatusCodeDescription
400INVALID_PARAMSMissing or malformed request parameters
401INVALID_API_KEYAPI key missing, expired, or revoked
402INSUFFICIENT_CREDITSAccount balance depleted
403PLAN_LIMITEndpoint requires Site Calibration plan
404DEVICE_NOT_FOUNDdevice_id not registered in the system
422OUTSIDE_COVERAGE_AREACoordinates outside supported terrain coverage
429RATE_LIMIT_EXCEEDEDToo many requests; check X-RateLimit-Reset
500INTERNAL_ERRORTransient server error; safe to retry with backoff
503SIMULATION_QUEUE_FULLConcurrency cap reached; retry after a few seconds

Async Jobs

Simulation endpoints return a job object rather than a result. Use a webhook to receive results or poll GET /jobs/{job_id}. Jobs expire 48 hours after completion.

Async job response JSON
{
  "ok": true,
  "job": {
    "id": "job_01hx2a3f9k8qmzn7p",
    "type": "simulate/plume",
    "status": "queued",  // queued | running | complete | failed
    "eta_seconds": 8,
    "poll_url": "https://api.geog.ai/v1/jobs/job_01hx2a3f9k8qmzn7p",
    "webhook_delivered": false
  },
  "meta": { "request_id": "req_01hx29sknd4bqyt8r" }
}
Webhooks: Register a webhook URL on your account dashboard or via POST /webhooks. Webhook payloads are signed with HMAC-SHA256 using your webhook secret. The full result is delivered as the result field in the standard response envelope.

Context API

Context endpoints resolve spatial state for registered devices and arbitrary coordinates. These are the most frequently called endpoints β€” designed for high-volume, low-latency per-event enrichment.

GET /context Resolve SpatialState for a registered device $0.001 / call Try it β†—

Returns the full SpatialState for a registered device β€” terrain, jurisdiction, wind vector, nearby entities, atmospheric stability class, and asset proximity. Enrichment runs at ingest time; this call returns the pre-computed state with a confidence-weighted freshness score.

Query Parameters
ParameterTypeRequiredDescription
device_idstringRequiredRegistered device identifier
includestring[]OptionalComma-separated list of context fields to include. Default: all. Options: terrain, jurisdiction, wind, nearby, assets, calibration
max_age_sintegerOptionalMaximum acceptable state age in seconds. If current state is older, triggers a fresh enrichment. Default: 300
Request HTTP
GET https://api.geog.ai/v1/context?device_id=sensor_h2s_023
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
X-Site-ID: wellpad_07
Response JSON
{
  "ok": true,
  "result": {
    "entity_id": "sensor_h2s_023",
    "location": {
      "lat": 31.9686, "lon": -99.9018, "alt_m": 547.2,
      "h3_index": "8944c0ba857ffff",
      "indoor": null
    },
    "time": "2026-04-21T03:14:22Z",
    "context": {
      "wind_vector": { "speed_ms": 8.2, "bearing_deg": 312 },
      "terrain": { "elevation_m": 547.2, "slope_deg": 3.1, "aspect_deg": 180 },
      "atmospheric_stability": "D",
      "nearby_entities": [
        { "id": "sensor_h2s_021", "distance_m": 180, "bearing_deg": 298, "reading_ppm": 0 },
        { "id": "sensor_h2s_024", "distance_m": 220, "bearing_deg": 45, "reading_ppm": 12 }
      ],
      "jurisdiction": { "authority": "TCEQ", "permit_id": "PSDTX1234", "limit_ppm": 10 },
      "asset_proximity": [
        { "asset_id": "wellpad_07", "distance_m": 195, "type": "emission_source" }
      ]
    }
  },
  "meta": { "confidence": 0.97, "model_version": "context-v3.0", "calibration_age_days": 47, "site_calibrated": true, "latency_ms": 43 }
}
GET /context/batch Batch context resolution $0.001 / device Try it β†—

Resolves SpatialState for up to 500 devices in a single request. Responses are returned as an array in the same order as the input IDs. Partial failures are represented with null result entries and an error sub-object.

ParameterTypeRequiredDescription
device_idsstring[]RequiredJSON-encoded array of device IDs. Max 500 per request.
includestring[]OptionalFields to include (see /context)
Request HTTP
GET https://api.geog.ai/v1/context/batch?device_ids=["sensor_h2s_023","sensor_h2s_024","drone_01"]
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response (abbreviated) JSON
{
  "ok": true,
  "result": {
    "count": 3,
    "succeeded": 3, "failed": 0,
    "items": [ /* array of SpatialState objects */ ]
  },
  "meta": { "confidence": 0.95, "latency_ms": 112 }
}
GET /nearby Proximity search $0.001 / call Try it β†—

Returns all registered entities within a given radius of a coordinate, optionally filtered by type. Results are ordered by distance and include the SpatialState for each entity.

ParameterTypeRequiredDescription
latfloatRequiredWGS84 latitude of center point
lonfloatRequiredWGS84 longitude of center point
radius_mintegerRequiredSearch radius in meters. Max: 50,000m
typestringOptionalFilter by entity type: gas_sensor, water_sensor, rf_node, asset, personnel, drone, or any registered custom type
limitintegerOptionalMaximum results to return. Default: 50, max: 500
Request HTTP
GET https://api.geog.ai/v1/nearby?lat=31.96&lon=-99.90&radius_m=500&type=gas_sensor
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response (abbreviated) JSON
{
  "ok": true,
  "result": {
    "center": { "lat": 31.96, "lon": -99.90 },
    "radius_m": 500,
    "count": 4,
    "entities": [
      { "id": "sensor_h2s_023", "type": "gas_sensor", "distance_m": 12, "bearing_deg": 0 },
      { "id": "sensor_h2s_021", "type": "gas_sensor", "distance_m": 192, "bearing_deg": 298 }
    ]
  },
  "meta": { "confidence": 1.0, "latency_ms": 18 }
}
GET /within Polygon containment query $0.001 / call Try it β†—

Returns all registered entities whose registered location falls within a given GeoJSON polygon. Used for zone-based queries β€” "which sensors are inside this permit boundary?", "which personnel are inside this exclusion zone?"

ParameterTypeRequiredDescription
geometryGeoJSONRequiredURL-encoded GeoJSON Polygon or MultiPolygon
typestringOptionalEntity type filter (same options as /nearby)
include_contextbooleanOptionalIf true, includes full SpatialState for each result. Billed at $0.001/entity. Default: false
Request HTTP
GET https://api.geog.ai/v1/within?geometry=%7B%22type%22%3A%22Polygon%22%2C...%7D&type=gas_sensor
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response JSON
{
  "ok": true,
  "result": {
    "count": 3,
    "entities": [
      { "id": "sensor_h2s_021", "type": "gas_sensor", "location": { "lat": 31.967, "lon": -99.904 } },
      { "id": "sensor_h2s_023", "type": "gas_sensor", "location": { "lat": 31.969, "lon": -99.902 } },
      { "id": "sensor_h2s_024", "type": "gas_sensor", "location": { "lat": 31.970, "lon": -99.900 } }
    ]
  },
  "meta": { "confidence": 1.0, "latency_ms": 24 }
}
GET /jurisdiction Jurisdiction resolution for any coordinate $0.0005 / call Try it β†—

Returns all applicable regulatory jurisdictions and permit obligations for a given coordinate β€” air quality authority, watershed governance, noise ordinance, and any cross-boundary relationships. Jurisdiction data is cached aggressively; jurisdictions change rarely. If you pass a device_id, jurisdiction is returned as part of the SpatialState (no separate charge).

ParameterTypeRequiredDescription
latfloatRequired*WGS84 latitude (*or device_id)
lonfloatRequired*WGS84 longitude (*or device_id)
device_idstringOptionalAlternative to lat/lon β€” uses the device's registered coordinates
domainsstring[]OptionalLimit response to specific domains: air, water, noise, land, fire. Default: all
Response JSON
{
  "ok": true,
  "result": {
    "location": { "lat": 31.9686, "lon": -99.9018 },
    "jurisdiction": {
      "air_quality": {
        "authority": "TCEQ",
        "region": "Permian Basin Ozone Area",
        "permit_class": "Major Source",
        "applicable_standards": ["NAAQS", "Reg 30 TAC 101"],
        "reporting_thresholds": { "H2S_ppm": 5, "NOx_tpy": 100 },
        "notification_requirement": "within 24 hours of exceedance"
      },
      "water": { "authority": "TCEQ Water Quality", "watershed": "Colorado River Basin", "permit_id": "TPDES-TX0123456" },
      "noise": { "authority": "City of Midland", "zone": "Industrial", "limit_dba_day": 70, "limit_dba_night": 60 }
    },
    "cross_boundary_risk": [
      { "jurisdiction": "Andrews County", "direction_deg": 275, "distance_m": 8400, "prevailing_wind_aligned": true }
    ]
  },
  "meta": { "confidence": 0.99, "latency_ms": 12, "provenance": ["jurisdiction:TCEQ-2025-Q4"] }
}

Simulate API

Propagation simulation endpoints run physics-based models against the current SpatialState. All simulation endpoints are async β€” they return a job immediately and deliver results via webhook or polling. Simulations that have a registered site with active calibration use site-specific model parameters.

Async only. No simulation endpoint returns a synchronous result. All POST /simulate/* calls return a job object. Tier 1 (Gaussian plume, single-node RF) typically completes in 3–8 seconds. Tier 2 (AERMOD, full mesh) typically completes in 15–60 seconds.
POST /simulate/plume Atmospheric dispersion simulation $0.05–$0.25 / run Try it β†—

Runs an atmospheric dispersion model for a point or area source. Tier 1 uses Gaussian plume (fast screening, <8s). Tier 2 uses AERMOD-style regulatory-grade modeling (15–60s). Tier 3 uses Lagrangian particle tracking for non-steady-state scenarios (minutes). Returns concentration isopleths (polygons), predicted readings at registered sensor locations, and affected zone classifications.

FieldTypeRequiredDescription
sourceobjectRequired{lat, lon, alt_m, emission_rate_gs, stack_height_m} β€” emission source geometry
windobjectOptional{speed_ms, bearing_deg} β€” overrides live met data if provided
stability_classstringOptionalPasquill-Gifford stability class A–F. Auto-resolved from met data if omitted.
duration_minintegerRequiredSimulation duration in minutes. Max 720 for Tier 1, 480 for Tier 2.
tierintegerOptional1 (Gaussian, $0.05), 2 (AERMOD, $0.25), 3 (Lagrangian, $0.50). Default: 1
speciesstringOptionalChemical species for species-specific dispersion coefficients (e.g., H2S, CH4, NO2, PM25). Default: generic passive scalar.
output_receptorsstring[]OptionalArray of device IDs to include predicted concentrations for in the response
webhook_urlstringOptionalOverride account-level webhook for this job only
Request JSON
POST https://api.geog.ai/v1/simulate/plume
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
X-Site-ID: wellpad_07
Content-Type: application/json

{
  "source": {
    "lat": 31.9642, "lon": -99.9035, "alt_m": 545,
    "emission_rate_gs": 2.4, "stack_height_m": 12
  },
  "duration_min": 60,
  "tier": 1,
  "species": "H2S",
  "output_receptors": ["sensor_h2s_021", "sensor_h2s_023", "sensor_h2s_024"]
}
Completed job result JSON
{
  "ok": true,
  "result": {
    "isopleths": [
      { "concentration_ppm": 50, "polygon": { /* GeoJSON polygon */ } },
      { "concentration_ppm": 10, "polygon": { /* GeoJSON polygon β€” permit limit */ } },
      { "concentration_ppm": 1, "polygon": { /* GeoJSON polygon */ } }
    ],
    "receptor_predictions": [
      { "device_id": "sensor_h2s_021", "predicted_ppm": 0.2 },
      { "device_id": "sensor_h2s_023", "predicted_ppm": 47.8 },
      { "device_id": "sensor_h2s_024", "predicted_ppm": 14.3 }
    ],
    "permit_exceedance": true,
    "exceedance_zones": [{ "jurisdiction": "TCEQ", "limit_ppm": 10, "area_m2": 41200 }],
    "max_downwind_distance_m": 1840,
    "time_to_boundary_min": 3.7
  },
  "meta": { "confidence": 0.91, "model_version": "plume-v4.1.2", "site_calibrated": true, "calibration_age_days": 47 }
}
POST /simulate/plume/stream Live plume tracking via Server-Sent Events $0.25 / run Try it β†—

Opens a Server-Sent Events (SSE) stream that delivers a running plume simulation in real time. Shares the source, wind, stability_class, duration_min, species, and output_receptors shape with the async /simulate/plume endpoint and adds two streaming-specific options. The server responds 200 OK with Content-Type: text/event-stream and emits four named events β€” tick, concentration, alert, and end β€” until the simulation completes or the client disconnects. Every event carries a stable id: field; clients that drop the connection should reconnect with Last-Event-ID to resume from the last event seen. The official SDKs (streamPlume in TypeScript, stream_plume in Python) implement this reconnect-with-backoff behaviour automatically. Billed once on end.

Site Calibration plans only. Callers on Discovery, Operations, or Compliance plans receive 403. Concurrent open streams are also rate-limited; 429 responses indicate the per-account stream limit was reached.
FieldTypeRequiredDescription
sourceobjectRequired{lat, lon, alt_m, emission_rate_gs, stack_height_m} β€” emission source geometry (same shape as /simulate/plume)
duration_minintegerRequiredTotal simulation duration in minutes. The stream closes with an end event when this elapses.
windobjectOptional{speed_ms, bearing_deg} β€” overrides live met data if provided
stability_classstringOptionalPasquill-Gifford stability class A–F. Auto-resolved from met data if omitted.
speciesstringOptionalChemical species (e.g. H2S, CH4, NO2, PM25). Default: generic passive scalar.
output_receptorsstring[]OptionalDevice IDs whose predicted concentrations should be included in each concentration event
tick_interval_sintegerOptionalSeconds between tick heartbeat events. Range 1–60. Lower values yield smoother UI updates at the cost of bandwidth. Default: 5
alert_thresholdsobjectOptionalConcentration thresholds in ppm (e.g. {warn: 1.0, action: 5.0, evacuate: 10.0}) that trigger alert events when crossed at any registered receptor or isopleth
Open the stream cURL
curl https://api.geog.ai/v1/simulate/plume/stream \
  -N \
  -H "Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’" \
  -H "X-Site-ID: wellpad_07" \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {"lat": 31.9642, "lon": -99.9035, "alt_m": 545, "emission_rate_gs": 2.4, "stack_height_m": 12},
    "wind": {"speed_ms": 8.2, "bearing_deg": 312},
    "duration_min": 60,
    "species": "H2S",
    "output_receptors": ["sensor_h2s_021", "sensor_h2s_023", "sensor_h2s_024"],
    "tick_interval_s": 5,
    "alert_thresholds": {"warn": 1.0, "action": 5.0, "evacuate": 10.0}
  }'

To resume a dropped connection, replay the same request with the Last-Event-ID header set to the last id: value the client received (e.g. -H "Last-Event-ID: 47"). The server will resume the stream from the next event.

Response stream text/event-stream
// event: tick β€” heartbeat with sim clock and wind vector, every tick_interval_s
event: tick
id: 1
data: {"sim_time_s":5,"wall_time":"2026-04-21T03:14:27Z","progress":0.0014,"wind_vector":{"speed_ms":8.2,"bearing_deg":312}}

// event: concentration β€” updated isopleths and per-receptor predictions
event: concentration
id: 2
data: {"sim_time_s":10,"isopleths":[{"concentration_ppm":10,"polygon":{"type":"Polygon","coordinates":[]}}],"receptors":[{"device_id":"sensor_h2s_023","concentration_ppm":4.7}],"max_concentration_ppm":12.4}

// event: alert β€” fired when an alert_thresholds value is crossed
event: alert
id: 9
data: {"sim_time_s":45,"threshold":"action","threshold_ppm":5.0,"observed_ppm":6.2,"device_id":"sensor_h2s_023","location":{"lat":31.9686,"lon":-99.9018}}

// event: end β€” terminal event with run summary; stop reconnecting after this
event: end
id: 720
data: {"sim_time_s":3600,"reason":"completed","summary":{"ticks_emitted":720,"alerts_emitted":3,"max_concentration_ppm":58.1,"job_id":"job_01hx2a3f9k8qmzn7p"}}
POST /simulate/flood Flood and hydrological simulation $0.10 / run Try it β†—

Runs DEM-based watershed delineation, SCS Curve Number rainfall-runoff modeling, kinematic wave channel routing, and 2D inundation mapping. Returns flood extent polygons at multiple return intervals, time-to-crest forecasts, and a list of sensors predicted to be inundated and at what time.

FieldTypeRequiredDescription
watershed_idstringRequired*Registered watershed identifier (*or area polygon)
areaGeoJSONRequired*Polygon to delineate watershed from (*or watershed_id)
precip_mmfloatRequiredTotal precipitation in mm
duration_hrfloatRequiredPrecipitation duration in hours
return_periodsinteger[]OptionalReturn intervals for inundation mapping. Default: [2, 10, 100]
antecedent_moisturestringOptionalSoil moisture condition: dry, normal, wet. Default: auto-resolved from recent sensor data
Request JSON
{
  "watershed_id": "basin_colorado_mid",
  "precip_mm": 85,
  "duration_hr": 3,
  "return_periods": [10, 100]
}
Completed job result (abbreviated) JSON
{
  "result": {
    "inundation_extents": [
      { "return_period_yr": 10, "polygon": { /* GeoJSON */ }, "max_depth_m": 1.2 },
      { "return_period_yr": 100, "polygon": { /* GeoJSON */ }, "max_depth_m": 3.8 }
    ],
    "time_to_crest_hr": 4.2,
    "sensors_at_risk": [
      { "device_id": "wq_sensor_17", "predicted_inundation_hr": 3.1, "depth_m": 0.4 }
    ]
  }
}
POST /simulate/rf_coverage Single-node RF coverage simulation $0.08 / run Try it β†—

Runs the ITM/Longley-Rice terrain propagation model for a single RF node. Returns a coverage probability polygon at the specified RSSI threshold, plus a raster heatmap and link budget summary. For multi-node mesh analysis, use POST /rf/mesh/viability.

FieldTypeRequiredDescription
node_idstringRequired*Registered RF node ID (*or lat/lon/alt)
lat, lon, alt_mfloatRequired*Node coordinates (*or node_id)
frequency_mhzfloatRequiredCarrier frequency in MHz
tx_power_dbmfloatRequiredTransmit power in dBm
rssi_threshold_dbmfloatOptionalRSSI threshold for coverage polygon. Default: -90 dBm
antenna_height_mfloatOptionalAntenna height above ground. Default: 3m
modelstringOptionalitm (default, terrain-aware), hata (urban macro-cell), two_ray (near-field)
Request JSON
{
  "node_id": "rf_node_A7",
  "frequency_mhz": 915,
  "tx_power_dbm": 20,
  "rssi_threshold_dbm": -95
}
Completed job result (abbreviated) JSON
{
  "result": {
    "coverage_polygon": { /* GeoJSON β€” area where RSSI β‰₯ threshold */ },
    "coverage_area_km2": 4.7,
    "max_range_m": 2340,
    "heatmap_url": "https://tiles.geog.ai/rf/job_01hx.../rssi/{z}/{x}/{y}.png",
    "obstructed_sensors": ["sensor_wq_09"],
    "link_budget": { "path_loss_db": 142.3, "margin_db": 7.2, "fresnel_clearance": true }
  }
}
POST /simulate/noise Noise propagation simulation $0.08 / run Try it β†—

Runs ISO 9613-2 octave-band outdoor sound propagation from a point or line source. Accounts for geometric spreading, atmospheric absorption, ground effect, barrier insertion loss, and vegetation attenuation. Returns noise contour polygons (isodB lines) and exceedance flags at registered receptor locations (property boundaries, sensitive receivers).

FieldTypeRequiredDescription
source_idstringRequired*Registered noise source ID (*or geometry)
octave_bands_dbfloat[8]RequiredSound power levels (dB) for 63, 125, 250, 500, 1k, 2k, 4k, 8k Hz octave bands
contour_levels_dbafloat[]OptionaldBA levels for contour polygons. Default: [35, 45, 55, 65, 75]
receptorsGeoJSON[]OptionalSpecific receptor points to evaluate (e.g., property boundary centroids)
Request JSON
POST https://api.geog.ai/v1/simulate/noise
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "source_id": "compressor_C4",
  "octave_bands_db": [82, 86, 91, 95, 93, 88, 84, 79],
  "contour_levels_dba": [45, 55, 65]
}
Completed job result JSON
{
  "ok": true,
  "result": {
    "contours": [
      { "level_dba": 65, "polygon": { /* GeoJSON polygon */ }, "area_m2": 8400 },
      { "level_dba": 55, "polygon": { /* GeoJSON polygon */ }, "area_m2": 31200 },
      { "level_dba": 45, "polygon": { /* GeoJSON polygon */ }, "area_m2": 98700 }
    ],
    "receptor_results": [
      { "id": "property_boundary_north", "predicted_dba": 61.4, "limit_dba": 60, "exceedance": true },
      { "id": "property_boundary_south", "predicted_dba": 48.2, "limit_dba": 60, "exceedance": false }
    ],
    "overall_la_max_dbm": 97.4
  },
  "meta": { "confidence": 0.88, "model_version": "noise-iso9613-v2.3", "latency_ms": 5200 }
}
POST /simulate/thermal Thermal / heat diffusion simulation $0.08 / run Try it β†—

Models heat propagation from industrial sources, fire events, or HVAC failures. Returns time-to-temperature-threshold at monitored locations, hot zone polygons for personnel exclusion, and cooling equipment failure cascade prediction. Used for cold chain breach analysis, server room modeling, and wildfire thermal exposure.

FieldTypeRequiredDescription
sourceobjectRequired{lat, lon, power_kw, temperature_c} β€” heat source specification
duration_minintegerRequiredSimulation duration in minutes
threshold_cfloat[]OptionalTemperature thresholds for zone boundary polygons. Default: [40, 60, 80]
output_receptorsstring[]OptionalDevice IDs to predict temperature at
Request JSON
POST https://api.geog.ai/v1/simulate/thermal
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "source": {
    "lat": 31.9642, "lon": -99.9035,
    "power_kw": 850, "temperature_c": 240
  },
  "duration_min": 120,
  "threshold_c": [40, 60, 80],
  "output_receptors": ["sensor_temp_07", "sensor_temp_12"]
}
Completed job result JSON
{
  "ok": true,
  "result": {
    "thermal_zones": [
      { "threshold_c": 80, "polygon": { /* GeoJSON polygon */ }, "area_m2": 1200 },
      { "threshold_c": 60, "polygon": { /* GeoJSON polygon */ }, "area_m2": 4800 },
      { "threshold_c": 40, "polygon": { /* GeoJSON polygon */ }, "area_m2": 14300 }
    ],
    "receptor_predictions": [
      { "device_id": "sensor_temp_07", "predicted_temp_c": 72.4 },
      { "device_id": "sensor_temp_12", "predicted_temp_c": 38.1 }
    ],
    "time_to_steady_state_min": 47
  },
  "meta": { "confidence": 0.86, "model_version": "thermal-diffusion-v1.4" }
}
POST /simulate/fire Fire spread simulation $0.15 / run Try it β†—

Models wildfire spread using terrain, fuel model, and weather conditions. Returns fire perimeter polygons at specified time intervals, rate-of-spread vectors, and smoke plume trajectory (linked to /simulate/plume for full smoke transport). Used for V50 (Wildfire) vertical, prescribed burn planning, and asset protection zone analysis.

FieldTypeRequiredDescription
ignition_pointGeoJSON PointRequiredFire ignition location
fuel_modelstringRequiredNFFL/Scott-Burgan fuel model code (e.g., GR2, SH5, TU1)
weatherobjectOptional{wind_speed_ms, wind_bearing_deg, rh_pct, temp_c} β€” overrides live met data
duration_hrfloatRequiredSimulation duration in hours. Max 72hr.
output_intervals_hrfloat[]OptionalTimes at which to output perimeter polygons. Default: [1, 4, 12, 24]
Request JSON
POST https://api.geog.ai/v1/simulate/fire
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "ignition_point": { "type": "Point", "coordinates": [-119.42, 34.21] },
  "fuel_model": "GR2",
  "weather": { "wind_speed_ms": 8.3, "wind_bearing_deg": 225, "rh_pct": 12, "temp_c": 38 },
  "duration_hr": 24,
  "output_intervals_hr": [1, 4, 12, 24]
}
Completed job result (abbreviated) JSON
{
  "ok": true,
  "result": {
    "perimeters": [
      { "elapsed_hr": 1, "polygon": { /* GeoJSON polygon */ }, "area_ha": 47 },
      { "elapsed_hr": 4, "polygon": { /* GeoJSON polygon */ }, "area_ha": 310 },
      { "elapsed_hr": 24, "polygon": { /* GeoJSON polygon */ }, "area_ha": 2840 }
    ],
    "rate_of_spread_m_min": 14.7,
    "spotting_probability": 0.62,
    "assets_threatened": ["wellpad_07", "pipeline_seg_C"],
    "smoke_plume_job_id": "job_01hxyz..."
  },
  "meta": { "confidence": 0.84, "model_version": "fire-rothermel-v3.1" }
}

RF API

RF endpoints model radio signal propagation for mesh network design, coverage analysis, and placement optimization. These are the core endpoints for the V47 (Border Security), V18 (Drone Operations), and V48 (Industrial Wireless Mesh) verticals.

GET /rf/coverage Coverage polygon for a registered node $0.08 / call Try it β†—

Returns the coverage probability polygon for a registered RF node at a given RSSI threshold. Cached per node until terrain or node configuration changes. Faster than /simulate/rf_coverage for registered nodes β€” uses pre-computed ITM results.

ParameterTypeRequiredDescription
nodestringRequiredRegistered RF node ID
threshold_dbmfloatOptionalRSSI threshold. Default: -90 dBm
Request HTTP
GET https://api.geog.ai/v1/rf/coverage?node=rf_node_A7&threshold_dbm=-92
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response JSON
{
  "ok": true,
  "result": {
    "node_id": "rf_node_A7",
    "threshold_dbm": -92,
    "coverage_polygon": { /* GeoJSON polygon */ },
    "coverage_area_km2": 5.1,
    "max_range_m": 2640,
    "cached": true,
    "cache_age_min": 12
  },
  "meta": { "confidence": 0.95, "latency_ms": 38 }
}
GET /rf/gaps Coverage gap heatmap within an area $0.05 / call Try it β†—

Identifies coverage gaps within a defined area given existing node topology. Returns a GeoJSON heatmap of gap severity and a list of H3 cells with insufficient coverage. Used as the input analysis step before /rf/optimize/placement.

ParameterTypeRequiredDescription
areaGeoJSONRequiredPolygon defining the area to analyze
threshold_dbmfloatOptionalRSSI below which a cell is considered a gap. Default: -90 dBm
node_idsstring[]OptionalSpecific nodes to analyze against. Default: all registered nodes in the area
Request HTTP
GET https://api.geog.ai/v1/rf/gaps?area=%7B%22type%22%3A%22Polygon%22...%7D&threshold_dbm=-90
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response JSON
{
  "ok": true,
  "result": {
    "coverage_pct": 67.4,
    "gap_pct": 32.6,
    "gap_heatmap": { /* GeoJSON heatmap FeatureCollection */ },
    "gap_cells_h3": ["892a307234bffff", "892a3072347ffff"],
    "worst_gap_location": { "lat": 31.972, "lon": -99.916, "best_rssi_dbm": -106.4 }
  },
  "meta": { "confidence": 0.93, "latency_ms": 84 }
}
POST /rf/mesh/viability Full mesh connectivity matrix $0.15 / run Try it β†—

Computes the full link budget matrix for a mesh network β€” every node-to-node pair, with failure probability based on calibrated path loss models. Returns a connectivity matrix, mesh reliability score, single-points-of-failure, and failure scenario analysis. Async job; typical completion 15–40 seconds for networks up to 200 nodes.

FieldTypeRequiredDescription
node_idsstring[]RequiredArray of registered node IDs to include in the analysis. Max 200.
failure_scenariosobject[]OptionalArray of {node_id, failure_probability} scenarios to evaluate. Used for resilience planning.
min_path_reliability_pctfloatOptionalMinimum acceptable end-to-end path reliability. Default: 99.0
Request JSON
POST https://api.geog.ai/v1/rf/mesh/viability
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "node_ids": ["rf_node_A7", "rf_node_B3", "rf_node_C2", "rf_node_D9"],
  "min_path_reliability_pct": 99.5,
  "failure_scenarios": [
    { "node_id": "rf_node_A7", "failure_probability": 0.05 }
  ]
}
Completed job result (abbreviated) JSON
{
  "ok": true,
  "result": {
    "mesh_reliability_score": 0.974,
    "link_matrix": {
      "rf_node_A7": { "rf_node_B3": { "viable": true, "rssi_dbm": -82.4, "margin_db": 7.6 }, "rf_node_C2": { "viable": true, "rssi_dbm": -88.1, "margin_db": 1.9 } },
      "rf_node_D9": { "rf_node_A7": { "viable": false, "rssi_dbm": -97.8, "margin_db": -7.8 } }
    },
    "single_points_of_failure": ["rf_node_C2"],
    "failure_scenario_results": [
      { "scenario": "rf_node_A7 fails", "connectivity_pct": 66.7, "isolated_nodes": ["rf_node_D9"] }
    ]
  },
  "meta": { "confidence": 0.92, "model_version": "mesh-itm-v2.0" }
}
POST /rf/optimize/placement Node placement optimizer for RF coverage $1.00–$5.00 / run Try it β†—

Given existing mesh topology, terrain, and coverage requirements, returns the top-N candidate locations that maximize marginal coverage gain. Consultants charge $50K+ for site surveys that produce this output manually. The optimizer runs terrain-aware ITM models against candidate grid points and ranks by marginal coverage gain per dollar of deployment cost.

FieldTypeRequiredDescription
areaGeoJSONRequiredPolygon defining the deployment area
coverage_thresholdfloatRequiredTarget coverage fraction (0.0–1.0)
existing_nodesstring[]OptionalRegistered node IDs to include in existing topology. Default: all nodes in area
candidate_countintegerOptionalNumber of candidate placements to return. Default: 5, max: 20
frequency_mhzfloatOptionalTarget frequency in MHz. Default: 915
constraintsobjectOptional{min_distance_from_edge_m, exclude_polygons, require_road_access}
Completed job result (abbreviated) JSON
{
  "result": {
    "current_coverage_pct": 67.4,
    "target_coverage_pct": 95.0,
    "candidates": [
      {
        "rank": 1,
        "location": { "lat": 31.971, "lon": -99.904, "alt_m": 552 },
        "marginal_coverage_gain_pct": 14.2,
        "links_to_existing": ["rf_node_A7", "rf_node_C2"],
        "terrain_type": "ridge_top",
        "road_access_m": 340
      }
    ]
  }
}

Optimize API

Multi-modal sensor placement optimization and coverage analysis. The /optimize endpoints extend RF placement to multi-sensor, multi-vertical deployments.

POST /optimize/node_placement General node placement optimizer $1.00–$5.00 / run Try it β†—

Generalizes RF placement to any monitoring objective β€” air quality sensor spacing (accounting for plume physics), flood gauge placement (watershed coverage), or acoustic monitor placement (source coverage). Returns ranked candidate locations with objective-specific marginal gain scores.

FieldTypeRequiredDescription
areaGeoJSONRequiredDeployment area polygon
coverage_thresholdfloatRequiredTarget coverage fraction (0.0–1.0)
existing_nodesstring[]OptionalRegistered existing node IDs
objectivestringOptionalOptimization objective: rf_coverage (default), plume_detection, flood_warning, acoustic_monitoring
Request JSON
POST https://api.geog.ai/v1/optimize/node_placement
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "area": { "type": "Polygon", "coordinates": [[/* polygon ring */]] },
  "coverage_threshold": 0.90,
  "existing_nodes": ["sensor_h2s_021", "sensor_h2s_023"],
  "objective": "plume_detection"
}
Completed job result (abbreviated) JSON
{
  "ok": true,
  "result": {
    "current_coverage_pct": 58.3,
    "candidates": [
      { "rank": 1, "location": { "lat": 31.965, "lon": -99.912 }, "marginal_gain_pct": 18.7, "terrain_type": "upwind_ridge" },
      { "rank": 2, "location": { "lat": 31.968, "lon": -99.896 }, "marginal_gain_pct": 11.4, "terrain_type": "flat" }
    ],
    "nodes_to_reach_threshold": 2
  },
  "meta": { "confidence": 0.89, "latency_ms": 18400 }
}
POST /optimize/sensor_placement Vertical-aware sensor placement $2.00–$5.00 / run Try it β†—

Recommends sensor placement for a given ZOOID vertical β€” accounts for the vertical's specific measurement physics, regulatory requirements, and deployment constraints. For V01 (gas monitoring), incorporates prevailing wind roses and upwind source locations. For V02 (water quality), uses watershed topology and flow network.

FieldTypeRequiredDescription
verticalstringRequiredZOOID vertical ID (e.g., V01, V02, V05)
areaGeoJSONRequiredSite or deployment area polygon
target_coverage_pctfloatRequiredTarget monitoring coverage (0.0–1.0)
budget_nodesintegerOptionalMaximum number of additional sensor nodes to place
Request JSON
POST https://api.geog.ai/v1/optimize/sensor_placement
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "vertical": "V01",
  "area": { "type": "Polygon", "coordinates": [[/* polygon ring */]] },
  "target_coverage_pct": 0.95,
  "budget_nodes": 4
}
Completed job result (abbreviated) JSON
{
  "ok": true,
  "result": {
    "vertical": "V01",
    "current_coverage_pct": 61.2,
    "placement_plan": [
      { "rank": 1, "location": { "lat": 31.962, "lon": -99.914 }, "rationale": "Upwind of 3 registered emission sources; P90 wind sector aligned", "coverage_gain_pct": 16.8 },
      { "rank": 2, "location": { "lat": 31.974, "lon": -99.897 }, "rationale": "Covers NE quadrant downwind gap", "coverage_gain_pct": 10.4 }
    ],
    "projected_coverage_with_plan": 0.954
  },
  "meta": { "confidence": 0.90, "model_version": "sensor-place-v2.1" }
}
GET /coverage Coverage analysis for an area $0.01 / call Try it β†—

Returns the current monitoring coverage fraction for a given area and sensor type. Coverage is defined by the spatial footprint of registered sensors given their detection physics β€” not just proximity. A gas sensor on a hilltop covers a different downwind area than one in a valley.

ParameterTypeRequiredDescription
areaGeoJSONRequiredURL-encoded GeoJSON polygon
typestringRequiredSensor type to assess coverage for
Request HTTP
GET https://api.geog.ai/v1/coverage?area=%7B%22type%22%3A%22Polygon%22...%7D&type=gas_sensor
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response JSON
{
  "ok": true,
  "result": {
    "sensor_type": "gas_sensor",
    "area_km2": 18.4,
    "covered_km2": 12.1,
    "coverage_pct": 65.8,
    "active_sensors": 7,
    "coverage_polygon": { /* GeoJSON β€” union of all sensor footprints */ }
  },
  "meta": { "confidence": 0.94, "latency_ms": 52 }
}
GET /gaps Monitoring gap analysis $0.01 / call Try it β†—

Identifies monitoring gaps within a defined area β€” H3 cells where no sensor provides adequate coverage given detection physics. Returns a GeoJSON gap heatmap and summary statistics. Companion to /coverage; where /coverage returns a fraction, /gaps returns the geographic specifics of what is not covered.

ParameterTypeRequiredDescription
areaGeoJSONRequiredURL-encoded GeoJSON polygon
threshold_mfloatOptionalMaximum acceptable distance from any sensor. Default: 500m
typestringOptionalSensor type filter
Request HTTP
GET https://api.geog.ai/v1/gaps?area=%7B%22type%22%3A%22Polygon%22...%7D&threshold_m=400&type=gas_sensor
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response JSON
{
  "ok": true,
  "result": {
    "gap_pct": 34.2,
    "gap_heatmap": { /* GeoJSON FeatureCollection β€” severity-colored H3 cells */ },
    "gap_cells_h3": ["892a307234bffff", "892a3072341ffff"],
    "largest_gap_area_km2": 1.8,
    "largest_gap_centroid": { "lat": 31.974, "lon": -99.921 }
  },
  "meta": { "confidence": 0.96, "latency_ms": 48 }
}

Trajectory API

Spatiotemporal trajectory storage and prediction for moving entities β€” drones, vehicles, personnel, drifting sensor buoys. All trajectories carry spatial uncertainty bounds alongside the estimated position.

GET /trajectory Trajectory history for a moving entity $0.002 / call Try it β†—

Returns the stored trajectory (sequence of positions with timestamps and uncertainty bounds) for a registered moving entity over a specified time window. Uses MobilityDB temporal spatial joins for efficient window queries.

ParameterTypeRequiredDescription
entity_idstringRequiredRegistered entity ID (drone, vehicle, or personnel)
fromISO 8601RequiredStart of time window
toISO 8601RequiredEnd of time window. Max window: 30 days
resolution_sintegerOptionalTemporal resolution of returned positions in seconds. Default: raw (all recorded positions)
Response (abbreviated) JSON
{
  "result": {
    "entity_id": "drone_01",
    "window": { "from": "2026-04-21T02:00:00Z", "to": "2026-04-21T03:00:00Z" },
    "point_count": 3600,
    "positions": [
      { "time": "2026-04-21T02:00:00Z", "lat": 31.968, "lon": -99.901, "alt_m": 120, "uncertainty_m": 1.2 },
      // ... more positions ...
    ],
    "geojson_linestring": { /* GeoJSON LineString for rendering */ }
  }
}
POST /trajectory/predict Trajectory prediction / dead reckoning $0.005 / call Try it β†—

Predicts the future trajectory of a moving entity given current position, velocity, and historical movement patterns. Returns a predicted path with expanding uncertainty bounds. Used for drone flight path prediction, fleet routing optimization, and personnel zone arrival forecasting.

FieldTypeRequiredDescription
entity_idstringRequiredRegistered entity ID
lookahead_minintegerRequiredPrediction horizon in minutes. Max 120min.
mission_planGeoJSONOptionalPlanned route as a GeoJSON LineString β€” used to constrain prediction to known flight plan
Request JSON
POST https://api.geog.ai/v1/trajectory/predict
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "entity_id": "drone_01",
  "lookahead_min": 30,
  "mission_plan": { "type": "LineString", "coordinates": [[-99.901, 31.968], [-99.892, 31.975], [-99.885, 31.965]] }
}
Response JSON
{
  "ok": true,
  "result": {
    "entity_id": "drone_01",
    "predicted_positions": [
      { "time": "2026-04-21T03:05:00Z", "lat": 31.970, "lon": -99.898, "alt_m": 120, "uncertainty_m": 8 },
      { "time": "2026-04-21T03:15:00Z", "lat": 31.973, "lon": -99.888, "alt_m": 120, "uncertainty_m": 22 },
      { "time": "2026-04-21T03:30:00Z", "lat": 31.966, "lon": -99.886, "alt_m": 120, "uncertainty_m": 41 }
    ],
    "predicted_path": { /* GeoJSON LineString */ },
    "uncertainty_corridor": { /* GeoJSON Polygon β€” expanding bounds */ }
  },
  "meta": { "confidence": 0.87, "latency_ms": 64 }
}

Graph API

The spatial graph engine models sensor networks as graphs β€” nodes are devices and assets, edges encode distance, radio connectivity, fluid connectivity (pipe/stream), and causal influence. Graph operations unlock reasoning that point queries cannot.

GET /graph/influence Downstream influence spread from a node $0.005 / call Try it β†—

Returns all nodes in the sensor graph that are "downstream" of a source event β€” entities likely to be affected if the source produces an event. Edge weights reflect propagation physics (plume transport, fluid flow, RF signal paths). Returns nodes ordered by predicted arrival time and estimated impact magnitude.

ParameterTypeRequiredDescription
source_idstringRequiredSource node ID (device, asset, or event location)
edge_typestringOptionalEdge type to traverse: atmospheric (plume), fluid (pipe/stream), rf (signal), causal (all). Default: causal
max_hopsintegerOptionalMaximum graph traversal depth. Default: 5
time_horizon_minintegerOptionalRestrict to nodes reachable within this time. Default: unlimited
Response (abbreviated) JSON
{
  "result": {
    "source": "sensor_h2s_023",
    "downstream_nodes": [
      { "id": "personnel_zone_C", "arrival_min": 3.7, "edge_type": "atmospheric", "impact_score": 0.87 },
      { "id": "sensor_h2s_024", "arrival_min": 5.1, "edge_type": "atmospheric", "impact_score": 0.64 }
    ]
  }
}
GET /graph/evacuate Evacuation routing given current hazard state $0.01 / call Try it β†—

Returns optimal evacuation routes from specified locations to safe zones, routing around active hazard polygons (from live plume, fire, or flood simulations). Uses pgRouting on the facility graph with real-time hazard avoidance. Used by V15 (Personnel Safety) and V17 (Emergency Response) verticals.

ParameterTypeRequiredDescription
from_idsstring[]RequiredEntity or zone IDs to evacuate from (personnel IDs, zone IDs)
to_idsstring[]OptionalTarget safe zone IDs. Default: all registered safe zones in the facility
avoid_job_idsstring[]OptionalSimulation job IDs whose result polygons to treat as impassable (live plume, flood, fire footprints)
site_idstringOptionalSite context for indoor/outdoor routing graph selection
Request HTTP
GET https://api.geog.ai/v1/graph/evacuate?from_ids=personnel_zone_C,personnel_zone_D&avoid_job_ids=job_01hxyz
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
X-Site-ID: wellpad_07
Response JSON
{
  "ok": true,
  "result": {
    "routes": [
      {
        "from_id": "personnel_zone_C",
        "to_id": "safe_zone_north",
        "distance_m": 340,
        "estimated_time_min": 4.1,
        "path": { /* GeoJSON LineString */ },
        "hazard_avoidance": { "jobs_avoided": ["job_01hxyz"], "rerouted": true }
      },
      {
        "from_id": "personnel_zone_D",
        "to_id": "safe_zone_north",
        "distance_m": 520,
        "estimated_time_min": 6.3,
        "path": { /* GeoJSON LineString */ },
        "hazard_avoidance": { "jobs_avoided": ["job_01hxyz"], "rerouted": false }
      }
    ]
  },
  "meta": { "confidence": 0.97, "latency_ms": 78 }
}
GET /graph/upstream Upstream sensors and contributing sources $0.005 / call Try it β†—

Returns all sensors and assets upstream of a given node in the sensor graph β€” entities that could causally affect the target node. Used for root cause analysis: "which upwind sources could have caused this reading?", "which upstream watershed sensors are relevant to this gauge?"

ParameterTypeRequiredDescription
target_idstringRequiredNode to find upstream sources for
edge_typestringOptionalEdge type filter (same options as /graph/influence)
max_hopsintegerOptionalMaximum traversal depth. Default: 5
Request HTTP
GET https://api.geog.ai/v1/graph/upstream?target_id=sensor_h2s_023&edge_type=atmospheric&max_hops=3
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response JSON
{
  "ok": true,
  "result": {
    "target": "sensor_h2s_023",
    "upstream_sources": [
      { "id": "emission_source_A", "type": "point_source", "edge_type": "atmospheric", "hops": 1, "travel_time_min": 3.7, "causal_probability": 0.84 },
      { "id": "compressor_C4", "type": "noise_source", "edge_type": "atmospheric", "hops": 2, "travel_time_min": 7.2, "causal_probability": 0.41 }
    ],
    "top_candidate": "emission_source_A"
  },
  "meta": { "confidence": 0.91, "latency_ms": 42 }
}

Calibration API

Calibration endpoints expose the site-specific model state β€” parameter values, calibration history, accuracy metrics, and pending updates. Available only on Site Calibration plan.

GET /model/calibration Current calibration state for a site model Site Calibration plan Try it β†—

Returns the current calibration state for a site-specific propagation model β€” parameter values, last update timestamp, accuracy metrics (predicted vs observed), and calibration history. Used for regulatory audit trails and model performance monitoring.

ParameterTypeRequiredDescription
modelstringRequiredModel type: plume, rf, flood, noise, thermal
site_idstringRequiredSite identifier (same as X-Site-ID header)
history_daysintegerOptionalDays of calibration history to include. Default: 30, max: 365
Response JSON
{
  "result": {
    "site_id": "wellpad_07",
    "model": "plume",
    "calibration_age_days": 47,
    "last_updated": "2026-03-05T14:22:00Z",
    "parameters": {
      "stability_correction": 1.14,
      "terrain_factor": 0.87,
      "effective_stack_height_m": 13.2,
      "sigma_y_correction": 1.08,
      "sigma_z_correction": 0.93
    },
    "accuracy_metrics": {
      "rmse_ppm": 2.1,
      "bias_ppm": 0.4,
      "correlation_r2": 0.94,
      "calibration_events": 1847
    },
    "history": [
      { "date": "2026-03-05", "rmse": 2.1, "events": 124 },
      { "date": "2026-03-04", "rmse": 2.3, "events": 98 }
    ]
  },
  "meta": { "confidence": 0.99, "latency_ms": 34 }
}
Regulatory reporting: The calibration audit trail (available via history_days=365) is formatted for inclusion in AERMOD permit demonstration reports and EPA compliance submittals. Each calibration event is timestamped, signed, and traceable to the sensor readings that drove the update.

Jobs

All /simulate/*, /rf/mesh/viability, /rf/optimize/placement, and /optimize/* endpoints are async β€” they return a job ID immediately and complete in the background. Use GET /jobs/{job_id} to poll for status and retrieve the result, or receive it automatically via webhook.

GET /jobs/{job_id} Poll async job status and retrieve result Try it β†—

Returns the current status of an async job. When status is complete, the full result is included in the response. Jobs expire 48 hours after completion.

ParameterTypeRequiredDescription
job_idstringRequiredJob ID returned from any async endpoint (path parameter)
Request HTTP
GET https://api.geog.ai/v1/jobs/job_01hx2a3f9k8qmzn7p
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” running JSON
{
  "ok": true,
  "job": {
    "id": "job_01hx2a3f9k8qmzn7p",
    "type": "simulate/plume",
    "status": "running",
    "eta_seconds": 4,
    "webhook_delivered": false
  },
  "result": null
}
Response β€” complete (abbreviated) JSON
{
  "ok": true,
  "job": {
    "id": "job_01hx2a3f9k8qmzn7p",
    "type": "simulate/plume",
    "status": "complete",
    "eta_seconds": null,
    "webhook_delivered": true
  },
  "result": { /* full simulation result */ },
  "meta": { "confidence": 0.91, "model_version": "plume-v4.1.2" }
}

Webhooks

Register one or more HTTPS URLs to receive async job results as soon as they complete, instead of polling GET /jobs/{job_id}. Each delivery is signed with HMAC-SHA256 so your server can verify it originated from geog.ai.

POST /webhooks Register a webhook endpoint to receive async job results Try it β†—

Registers an HTTPS callback URL on your account. Once registered, every async job your account creates (/simulate/*, /rf/mesh/viability, /rf/optimize/placement, /optimize/*) will POST its full result envelope to this URL when it transitions to complete or failed. Per-job overrides can still be supplied via the webhook_url field on the job request body. Returns the registered webhook record including the secret used to sign deliveries β€” store it securely; it is shown only once.

Request Body
ParameterTypeRequiredDescription
urlstringRequiredHTTPS endpoint that will receive POST deliveries. Must be reachable from the public internet and respond with 2xx within 10 seconds.
eventsstring[]OptionalFilter deliveries to specific event types. Default: all. Options: job.complete, job.failed, calibration.updated, webhook.disabled (fired on the final attempt before the webhook is auto-disabled β€” best subscribed on a separately-monitored receiver so you are not relying on the failing endpoint to learn it has been disabled; the same state is queryable via GET /webhooks/{id}/health)
descriptionstringOptionalHuman-readable label shown on the dashboard (max 200 chars)
activebooleanOptionalWhether the webhook should start delivering immediately. Default: true
Request HTTP
POST https://api.geog.ai/v1/webhooks
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "url": "https://api.acme.com/hooks/geog",
  "events": ["job.complete", "job.failed"],
  "description": "Production plume results β†’ SOC pipeline",
  "active": true
}
Response β€” 200 OK
FieldTypeDescription
okbooleanAlways true on success
result.idstringUnique webhook identifier (prefix whk_)
result.urlstringEchoes the registered HTTPS callback URL
result.eventsstring[]Event types this webhook is subscribed to
result.descriptionstringEchoes the human-readable label
result.activebooleanWhether deliveries are currently enabled. Flips to false if the owner pauses the webhook or if the platform auto-disables it after sustained failures (β‰₯50% failed deliveries in the last hour)
result.disabled_atstring | nullTimestamp at which active last transitioned from true to false; null while the webhook is active
result.disabled_reasonstring | nullmanual (owner set active=false) or auto_failure_rate (auto-disabled after sustained failures); null while active=true. See GET /webhooks/{id}/health for the live failure counters.
result.secretstringShared secret for HMAC-SHA256 signing of deliveries. Returned only on this initial response β€” store it securely; rotate by deleting and re-registering
result.created_atstring (ISO 8601)Registration timestamp
meta.request_idstringUnique request identifier for support tickets
Response β€” 200 OK JSON
{
  "ok": true,
  "result": {
    "id": "whk_01hx2f8q7n3tdvm5j",
    "url": "https://api.acme.com/hooks/geog",
    "events": ["job.complete", "job.failed"],
    "description": "Production plume results β†’ SOC pipeline",
    "active": true,
    "disabled_at": null,
    "disabled_reason": null,
    "secret": "whsec_8k3Jq2pNvR7tYxL4mZ9cFwH1bD5sQ6aU",  // shown once β€” store securely
    "created_at": "2026-04-21T03:14:22Z"
  },
  "meta": { "request_id": "req_01hx2f8q9m4cnzr7t" }
}
Error Responses
StatusCodeDescription
400INVALID_WEBHOOK_URLURL is missing, malformed, not HTTPS, or resolves to a private/loopback address
400UNSUPPORTED_EVENTOne or more entries in events is not a recognised event type
401INVALID_API_KEYMissing or invalid Authorization header
409WEBHOOK_LIMIT_EXCEEDEDAccount already has the maximum number of registered webhooks (10)

Delivery payload

Each delivery is a POST with Content-Type: application/json containing the same response envelope you would receive from GET /jobs/{job_id} when the job is complete. The following headers are always set:

HeaderDescription
X-Geog-SignatureHex-encoded HMAC-SHA256 of {timestamp}.{raw_body} using your webhook secret. Format: t=<unix_ts>,v1=<hex_digest>
X-Geog-EventEvent type (job.complete, job.failed, calibration.updated, webhook.disabled)
X-Geog-DeliveryUnique delivery ID β€” safe to use for idempotency on your side
X-Geog-Webhook-IdID of the registered webhook that produced this delivery
X-Geog-TestPresent and set to true only on deliveries produced by POST /webhooks/{id}/test. Absent on real job-driven deliveries β€” use this header to route test traffic away from production side-effects (alerting, billing, downstream writes)

Verifying the signature

To prevent forged callbacks, recompute the HMAC on your side and compare it with X-Geog-Signature using a constant-time comparison. Reject deliveries where the timestamp is more than 5 minutes old to mitigate replay attacks. To exercise this code path end-to-end before any real job runs, jump to Recipe: verify your webhook in 30 seconds — it stitches registration, POST /webhooks/{id}/test, and this verifier into a single copy-pasteable example.

Verify signature Python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, sig = parts["t"], parts["v1"]
    if abs(time.time() - int(ts)) > 300:
        return False  # reject >5 min old
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256)
    return hmac.compare_digest(mac.hexdigest(), sig)

Accepting replays of older deliveries

Because POST /webhooks/{id}/deliveries/{delivery_id}/replay reuses the original signature timestamp, a strict |now - t| > 300 check will reject any replay of a delivery older than 5 minutes. A replay-safe verifier needs all three delivery headers: X-Geog-Webhook-Id to look up the correct endpoint secret (an account can have up to 10 registered webhooks, each with its own secret), X-Geog-Signature to recompute the HMAC, and X-Geog-Delivery to identify which delivery this is. Always recompute the HMAC, and skip the freshness check only when X-Geog-Delivery matches a delivery your server has previously seen and verified β€” that's the signal a replay is legitimate rather than a forged old payload. Dedupe on X-Geog-Delivery so the same event is not processed twice.

Verify fresh + replayed deliveries Python
import hmac, hashlib, time

# `secrets_by_webhook_id` maps X-Geog-Webhook-Id -> that webhook's signing secret.
# `seen_deliveries` is any store keyed on X-Geog-Delivery (e.g. a Redis SET with a
# 30-day TTL matching the dashboard retention window).
def verify(secrets_by_webhook_id: dict, headers: dict, raw_body: bytes, seen_deliveries) -> bool:
    webhook_id = headers["X-Geog-Webhook-Id"]
    sig_header = headers["X-Geog-Signature"]
    delivery_id = headers["X-Geog-Delivery"]

    # 0. Look up the secret for the webhook that produced this delivery.
    # Reject unknown webhook IDs outright β€” never fall back to a default secret.
    secret = secrets_by_webhook_id.get(webhook_id)
    if secret is None:
        return False
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    ts, sig = parts["t"], parts["v1"]

    # 1. Always recompute and constant-time compare the HMAC.
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256)
    if not hmac.compare_digest(mac.hexdigest(), sig):
        return False

    # 2. Fresh delivery? Enforce the 5-minute window and remember the ID.
    if delivery_id not in seen_deliveries:
        if abs(time.time() - int(ts)) > 300:
            return False  # reject forged old payloads
        seen_deliveries.add(delivery_id, ttl=30 * 86400)
        return True

    # 3. Replay of a delivery we already processed: signature is valid, skip the
    # freshness check, and let the caller dedupe on X-Geog-Delivery.
    return True
Verify fresh + replayed deliveries Node
const crypto = require("crypto");

// `secretsByWebhookId` maps X-Geog-Webhook-Id -> that webhook's signing secret.
// `seenDeliveries` is any store keyed on X-Geog-Delivery (e.g. Redis SET with a
// 30-day TTL matching the dashboard retention window).
function verify(secretsByWebhookId, headers, rawBody, seenDeliveries) {
  const webhookId = headers["x-geog-webhook-id"];
  const sigHeader = headers["x-geog-signature"];
  const deliveryId = headers["x-geog-delivery"];

  // 0. Look up the secret for the webhook that produced this delivery.
  // Reject unknown webhook IDs outright β€” never fall back to a default secret.
  const secret = secretsByWebhookId[webhookId];
  if (!secret) return false;
  const parts = Object.fromEntries(sigHeader.split(",").map(p => p.split("=")));
  const { t: ts, v1: sig } = parts;

  // 1. Always recompute and constant-time compare the HMAC.
  // Guard the length first β€” timingSafeEqual throws on mismatched buffers.
  const mac = crypto.createHmac("sha256", secret)
    .update(`${ts}.`).update(rawBody).digest("hex");
  if (typeof sig !== "string" || sig.length !== mac.length) return false;
  const ok = crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig));
  if (!ok) return false;

  // 2. Fresh delivery? Enforce the 5-minute window and remember the ID.
  if (!seenDeliveries.has(deliveryId)) {
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
    seenDeliveries.add(deliveryId, { ttlSeconds: 30 * 86400 });
    return true;
  }

  // 3. Replay of a delivery we already processed: signature is valid, skip the
  // freshness check, and let the caller dedupe on X-Geog-Delivery.
  return true;
}
Retry behaviour: Non-2xx responses (or no response within 10 seconds) trigger automatic retries with exponential backoff at 30s, 2m, 10m, 30m, 2h, 6h, and 24h. After 7 failed attempts the delivery is marked undeliverable. If 50% of deliveries in the last hour have failed the webhook is automatically disabled (active=false, disabled_reason=auto_failure_rate) and an email is sent to the account owner. To detect this auto-disable programmatically rather than relying on the owner's inbox, either subscribe to the webhook.disabled event (delivered on the final attempt before the webhook is paused β€” best subscribed on a separately-monitored receiver) or poll GET /webhooks/{id}/health for the live failure rate and disabled_at timestamp. All deliveries (including failures) are visible on the dashboard for 30 days, and can be inspected and replayed via the endpoints below.
Event payloads Body shape your receiver should expect for each subscribable X-Geog-Event

Every delivery, regardless of event type, uses the same JSON envelope so receivers can dispatch on a single field:

Common envelope JSON
{
  "ok": true,
  "event": "<event-type>",  // also surfaced as the X-Geog-Event header
  "result": { /* shape depends on event β€” see below */ },
  "meta": { "request_id": "req_…", "model_version": "webhooks-v1.0.0" }
}

Dispatch on event (or the X-Geog-Event header β€” they always match) and parse result using the schema for that type:

eventresult shapeNotes
job.completeThe full async job result envelope β€” same body as GET /jobs/{job_id} when the job has finished. result.id, result.type, result.status="complete", result.result (the simulation/optimization output).Fired once per /simulate/*, /rf/*, /optimize/* job that transitions to complete.
job.failedSame envelope as job.complete but result.status="failed", result.result is null, and result.error contains code + message.Fired once per job that exhausts its retries on the platform side.
calibration.updated{ site_id, model_version, calibration_age_days, updated_at, accuracy_metrics } β€” the same fields surfaced by GET /model/calibration for the affected site.Fired when a site's calibration parameters are re-fit (Site Calibration plan only).
webhook.disabledThe same shape returned by GET /webhooks/{id}/health at the moment of the auto-disable: webhook_id, active=false, status="disabled", disabled_at, disabled_reason="auto_failure_rate", plus the rolling-hour counters (delivery_count, success_count, failed_count, undeliverable_count, pending_count, failure_rate, auto_disable_threshold, consecutive_failures, last_success_at, last_failure_at, window_seconds, as_of).Fired exactly once on the final retry attempt before the webhook is paused. Reuses the WebhookHealth shape so receivers do not need a follow-up GET /webhooks/{id}/health call to know what tripped the auto-disable.

job.complete body

POST body JSON
{
  "ok": true,
  "event": "job.complete",
  "result": {
    "id": "job_01hx2a3f9k8qmzn7p",
    "type": "simulate/plume",
    "status": "complete",
    "created_at": "2026-04-21T03:14:22Z",
    "completed_at": "2026-04-21T03:14:30Z",
    "result": { /* full simulation output β€” same as GET /jobs/{id}.result */ }
  },
  "meta": { "request_id": "req_01hx2g4n8p7rqzm3k", "model_version": "webhooks-v1.0.0" }
}

job.failed body

POST body JSON
{
  "ok": true,
  "event": "job.failed",
  "result": {
    "id": "job_01hx2a3f9k8qmzn7p",
    "type": "simulate/plume",
    "status": "failed",
    "created_at": "2026-04-21T03:14:22Z",
    "completed_at": "2026-04-21T03:14:48Z",
    "result": null,
    "error": { "code": "SOLVER_DIVERGED", "message": "Plume solver did not converge after 200 iterations" }
  },
  "meta": { "request_id": "req_01hx2g4n8p7rqzm3k", "model_version": "webhooks-v1.0.0" }
}

calibration.updated body

POST body JSON
{
  "ok": true,
  "event": "calibration.updated",
  "result": {
    "site_id": "wellpad_07",
    "model_version": "plume-v4.1.2",
    "calibration_age_days": 0,
    "updated_at": "2026-04-21T03:30:00Z",
    "accuracy_metrics": { "r_squared": 0.94, "rmse_ppm": 0.18, "sample_count": 1847 }
  },
  "meta": { "request_id": "req_01hx2g4n8p7rqzm3k", "model_version": "webhooks-v1.0.0" }
}

webhook.disabled body

Fired on the last retry attempt before the platform pauses the webhook. The result block is byte-for-byte the same shape as GET /webhooks/{id}/health at as_of, so a receiver that already parses health-poll responses can reuse the same parser without a second API call to find out which counters tripped the auto-disable.

POST body JSON
{
  "ok": true,
  "event": "webhook.disabled",
  "result": {
    "webhook_id": "whk_01hx2f8q7n3tdvm5j",
    "active": false,
    "status": "disabled",
    "disabled_at": "2026-04-21T03:48:11Z",
    "disabled_reason": "auto_failure_rate",
    "window_seconds": 3600,
    "as_of": "2026-04-21T03:48:11Z",
    "delivery_count": 42,
    "success_count": 18,
    "failed_count": 3,
    "undeliverable_count": 21,
    "pending_count": 0,
    "failure_rate": 0.571,
    "auto_disable_threshold": 0.5,
    "consecutive_failures": 7,
    "last_success_at": "2026-04-21T03:11:08Z",
    "last_failure_at": "2026-04-21T03:47:54Z"
  },
  "meta": { "request_id": "req_01hx2g4n8p7rqzm3k", "model_version": "webhooks-v1.0.0" }
}
One delivery, one chance. webhook.disabled is fired on the final attempt before the webhook is paused β€” there is no eighth retry. If the receiver subscribed to the event is the same one that is failing, this notification will almost certainly fail too. Subscribe a separately-monitored receiver (different host, different on-call rotation) to webhook.disabled, and pair it with the health polling recipe as a backstop. The same result block is also available at any time via GET /webhooks/{id}/health.
POST /webhooks/{id}/test Send a synthetic event to a webhook to verify your receiver end-to-end

Generates a synthetic delivery for the registered webhook and POSTs it to the URL exactly as a real event would be sent β€” same envelope shape, same X-Geog-Signature HMAC, same retry rules. Designed for day-one verification of a brand-new receiver before any real job has run: confirm that the URL is reachable, your TLS certificate is valid, and your signature-verification code accepts the payload. Returns the resulting WebhookDelivery with test: true, and adds an X-Geog-Test: true header to the outbound POST so your handler can short-circuit production side-effects (alerts paged out, downstream writes, billing) for synthetic traffic.

The endpoint accepts an optional event in the body to choose which event type to simulate (job.complete by default). Test deliveries appear in GET /webhooks/{id}/deliveries alongside real ones (filter on test=true), are subject to the same retry schedule and auto-disable rules, and count against the per-webhook rate limit. They are throttled to 60 test deliveries per webhook per hour.

Want a copy-paste example? Recipe: verify your webhook in 30 seconds chains POST /webhooksPOST /webhooks/{id}/test → a tiny Flask / Express / net/http / Sinatra receiver that verifies X-Geog-Signature, in Python, Node, Go, and Ruby.
Path Parameters
ParameterTypeRequiredDescription
idstringRequiredWebhook identifier returned by POST /webhooks (prefix whk_)
Body Parameters
ParameterTypeRequiredDescription
eventstringOptionalEvent type to simulate. One of job.complete, job.failed, calibration.updated, webhook.disabled. Default: job.complete. The webhook must be subscribed to the chosen event type or the request returns 400 UNSUPPORTED_EVENT. Selecting webhook.disabled only generates a synthetic delivery β€” it does not actually disable the webhook.
Request HTTP
POST https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j/test
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "event": "job.complete"
}
Response β€” 202 Accepted
FieldTypeDescription
okbooleanAlways true on success
resultWebhookDeliveryThe synthetic delivery record. status starts as pending; poll GET /webhooks/{id}/deliveries to watch it transition to success or failed.
result.testbooleanAlways true for deliveries produced by this endpoint. Real job-driven deliveries report false.
result.job_idstringSynthetic job ID prefixed job_test_ so it cannot collide with a real job
meta.request_idstringUnique request identifier for support tickets
Response β€” 202 Accepted JSON
{
  "ok": true,
  "result": {
    "id": "whd_01hx2gtest9p7rqzm",
    "webhook_id": "whk_01hx2f8q7n3tdvm5j",
    "event": "job.complete",
    "job_id": "job_test_01hx2gtestmzn7p",
    "status": "pending",
    "test": true,
    "attempt_count": 1,
    "response_code": null,
    "response_body_excerpt": null,
    "error": null,
    "signature_timestamp": 1745213044,
    "created_at": "2026-04-21T03:44:04Z",
    "last_attempt_at": null,
    "next_attempt_at": "2026-04-21T03:44:04Z"
  },
  "meta": { "request_id": "req_01hx2gtest3q8wnzvr4" }
}
Distinguishing test deliveries on your receiver: the outbound POST carries X-Geog-Test: true in addition to the usual signature headers, and the in-envelope result.test field is also true. Either signal is safe to gate on; we recommend the header so you can decide before parsing the body.
Error Responses
StatusCodeDescription
400UNSUPPORTED_EVENTRequested event is not in the webhook's subscribed events list, or is not a recognised event type
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDWebhook does not exist or is owned by another account
409WEBHOOK_DISABLEDWebhook is currently inactive (active=false or auto-disabled after sustained failures); re-enable it on the dashboard before sending a test
429TEST_RATE_LIMITEDMore than 60 test deliveries have been requested for this webhook in the last hour
429RATE_LIMITEDAccount-level rate limit exceeded
Recipe: verify your webhook in 30 seconds Register → send a test event → verify the signature on receipt — one copy-paste loop

The three pieces above (register, POST /webhooks/{id}/test, and the signature verifier) are usually wired up together on day one. The snippets below stitch them into a single closed loop so you can prove your receiver works end-to-end before any real simulation job runs — and without waiting for one to fire.

How to use: run the receiver on a public HTTPS URL (use ngrok or similar in development), then run the registration script with that URL. The script registers the webhook, captures the signing secret, fires a synthetic job.complete via POST /webhooks/{id}/test, and your receiver logs signature OK within a second. If the signature fails, the receiver returns 400 and the delivery shows up as failed in GET /webhooks/{id}/deliveries — walk through Recipe: debug a failed delivery and replay it to fix the receiver and re-send without waiting for the next real job.

Test traffic is tagged. Every delivery produced by POST /webhooks/{id}/test carries X-Geog-Test: true and result.test == true. The receivers below short-circuit on that header so synthetic events never trigger production side-effects.

Python — register, send test, verify (Flask)

1. Register the webhook and fire a test event Python
import os, requests

API = "https://api.geog.ai/v1"
KEY = os.environ["GEOG_API_KEY"]  # geog_live_sk_...
RECEIVER = "https://your-ngrok-id.ngrok.app/hooks/geog"

# 1) Register β€” the secret is returned ONCE; store it before you do anything else
r = requests.post(f"{API}/webhooks",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"url": RECEIVER, "events": ["job.complete", "job.failed"]})
r.raise_for_status()
hook = r.json()["result"]
hook_id, secret = hook["id"], hook["secret"]
print("registered", hook_id)

# Persist `secret` in your secret manager β€” it is shown only once.
# Then export it where the receiver below can read it (GEOG_WEBHOOK_SECRET).

# 2) Fire a synthetic delivery β€” should land at your receiver in <1s
r = requests.post(f"{API}/webhooks/{hook_id}/test",
    headers={"Authorization": f"Bearer {KEY}"},
    json={"event": "job.complete"})
r.raise_for_status()
print("test queued", r.json()["result"]["id"])
2. Receive and verify Python Β· Flask
import hmac, hashlib, os, time
from flask import Flask, request, abort

SECRET = os.environ["GEOG_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

def verify(header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, sig = parts["t"], parts["v1"]
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(SECRET, f"{ts}.".encode() + raw_body, hashlib.sha256)
    return hmac.compare_digest(mac.hexdigest(), sig)

@app.post("/hooks/geog")
def receive():
    sig = request.headers.get("X-Geog-Signature", "")
    if not verify(sig, request.get_data()):
        abort(400, "bad signature")
    if request.headers.get("X-Geog-Test") == "true":
        print("signature OK (test)", request.headers["X-Geog-Delivery"])
        return "", 200  # skip production side-effects
    handle_job_result(request.get_json())  # your real handler
    return "", 200

Node — register, send test, verify (Express)

1. Register the webhook and fire a test event Node.js
const API = "https://api.geog.ai/v1";
const KEY = process.env.GEOG_API_KEY;  // geog_live_sk_...
const RECEIVER = "https://your-ngrok-id.ngrok.app/hooks/geog";

const auth = { "Authorization": `Bearer ${KEY}`, "Content-Type": "application/json" };

// 1) Register β€” the secret is returned ONCE; store it before you do anything else
const reg = await fetch(`${API}/webhooks`, {
  method: "POST", headers: auth,
  body: JSON.stringify({ url: RECEIVER, events: ["job.complete", "job.failed"] })
}).then(r => r.json());
const { id: hookId, secret } = reg.result;
console.log("registered", hookId);

// Persist `secret` in your secret manager β€” it is shown only once.
// Then export it where the receiver below can read it (GEOG_WEBHOOK_SECRET).

// 2) Fire a synthetic delivery β€” should land at your receiver in <1s
const test = await fetch(`${API}/webhooks/${hookId}/test`, {
  method: "POST", headers: auth,
  body: JSON.stringify({ event: "job.complete" })
}).then(r => r.json());
console.log("test queued", test.result.id);
2. Receive and verify Node.js Β· Express
import express from "express";
import crypto from "node:crypto";

const SECRET = process.env.GEOG_WEBHOOK_SECRET;
const app = express();

function verify(header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const { t: ts, v1: sig } = parts;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const mac = crypto.createHmac("sha256", SECRET)
    .update(`${ts}.`).update(rawBody).digest("hex");
  try { return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig)); }
  catch { return false; }
}

app.post("/hooks/geog",
  express.raw({ type: "application/json" }),  // keep raw bytes for HMAC
  (req, res) => {
    const sig = req.header("X-Geog-Signature") ?? "";
    if (!verify(sig, req.body)) return res.status(400).send("bad signature");
    if (req.header("X-Geog-Test") === "true") {
      console.log("signature OK (test)", req.header("X-Geog-Delivery"));
      return res.sendStatus(200);  // skip production side-effects
    }
    handleJobResult(JSON.parse(req.body.toString("utf8")));
    res.sendStatus(200);
  });

app.listen(3000);

Go — register, send test, verify (net/http)

1. Register the webhook and fire a test event Go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
)

const (
    api = "https://api.geog.ai/v1"
    receiver = "https://your-ngrok-id.ngrok.app/hooks/geog"
)

func post(path string, body any) map[string]any {
    buf, _ := json.Marshal(body)
    req, _ := http.NewRequest("POST", api+path, bytes.NewReader(buf))
    req.Header.Set("Authorization", "Bearer "+os.Getenv("GEOG_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(req)
    if err != nil { panic(err) }
    defer resp.Body.Close()
    var out map[string]any
    json.NewDecoder(resp.Body).Decode(&out)
    return out
}

func main() {
    // 1) Register β€” the secret is returned ONCE; store it before you do anything else
    reg := post("/webhooks", map[string]any{
        "url": receiver,
        "events": []string{"job.complete", "job.failed"},
    })
    hook := reg["result"].(map[string]any)
    hookID, secret := hook["id"].(string), hook["secret"].(string)
    fmt.Println("registered", hookID)
    _ = secret // persist in your secret manager β€” shown only once

    // 2) Fire a synthetic delivery β€” should land at your receiver in <1s
    test := post("/webhooks/"+hookID+"/test", map[string]any{"event": "job.complete"})
    fmt.Println("test queued", test["result"].(map[string]any)["id"])
}
2. Receive and verify Go Β· net/http
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var secret = []byte(os.Getenv("GEOG_WEBHOOK_SECRET"))

func verify(header string, rawBody []byte) bool {
    parts := map[string]string{}
    for _, p := range strings.Split(header, ",") {
        if kv := strings.SplitN(p, "=", 2); len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }
    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil || abs(time.Now().Unix()-ts) > 300 {
        return false
    }
    mac := hmac.New(sha256.New, secret)
    fmt.Fprintf(mac, "%d.", ts)
    mac.Write(rawBody)
    expected, _ := hex.DecodeString(parts["v1"])
    return hmac.Equal(mac.Sum(nil), expected)
}

func abs(n int64) int64 { if n < 0 { return -n }; return n }

func receive(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)  // keep raw bytes for HMAC
    if !verify(r.Header.Get("X-Geog-Signature"), raw) {
        http.Error(w, "bad signature", 400)
        return
    }
    if r.Header.Get("X-Geog-Test") == "true" {
        fmt.Println("signature OK (test)", r.Header.Get("X-Geog-Delivery"))
        w.WriteHeader(200)  // skip production side-effects
        return
    }
    handleJobResult(raw)  // your real handler
    w.WriteHeader(200)
}

func main() {
    http.HandleFunc("/hooks/geog", receive)
    http.ListenAndServe(":3000", nil)
}

Ruby — register, send test, verify (Sinatra)

1. Register the webhook and fire a test event Ruby
require "net/http"
require "json"
require "uri"

API = "https://api.geog.ai/v1"
KEY = ENV.fetch("GEOG_API_KEY")  # geog_live_sk_...
RECEIVER = "https://your-ngrok-id.ngrok.app/hooks/geog"

def post(path, body)
  uri = URI("#{API}#{path}")
  req = Net::HTTP::Post.new(uri,
    "Authorization" => "Bearer #{KEY}",
    "Content-Type"  => "application/json")
  req.body = body.to_json
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  raise res.body unless res.is_a?(Net::HTTPSuccess)
  JSON.parse(res.body)
end

# 1) Register β€” the secret is returned ONCE; store it before you do anything else
hook = post("/webhooks", url: RECEIVER, events: %w[job.complete job.failed])["result"]
hook_id, secret = hook["id"], hook["secret"]
puts "registered", hook_id

# Persist `secret` in your secret manager β€” it is shown only once.
# Then export it where the receiver below can read it (GEOG_WEBHOOK_SECRET).

# 2) Fire a synthetic delivery β€” should land at your receiver in <1s
test = post("/webhooks/#{hook_id}/test", event: "job.complete")
puts "test queued", test["result"]["id"]
2. Receive and verify Ruby Β· Sinatra
require "sinatra"
require "openssl"
require "json"

SECRET = ENV.fetch("GEOG_WEBHOOK_SECRET")

def verify(header, raw_body)
  parts = header.split(",").map { |p| p.split("=", 2) }.to_h
  ts, sig = parts["t"], parts["v1"]
  return false if ts.nil? || sig.nil?
  return false if (Time.now.to_i - ts.to_i).abs > 300
  mac = OpenSSL::HMAC.hexdigest("sha256", SECRET, "#{ts}.#{raw_body}")
  OpenSSL.secure_compare(mac, sig)
end

post "/hooks/geog" do
  raw = request.body.read  # keep raw bytes for HMAC
  halt 400, "bad signature" unless verify(request.env["HTTP_X_GEOG_SIGNATURE"].to_s, raw)
  if request.env["HTTP_X_GEOG_TEST"] == "true"
    puts "signature OK (test) #{request.env['HTTP_X_GEOG_DELIVERY']}"
    halt 200  # skip production side-effects
  end
  handle_job_result(JSON.parse(raw))  # your real handler
  200
end
Expected output: the receiver prints signature OK (test) whd_... within a second of step 1 finishing, and the delivery shows up as status: success, test: true in GET /webhooks/{id}/deliveries. If you see bad signature, double-check that you stored the secret from the registration response (it is shown only once) and that your receiver reads the raw request body β€” JSON-parsing before HMAC will silently corrupt the digest.
GET /webhooks/{id}/deliveries List recent deliveries for a webhook (paginated, newest first)

Returns a paginated list of delivery attempts for the given webhook, newest first. Mirrors the delivery log shown on the dashboard and is intended for debugging receiver-side bugs without waiting for the next real job. Deliveries are retained for 30 days; older records return an empty page rather than an error. Combine with the status=failed filter to find candidates for replay after fixing your handler.

Path Parameters
ParameterTypeRequiredDescription
idstringRequiredWebhook identifier returned by POST /webhooks (prefix whk_)
Query Parameters
ParameterTypeRequiredDescription
statusstringOptionalFilter to a single status. Options: pending, success, failed, undeliverable. Default: all.
eventstringOptionalFilter to a single event type: job.complete, job.failed, calibration.updated, webhook.disabled
testbooleanOptionalFilter by delivery origin. true returns only synthetic deliveries produced by POST /webhooks/{id}/test; false returns only real job-driven deliveries. Default: omitted β€” both kinds are returned. Composes with status and event using AND semantics (e.g. ?test=true&status=failed returns only failed test deliveries).
created_afterstring (ISO 8601)OptionalInclusive lower bound on created_at. Only deliveries first queued at or after this UTC timestamp are returned (e.g. 2026-04-21T03:00:00Z). Composes with status, event, and test using AND semantics β€” for example, ?status=failed&created_after=2026-04-21T02:00:00Z&created_before=2026-04-21T03:00:00Z returns only failed deliveries queued during that one-hour window. Bounded by the 30-day retention window.
created_beforestring (ISO 8601)OptionalExclusive upper bound on created_at. Only deliveries first queued strictly before this UTC timestamp are returned. May be combined with created_after to define a closed-open [after, before) range, or used alone to scope to "everything older than X". Like the other filters, composes with status, event, and test using AND semantics.
limitintegerOptionalPage size, 1–100. Default: 25
cursorstringOptionalOpaque pagination cursor returned as meta.next_cursor on the previous page. Omit for the first page.
Request HTTP
GET https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j/deliveries?status=failed&limit=25
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” 200 OK
FieldTypeDescription
okbooleanAlways true on success
resultWebhookDelivery[]Page of delivery records, newest first
result[].idstringDelivery ID (prefix whd_) β€” same value sent in the X-Geog-Delivery header
result[].webhook_idstringID of the registered webhook that produced this delivery
result[].eventstringEvent type: job.complete, job.failed, calibration.updated, or webhook.disabled
result[].job_idstring | nullSource job ID for job.* events; null otherwise
result[].statusstringpending (queued / in-flight), success (2xx received), failed (non-2xx with another retry scheduled), or undeliverable (all 7 attempts exhausted)
result[].attempt_countintegerNumber of attempts made so far (1–7, including the original send)
result[].response_codeinteger | nullHTTP status code returned by the receiver on the most recent attempt; null for transport failures
result[].response_msinteger | nullRound-trip time in milliseconds from request send to response received on the most recent attempt; null for transport failures or while still pending
result[].response_body_excerptstring | nullFirst 512 bytes of the receiver's response body on the most recent attempt
result[].errorstring | nullTransport-level error if the receiver could not be reached (e.g. connection_timeout, dns_failure, tls_handshake_failed)
result[].signature_timestampintegerUnix timestamp (seconds) used to build the original X-Geog-Signature. Stable across retries and replays.
result[].created_atstring (ISO 8601)When the delivery was first queued
result[].last_attempt_atstring | nullTimestamp of the most recent attempt; null while still pending with no attempt yet
result[].delivered_atstring | nullTimestamp of the first successful (2xx) attempt; null until status becomes success. Stable across replays.
result[].next_attempt_atstring | nullScheduled time of the next retry; null when status is success or undeliverable
meta.next_cursorstring | nullOpaque cursor for the next page; null when there are no more results
meta.request_idstringUnique request identifier for support tickets
Response β€” 200 OK JSON
{
  "ok": true,
  "result": [
    {
      "id": "whd_01hx2g4n8p7rqzm3k",
      "webhook_id": "whk_01hx2f8q7n3tdvm5j",
      "event": "job.complete",
      "job_id": "job_01hx2a3f9k8qmzn7p",
      "status": "failed",
      "attempt_count": 3,
      "response_code": 502,
      "response_ms": 482,
      "response_body_excerpt": "<html><head><title>502 Bad Gateway</title>...",
      "error": null,
      "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_01hx2g5p3q8wnzvr4",
    "next_cursor": "eyJ0IjoxNzQ1MjEwMDAwLCJpZCI6IndoZF8wMWh4MmcxIn0"
  }
}
Error Responses
StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDWebhook does not exist or is owned by another account
429RATE_LIMITEDRate limit exceeded
GET /webhooks/{id}/deliveries.csv Export the same filtered deliveries as a CSV stream

Returns the same filtered, paginated rows as GET /webhooks/{id}/deliveries, serialised as a CSV stream rather than a JSON envelope. Intended for one-off ops investigations: pipe the response into a file, hand it to a teammate, or paste it into a spreadsheet without scripting against the JSON endpoint.

The response uses Content-Type: text/csv; charset=utf-8 and is sent with Content-Disposition: attachment; filename="deliveries-<webhook_id>.csv" so browsers and curl -OJ save it directly to disk.

Path Parameters
ParameterTypeRequiredDescription
idstringRequiredWebhook identifier returned by POST /webhooks (prefix whk_)
Query Parameters

All filters behave identically to the JSON endpoint and compose with AND semantics β€” for example, ?status=failed&test=false&created_after=2026-04-21T02:00:00Z&created_before=2026-04-21T03:00:00Z exports only failed real (non-test) deliveries queued during that one-hour window, the slice you typically want to attach to an outage post-mortem.

ParameterTypeRequiredDescription
statusstringOptionalFilter to a single status. Options: pending, success, failed, undeliverable. Default: all.
eventstringOptionalFilter to a single event type: job.complete, job.failed, calibration.updated, webhook.disabled
testbooleanOptionalFilter by delivery origin. true returns only synthetic deliveries produced by POST /webhooks/{id}/test; false returns only real job-driven deliveries. Default: omitted β€” both kinds are returned.
created_afterstring (ISO 8601)OptionalInclusive lower bound on created_at (UTC). Bounded by the 30-day retention window.
created_beforestring (ISO 8601)OptionalExclusive upper bound on created_at (UTC). Combine with created_after for a closed-open [after, before) range.
limitintegerOptionalPage size, 1–100. Default: 25. Identical bounds to the JSON endpoint.
cursorstringOptionalOpaque pagination cursor returned in the X-Next-Cursor response header on the previous page. Omit for the first page. Cursor format is interchangeable with the JSON endpoint's meta.next_cursor.
Request HTTP
GET https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j/deliveries.csv?status=failed&created_after=2026-04-21T02:00:00Z&created_before=2026-04-21T03:00:00Z
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Accept: text/csv
Response β€” 200 OK

Body is RFC 4180 CSV with a fixed header row, newest-first, identical to the JSON ordering. Columns, in order:

ColumnMaps to JSON fieldNotes
idresult[].idDelivery ID (prefix whd_)
webhook_idresult[].webhook_idID of the registered webhook
eventresult[].eventEvent type
job_idresult[].job_idSource job ID for job.* events; empty for non-job events
statusresult[].statuspending, success, failed, or undeliverable
attempt_countresult[].attempt_countInteger 1–7
response_coderesult[].response_codeHTTP status; empty for transport failures
response_msresult[].response_msRound-trip ms; empty for transport failures or while still pending
response_body_excerptresult[].response_body_excerptFirst 512 bytes of the receiver's response. Often contains commas, quotes, or newlines and will be quoted per RFC 4180.
errorresult[].errorTransport-level error string; empty when an HTTP response was received
signature_timestampresult[].signature_timestampUnix timestamp (seconds), stable across retries and replays
created_atresult[].created_atISO 8601 UTC; when the delivery was first queued
last_attempt_atresult[].last_attempt_atISO 8601 UTC; empty while still pending with no attempt yet
delivered_atresult[].delivered_atISO 8601 UTC; empty until status becomes success
next_attempt_atresult[].next_attempt_atISO 8601 UTC; empty when status is success or undeliverable
testresult[].testtrue / false

Encoding rules: RFC 4180 β€” fields containing commas, quotes, or newlines are wrapped in double quotes and embedded double quotes are doubled (" β†’ ""). JSON null values are emitted as empty fields, not the literal string null. Line terminator is \r\n. Character set is UTF-8.

Response Headers
HeaderDescription
Content-TypeAlways text/csv; charset=utf-8
Content-DispositionAlways attachment; filename="deliveries-<webhook_id>.csv"
X-Next-CursorOpaque cursor for the next page. Omitted (header absent) when there are no more results. Pass back as the cursor query parameter to fetch the next page.
X-Request-IdUnique request identifier for support tickets. Equivalent to meta.request_id on the JSON endpoint.
Response β€” 200 OK CSV
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="deliveries-whk_01hx2f8q7n3tdvm5j.csv"
X-Next-Cursor: eyJ0IjoxNzQ1MjEwMDAwLCJpZCI6IndoZF8wMWh4MmcxIn0
X-Request-Id: req_01hx2g5p3q8wnzvr4

id,webhook_id,event,job_id,status,attempt_count,response_code,response_ms,response_body_excerpt,error,signature_timestamp,created_at,last_attempt_at,delivered_at,next_attempt_at,test
whd_01hx2g4n8p7rqzm3k,whk_01hx2f8q7n3tdvm5j,job.complete,job_01hx2a3f9k8qmzn7p,failed,3,502,482,"<html><head><title>502 Bad Gateway</title>...",,1745211262,2026-04-21T03:14:22Z,2026-04-21T03:24:51Z,,2026-04-21T03:34:51Z,false
whd_01hx2g3p7m4qzn5j,whk_01hx2f8q7n3tdvm5j,job.complete,job_01hx2a2c8j7pkym6n,failed,2,,,,connection_timeout,1745210958,2026-04-21T03:09:18Z,2026-04-21T03:19:42Z,,2026-04-21T03:29:42Z,false
Paginating through every match

CSV bodies cannot carry a JSON meta object, so the next-page cursor is exposed in the X-Next-Cursor response header. To export every matching delivery, follow the cursor and concatenate the body of each page (skipping the header row on pages 2+):

Export every matching row bash
#!/usr/bin/env bash
# Stream every failed delivery in a one-hour window into one CSV file.

ID="whk_01hx2f8q7n3tdvm5j"
QS="status=failed&created_after=2026-04-21T02:00:00Z&created_before=2026-04-21T03:00:00Z&limit=100"
OUT="deliveries.csv"
cursor=""
first=1
> "$OUT"

while :; do
  url="https://api.geog.ai/v1/webhooks/$ID/deliveries.csv?$QS"
  [ -n "$cursor" ] && url="$url&cursor=$cursor"

  # -D - dumps headers to stdout so we can grep X-Next-Cursor.
  resp=$(curl -sS -D /tmp/h.$$ -H "Authorization: Bearer $GEOG_KEY" "$url")

  if [ "$first" -eq 1 ]; then
    printf '%s' "$resp" >> "$OUT"
    first=0
  else
    # Drop the header row on subsequent pages.
    printf '%s' "$resp" | tail -n +2 >> "$OUT"
  fi

  cursor=$(awk 'BEGIN{IGNORECASE=1} /^x-next-cursor:/ {print $2}' /tmp/h.$$ | tr -d '\r')
  rm -f /tmp/h.$$
  [ -z "$cursor" ] && break
done
Error Responses

Errors are returned as the standard JSON error envelope (not CSV) so existing client error handling keeps working.

StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDWebhook does not exist or is owned by another account
429RATE_LIMITEDRate limit exceeded
POST /webhooks/{id}/deliveries/{delivery_id}/replay Re-send a previously attempted webhook delivery

Re-queues a previously attempted delivery for immediate re-send to the webhook's currently registered URL. Useful after fixing a receiver-side bug β€” there is no need to wait for the next real job. Returns a 202 Accepted with the updated WebhookDelivery reflecting its new pending state. The replay attempt counts toward the webhook's retry budget and increments attempt_count.

Replays use the original payload and signature timestamp. The exact bytes that were originally signed are re-sent, and the original X-Geog-Signature (including its t= value) and X-Geog-Delivery ID are reused β€” HMAC verification on your receiver behaves identically to the original delivery, and idempotent receivers keyed on X-Geog-Delivery deduplicate naturally (see Recipe: idempotent receiver for a copy-pasteable Python + Node implementation). Note that the standard 5-minute replay-window check (|now - t| > 300) will reject the request if the original signature timestamp is older than 5 minutes; receivers that need to accept replays of older deliveries should relax this check for requests carrying a known X-Geog-Delivery ID.
Path Parameters
ParameterTypeRequiredDescription
idstringRequiredWebhook identifier (prefix whk_)
delivery_idstringRequiredDelivery identifier returned by GET /webhooks/{id}/deliveries (prefix whd_)
Request HTTP
POST https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j/deliveries/whd_01hx2g4n8p7rqzm3k/replay
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” 202 Accepted
FieldTypeDescription
okbooleanAlways true on success
resultWebhookDeliveryUpdated delivery record. status is reset to pending, attempt_count reflects the new attempt being queued, and signature_timestamp is unchanged from the original delivery.
meta.request_idstringUnique request identifier for support tickets
Response β€” 202 Accepted JSON
{
  "ok": true,
  "result": {
    "id": "whd_01hx2g4n8p7rqzm3k",  // unchanged β€” same X-Geog-Delivery
    "webhook_id": "whk_01hx2f8q7n3tdvm5j",
    "event": "job.complete",
    "job_id": "job_01hx2a3f9k8qmzn7p",
    "status": "pending",
    "attempt_count": 4,
    "response_code": null,
    "response_ms": null,
    "response_body_excerpt": null,
    "error": null,
    "signature_timestamp": 1745211262,  // unchanged β€” same X-Geog-Signature t=
    "created_at": "2026-04-21T03:14:22Z",
    "last_attempt_at": "2026-04-21T03:24:51Z",
    "delivered_at": null,
    "next_attempt_at": "2026-04-21T04:02:18Z"
  },
  "meta": { "request_id": "req_01hx2g7v4r9xpqzm8" }
}
Error Responses
StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDWebhook does not exist or is owned by another account
404DELIVERY_NOT_FOUNDDelivery does not belong to this webhook or has aged past the 30-day retention window
409DELIVERY_ALREADY_PENDINGDelivery is already queued or in-flight; wait for it to settle before replaying
409WEBHOOK_DISABLEDWebhook is currently inactive (active=false or auto-disabled after sustained failures); re-enable it on the dashboard before replaying
429RATE_LIMITEDRate limit exceeded
GET /webhooks/{id}/health Read the rolling-hour delivery health (and auto-disable state) for a webhook

Returns the same rolling-window failure-rate counters that drive the platform's auto-disable rule, plus the current active / disabled_reason state. Use this endpoint to detect β€” programmatically, without waiting for the account owner's email β€” when a webhook has been auto-disabled after sustained delivery failures (β‰₯50% failed deliveries in the last hour), or is trending toward it.

How auto-disable works. Once a minute the platform recomputes failure_rate = (failed_count + undeliverable_count) / max(1, delivery_count - pending_count) over the last hour for each active webhook. If that value is β‰₯ auto_disable_threshold (currently 0.5) and the resolved sample is β‰₯ 4 deliveries, the webhook is paused: active flips to false, disabled_at is stamped, disabled_reason is set to auto_failure_rate, an email is sent to the account owner, and (if the webhook is subscribed) a final webhook.disabled event delivery is enqueued. This endpoint exposes those exact counters so you can act on them yourself.
Path Parameters
ParameterTypeRequiredDescription
idstringRequiredWebhook identifier returned by POST /webhooks (prefix whk_)
Request HTTP
GET https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j/health
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” 200 OK
FieldTypeDescription
okbooleanAlways true on success
result.webhook_idstringWebhook identifier (prefix whk_)
result.activebooleanCurrent active state of the webhook. Mirrors Webhook.active.
result.statusstringQuick categorical summary: healthy (active and failure_rate < 0.25), degraded (active and 0.25 ≀ failure_rate < auto_disable_threshold β€” heading for trouble), or disabled (active=false; inspect disabled_reason)
result.disabled_atstring | nullWhen the webhook was last paused. null while active=true.
result.disabled_reasonstring | nullmanual (owner set active=false) or auto_failure_rate (platform auto-disabled after sustained failures). null while active=true.
result.window_secondsintegerLength of the rolling window the counters cover. Currently fixed at 3600 (1 hour).
result.as_ofstring (ISO 8601)Trailing edge of the window β€” "now" on the platform side
result.delivery_countintegerTotal logical deliveries (success + failed + undeliverable + pending) in the window. Counters are per logical delivery, not per retry attempt.
result.success_countintegerLogical deliveries that received a 2xx within 10 s on some attempt
result.failed_countintegerLogical deliveries whose latest attempt was non-2xx, timed out, or connection-refused, with another retry still scheduled
result.undeliverable_countintegerLogical deliveries that exhausted all 7 retries in the window without ever returning 2xx
result.pending_countintegerLogical deliveries currently pending (queued or in-flight)
result.failure_ratenumber (0–1)(failed_count + undeliverable_count) / max(1, delivery_count - pending_count) β€” the exact value compared to auto_disable_threshold on each evaluation tick
result.auto_disable_thresholdnumberFailure rate at or above which the platform auto-disables the webhook on the next evaluation tick (currently 0.5). Small samples (resolved deliveries < 4) are ignored to avoid flapping.
result.consecutive_failuresintegerNumber of consecutive deliveries (newest-first) whose latest attempt is failed or undeliverable. Resets to 0 on the next success.
result.last_success_atstring | nullTimestamp of the most recent successful delivery in the window; null if no delivery has succeeded in the last hour
result.last_failure_atstring | nullTimestamp of the most recent failed or undeliverable delivery in the window; null if every delivery succeeded
meta.request_idstringUnique request identifier for support tickets
Response β€” 200 OK JSON
{
  "ok": true,
  "result": {
    "webhook_id": "whk_01hx2f8q7n3tdvm5j",
    "active": false,
    "status": "disabled",
    "disabled_at": "2026-04-21T03:48:11Z",
    "disabled_reason": "auto_failure_rate",
    "window_seconds": 3600,
    "as_of": "2026-04-21T03:48:11Z",
    "delivery_count": 42,
    "success_count": 18,
    "failed_count": 3,
    "undeliverable_count": 21,
    "pending_count": 0,
    "failure_rate": 0.571,
    "auto_disable_threshold": 0.5,
    "consecutive_failures": 7,
    "last_success_at": "2026-04-21T03:11:08Z",
    "last_failure_at": "2026-04-21T03:47:54Z"
  },
  "meta": { "request_id": "req_01hx2g9w5s3xqzm9p" }
}
Recommended polling. Once a minute is enough β€” the platform itself re-evaluates the auto-disable rule on the same cadence, so polling faster gives no extra signal. Treat status == "degraded" as a warning (page the receiver's owner; the failure rate has crossed 25%) and status == "disabled" with disabled_reason == "auto_failure_rate" as a hard incident: the webhook will not deliver again until you re-enable it via PATCH /webhooks/{id} with {"active": true}.
Error Responses
StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDWebhook does not exist or is owned by another account
429RATE_LIMITEDAccount-level rate limit exceeded
Recipe: poll webhook health and alert on degraded delivery Page on status == "degraded" and treat auto_failure_rate auto-disable as a hard incident

The reference for GET /webhooks/{id}/health and the webhook.disabled event tell you what the platform exposes; this recipe is the copy-pasteable monitoring loop you actually deploy. It pairs two complementary patterns so you find out about a sick receiver the moment the platform does, regardless of whether the receiver itself is still reachable:

(A) The polling guard β€” a small job that runs in your monitoring service (not on the receiver) and pulls GET /webhooks/{id}/health once a minute. It pages the receiver's owner as soon as status flips to degraded (failure rate β‰₯ 25%, still active) and opens a hard incident the instant status == "disabled" with disabled_reason == "auto_failure_rate" appears. disabled_reason == "manual" is treated as benign β€” someone turned it off on purpose.

(B) The inbound webhook.disabled handler β€” a separately-deployed receiver (different host, different on-call rotation) subscribed to the webhook.disabled event. The platform fires this event on the final attempt before pausing the original webhook, so a receiver that is failing for its own deploy reasons cannot also swallow the notification that it is failing. Pattern (A) is the source of truth (it works even if pattern (B) misses a delivery); pattern (B) closes the gap between two polls and is what wakes you up at 3am.

Why both? Polling alone is bounded by your poll interval β€” a webhook auto-disabled at 03:00:30 is invisible until the 03:01:00 tick. The inbound event closes that window. The inbound event alone is fragile β€” if it lands on the same broken receiver that is being auto-disabled, you'll never see it. Run both, and treat (A) as authoritative when they disagree.
A. Polling guard (run on your monitoring service, not the receiver) Python
"""
Poll GET /webhooks/{id}/health once a minute and route alerts:
status == "healthy" β†’ silent
status == "degraded" β†’ page receiver owner (warning, recovers on its own if
the receiver comes back below 25%)
status == "disabled" + disabled_reason == "auto_failure_rate"
β†’ hard incident; the webhook will NOT deliver again
until you PATCH /webhooks/{id} {"active": true}
status == "disabled" + disabled_reason == "manual"
β†’ silent (someone turned it off on purpose)
"""
import os, time, requests

API = "https://api.geog.ai/v1"
KEY = os.environ["GEOG_API_KEY"]
HOOK = "whk_01hx2f8q7n3tdvm5j"  # the production webhook you are guarding
AUTH = {"Authorization": f"Bearer {KEY}"}

def page_oncall(severity, msg):
    # Wire to PagerDuty / Opsgenie / your incident tool of choice.
    print(f"[{severity}] {msg}")

def check_once():
    r = requests.get(f"{API}/webhooks/{HOOK}/health", headers=AUTH, timeout=10)
    r.raise_for_status()
    h = r.json()["result"]

    if h["status"] == "degraded":
        page_oncall("warning",
            f"webhook {HOOK} degraded: failure_rate="
            f"{h['failure_rate']:.0%} ({h['failed_count']}+"
            f"{h['undeliverable_count']}/{h['delivery_count']}); "
            f"auto-disable threshold {h['auto_disable_threshold']:.0%}; "
            f"last success {h['last_success_at']}")
        return

    if h["status"] == "disabled" and h["disabled_reason"] == "auto_failure_rate":
        page_oncall("critical",
            f"webhook {HOOK} AUTO-DISABLED at {h['disabled_at']} "
            f"(failure_rate={h['failure_rate']:.0%}, "
            f"consecutive_failures={h['consecutive_failures']}). "
            f"No deliveries until you re-enable via "
            f"PATCH /webhooks/{HOOK} {{\"active\": true}}.")
        return

    # status == "disabled" + reason == "manual": someone paused it on purpose.
    # status == "healthy": nothing to do.

if __name__ == "__main__":
    while True:
        try:
            check_once()
        except Exception as e:
            # Don't page on transient 5xx/timeout from the health endpoint itself β€”
            # a multi-tick outage will, via your monitoring tool's own staleness check.
            print(f"health poll failed: {e}")
        time.sleep(60)  # once a minute matches the platform's own evaluation tick
B. Inbound webhook.disabled handler (on a separately-monitored receiver) Python Β· Flask
# Deploy this receiver on a DIFFERENT host than the production webhook it
# watches β€” typically your monitoring/alerting service. Register it with:
#
# POST /webhooks { "url": "https://alerts.acme.com/hooks/geog-disabled",
# "events": ["webhook.disabled"] }
#
# If the production receiver is the one being auto-disabled, this one is still
# up and pages you. Use the SAME HMAC verifier as the verify-recipe; the
# signing secret is the secret of THIS webhook (the alerts one), not the
# production webhook whose disable is being announced.

@app.post("/hooks/geog-disabled")
def on_webhook_disabled():
    sig = request.headers.get("X-Geog-Signature", "")
    if not verify(sig, request.get_data()):
        abort(400, "bad signature")

    # X-Geog-Event tells us what fired. We only ever subscribe this receiver
    # to webhook.disabled, but assert it explicitly so a misconfigured
    # subscription doesn't silently page on the wrong event.
    if request.headers.get("X-Geog-Event") != "webhook.disabled":
        return "", 200  # ack so the platform stops retrying, but ignore

    result = request.get_json()["result"]
    # result.disabled_reason is "auto_failure_rate" β€” the only reason the
    # platform fires this event. (manual disables don't emit it.)
    page_oncall("critical",
        f"webhook {result['id']} ({result['url']}) auto-disabled at "
        f"{result['disabled_at']} (reason={result['disabled_reason']}). "
        f"Re-enable with PATCH /webhooks/{result['id']} {{\"active\": true}}.")
    return "", 200
Expected output: on a healthy webhook the polling loop emits no pages; the moment the rolling-hour failure rate crosses 25% you get a single warning page that auto-resolves once the receiver recovers below the threshold; the moment it crosses 50% (the auto_disable_threshold) the platform pauses the webhook, fires one webhook.disabled delivery to the alerts receiver, and the very next polling tick on the guard also flips to critical — so the on-call engineer sees the same incident from two independent sources within ~60 seconds.
Don't subscribe the production receiver to its own webhook.disabled event. The platform fires it on the final attempt before the webhook is paused, so it is delivered to the same URL that just failed seven times in a row — almost always with the same outcome. That is why pattern (B) above lives on a separate receiver. Pattern (A) (the polling guard) is the authoritative signal; the inbound event is the sub-minute booster. Also: POST /webhooks/{id}/test with event=webhook.disabled generates a synthetic delivery you can use to verify pattern (B) end-to-end without actually disabling anything.
Recipe: debug a failed delivery and replay it Induce a 500 → find it in the delivery log → fix the receiver → replay → see success

Once Recipe: verify your webhook in 30 seconds reports signature OK, the next thing you will hit in production is a failed delivery — a missing field, a 500 from your handler, a deploy that broke the route. This recipe walks through that full debug loop end-to-end using GET /webhooks/{id}/deliveries and POST /webhooks/{id}/deliveries/{delivery_id}/replay, so you can fix and re-send a real failure without waiting for the next simulation job to fire.

The worked example: the receiver from the verify recipe is shipped with a one-line bug — it calls result["bbox"], but job.complete payloads use bbox_4326. The first synthetic delivery returns 500 Internal Server Error, the platform retries with backoff and the delivery moves to status: failed. We list it, fix the field name, redeploy, replay the same whd_, and watch it land as success.

Replays reuse the original payload, signature, and delivery ID. The bytes the platform originally signed are re-sent unchanged, the original X-Geog-Signature (including its t=) and X-Geog-Delivery are reused, and attempt_count increments. Idempotent receivers keyed on X-Geog-Delivery deduplicate naturally — see Recipe: idempotent receiver for a Python + Node implementation that uses X-Geog-Delivery as the dedupe key, so a replay of an already-archived event acks with 200 instead of double-writing. If the original signature timestamp is older than 5 minutes, the standard replay-window check on your receiver will reject it — see the note on the replay endpoint for how to relax that check for known delivery IDs.

Python — the buggy receiver, the fix, then debug + replay

1. The buggy receiver returning 500 Python Β· Flask
# Same Flask receiver as the verify recipe, with one bug: wrong field name.
@app.post("/hooks/geog")
def receive():
    sig = request.headers.get("X-Geog-Signature", "")
    if not verify(sig, request.get_data()):
        abort(400, "bad signature")
    payload = request.get_json()["result"]
    bbox = payload["bbox"]  # πŸ’₯ KeyError β€” the real field is `bbox_4326`
    archive_to_s3(bbox, payload)
    return "", 200
2. List failed deliveries to confirm the failure Python
import os, requests

API = "https://api.geog.ai/v1"
KEY = os.environ["GEOG_API_KEY"]
HOOK = "whk_01hx2f8q7n3tdvm5j"  # from POST /webhooks earlier

r = requests.get(f"{API}/webhooks/{HOOK}/deliveries",
    headers={"Authorization": f"Bearer {KEY}"},
    params={"status": "failed", "limit": 5})
r.raise_for_status()
for d in r.json()["result"]:
    print(d["id"], d["response_code"], d["attempt_count"], d["response_body_excerpt"][:80])

# whd_01hx2g4n8p7rqzm3k 500 3 KeyError: 'bbox' ... File "/app/hooks.py", line 41, in receive
3. Fix the receiver and redeploy Python Β· Flask
# One-line fix: use the documented field name.
bbox = payload["bbox_4326"]  # βœ… matches the job.complete schema
4. Replay the same delivery Python
DELIVERY = "whd_01hx2g4n8p7rqzm3k"  # from step 2

r = requests.post(f"{API}/webhooks/{HOOK}/deliveries/{DELIVERY}/replay",
    headers={"Authorization": f"Bearer {KEY}"})
r.raise_for_status()
print("replay queued", r.json()["result"]["status"])  # pending

# Poll until it settles (success or failed again). Backoff in real code.
import time
for _ in range(10):
    time.sleep(1)
    d = requests.get(f"{API}/webhooks/{HOOK}/deliveries",
        headers={"Authorization": f"Bearer {KEY}"},
        params={"limit": 1}).json()["result"][0]
    if d["id"] == DELIVERY and d["status"] != "pending":
        print(d["status"], d["response_code"], "attempt", d["attempt_count"])
        break

# success 200 attempt 4

Node — the buggy receiver, the fix, then debug + replay

1. The buggy receiver returning 500 Node.js Β· Express
// Same Express receiver as the verify recipe, with one bug: wrong field name.
app.post("/hooks/geog",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.header("X-Geog-Signature") ?? "";
    if (!verify(sig, req.body)) return res.status(400).send("bad signature");
    const { result } = JSON.parse(req.body.toString("utf8"));
    const bbox = result.bbox;  // πŸ’₯ undefined β€” the real field is `bbox_4326`
    archiveToS3(bbox, result);  // throws β†’ Express returns 500
    res.sendStatus(200);
  });
2. List failed deliveries to confirm the failure Node.js
const API = "https://api.geog.ai/v1";
const KEY = process.env.GEOG_API_KEY;
const HOOK = "whk_01hx2f8q7n3tdvm5j";  // from POST /webhooks earlier
const auth = { "Authorization": `Bearer ${KEY}` };

const page = await fetch(
  `${API}/webhooks/${HOOK}/deliveries?status=failed&limit=5`,
  { headers: auth }
).then(r => r.json());

for (const d of page.result) {
  console.log(d.id, d.response_code, d.attempt_count, d.response_body_excerpt?.slice(0, 80));
}

// whd_01hx2g4n8p7rqzm3k 500 3 TypeError: Cannot read properties of undefined (reading 'minLon')
3. Fix the receiver and redeploy Node.js Β· Express
// One-line fix: use the documented field name.
const bbox = result.bbox_4326;  // βœ… matches the job.complete schema
4. Replay the same delivery Node.js
const DELIVERY = "whd_01hx2g4n8p7rqzm3k";  // from step 2

const queued = await fetch(
  `${API}/webhooks/${HOOK}/deliveries/${DELIVERY}/replay`,
  { method: "POST", headers: auth }
).then(r => r.json());
console.log("replay queued", queued.result.status);  // pending

// Poll until it settles (success or failed again). Backoff in real code.
for (let i = 0; i < 10; i++) {
  await new Promise(r => setTimeout(r, 1000));
  const d = (await fetch(`${API}/webhooks/${HOOK}/deliveries?limit=1`,
    { headers: auth }).then(r => r.json())).result[0];
  if (d.id === DELIVERY && d.status !== "pending") {
    console.log(d.status, d.response_code, "attempt", d.attempt_count);
    break;
  }
}

// success 200 attempt 4
Expected output: the failed delivery is listed with response_code: 500 and an excerpt of your stack trace in response_body_excerpt. After the fix, the replay shows status: pending immediately, then settles to status: success, response_code: 200 within a second — with the same id (whd_…) and signature_timestamp as the original failure, and attempt_count incremented by one.
If the replay also fails, check three things in order: (1) the receiver actually redeployed (curl it directly with the same payload to confirm the new code is live), (2) the delivery is not stuck on the old X-Geog-Signature timestamp — if more than 5 minutes have passed since the original send, your verifier's |now - t| > 300 check will reject it (relax that check for known X-Geog-Delivery IDs, see the note on the replay endpoint), and (3) the webhook is still active — sustained failures auto-disable it and the replay will return 409 WEBHOOK_DISABLED until you re-enable it on the dashboard.
Recipe: idempotent receiver (dedupe replays on X-Geog-Delivery) Use X-Geog-Delivery as a dedupe key so replayed deliveries don't double-process

Once you've worked through Recipe: debug a failed delivery and replay it, the very next failure mode is a silent one: a replay of a delivery you already processed. The signature is valid, the payload is identical, and your handler will happily run end-to-end again — archiving the same plume result twice, double-charging an internal counter, or sending the same notification twice. The fix is a one-line dedupe at the top of the handler keyed on X-Geog-Delivery, the unique-per-delivery ID that POST /webhooks/{id}/deliveries/{delivery_id}/replay deliberately keeps stable across replays.

The pattern: claim the delivery ID atomically before doing any side-effecting work. If the claim succeeds, this is the first time you've seen this delivery — do the work. If the claim fails, this is a replay (or a retry from the platform's own backoff schedule) — ack with 200 and skip. The two snippets below show the claim against Postgres (INSERT ... ON CONFLICT DO NOTHING) and against Redis (SET NX); both are atomic primitives, so concurrent replays of the same delivery cannot both win the claim. Pick whichever store your service already has — the dedupe table only needs the delivery ID and a timestamp.

TTL the dedupe entries to 30 days, matching the dashboard's delivery retention window. After 30 days the platform itself can no longer replay the delivery (it's purged from GET /webhooks/{id}/deliveries), so older entries in your dedupe store can be safely evicted. Verify the HMAC before claiming the ID — an unauthenticated POST with a forged X-Geog-Delivery must not be able to poison your dedupe store and silently swallow the next legitimate delivery with that ID.

Python — Flask + Postgres ON CONFLICT DO NOTHING

Idempotent receiver Python Β· Flask
# One-time schema: a tiny dedupe table keyed on the delivery ID.
# CREATE TABLE webhook_deliveries (
# delivery_id text PRIMARY KEY,
# processed_at timestamptz NOT NULL DEFAULT now()
# );
# A nightly job deletes rows where processed_at < now() - interval '30 days'.

@app.post("/hooks/geog")
def receive():
    sig = request.headers.get("X-Geog-Signature", "")
    # 1. Verify the HMAC FIRST β€” never let an unauthenticated caller
    # poison the dedupe table with a forged X-Geog-Delivery.
    if not verify(sig, request.get_data()):
        abort(400, "bad signature")
    delivery_id = request.headers["X-Geog-Delivery"]

    # 2. Claim the delivery ID atomically. ON CONFLICT DO NOTHING means
    # a duplicate insert returns rowcount=0 instead of raising.
    with db.cursor() as cur:
        cur.execute(
            "INSERT INTO webhook_deliveries (delivery_id) "
            "VALUES (%s) ON CONFLICT DO NOTHING",
            (delivery_id,),
        )
        if cur.rowcount == 0:
            # Already processed β€” this is a replay or a platform retry.
            # Ack with 200 so the platform stops retrying, and skip the side effect.
            return "", 200

    # 3. First time we've seen this delivery β€” do the real work.
    payload = request.get_json()["result"]
    archive_to_s3(payload["bbox_4326"], payload)
    return "", 200

Node — Express + Redis SET NX

Idempotent receiver Node.js Β· Express
// `redis` is any ioredis / node-redis client.
// SET key value NX EX 2592000 is atomic: it only sets the key if absent,
// returns "OK" on win and null on lose, with a 30-day TTL matching the
// dashboard's delivery retention window.
const DEDUPE_TTL_SECONDS = 30 * 86400;

app.post("/hooks/geog",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.header("X-Geog-Signature") ?? "";
    // 1. Verify the HMAC FIRST β€” never let an unauthenticated caller
    // poison the dedupe store with a forged X-Geog-Delivery.
    if (!verify(sig, req.body)) return res.status(400).send("bad signature");
    const deliveryId = req.header("X-Geog-Delivery");

    // 2. Claim the delivery ID atomically. SETNX returns "OK" if we won
    // the claim, null if the key already existed (replay or retry).
    const claimed = await redis.set(
      `whd:${deliveryId}`, "1", "NX", "EX", DEDUPE_TTL_SECONDS,
    );
    if (claimed === null) {
      // Already processed β€” ack with 200 so the platform stops retrying,
      // and skip the side effect.
      return res.sendStatus(200);
    }

    // 3. First time we've seen this delivery β€” do the real work.
    const { result } = JSON.parse(req.body.toString("utf8"));
    await archiveToS3(result.bbox_4326, result);
    res.sendStatus(200);
  });
How to verify it works: trigger an initial delivery with POST /webhooks/{id}/test, watch your handler archive the payload once, then call POST /webhooks/{id}/deliveries/{delivery_id}/replay with the same whd_. The replay shows up as status: success, response_code: 200 in GET /webhooks/{id}/deliveries with attempt_count incremented, but your S3 bucket still contains exactly one object — the dedupe claim short-circuited the second write.
If the side effect can fail mid-handler (e.g. archive_to_s3 raises after the dedupe row is committed), you'll have claimed the delivery without doing the work, and the platform's automatic retry will be silently dropped. Two safe options: (a) wrap the claim and the side effect in the same database transaction so a failed write rolls back the claim, or (b) keep the claim, return non-2xx so the platform retries, and make the side effect itself idempotent on a stable key inside the payload (e.g. result.job_id) — the dedupe row then guards only the final success ack.
GET /webhooks List every webhook registered on the account

Returns every webhook registered on the calling account, ordered by created_at descending. Use this to audit which endpoints are receiving deliveries, detect stale URLs, or build a management UI.

The signing secret is never returned by this endpoint. It is only available on the original POST /webhooks response. To rotate a leaked secret, DELETE the webhook and re-register it.
Query Parameters
ParameterTypeRequiredDescription
activebooleanOptionalFilter to only active (true) or paused (false) webhooks. Omit to return all.
Request HTTP
GET https://api.geog.ai/v1/webhooks?active=true
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” 200 OK
FieldTypeDescription
okbooleanAlways true on success
resultWebhook[]Array of webhook records (without secret)
meta.request_idstringUnique request identifier for support tickets
Response β€” 200 OK JSON
{
  "ok": true,
  "result": [
    {
      "id": "whk_01hx2f8q7n3tdvm5j",
      "url": "https://api.acme.com/hooks/geog",
      "events": ["job.complete", "job.failed"],
      "description": "Production plume results β†’ SOC pipeline",
      "active": true,
      "created_at": "2026-04-21T03:14:22Z"
    },
    {
      "id": "whk_01hw9n2k4r8jbtvz5",
      "url": "https://staging.acme.com/hooks/geog",
      "events": ["job.complete"],
      "description": "Staging mirror",
      "active": true,
      "created_at": "2026-04-12T18:02:10Z"
    }
  ],
  "meta": { "request_id": "req_01hx2g4r5n7t8yzpc" }
}
Error Responses
StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
GET /webhooks/{webhook_id} Retrieve a single registered webhook

Returns the current configuration of one webhook. The signing secret is not returned β€” it is only available on the original POST /webhooks response. To rotate it, delete and re-register.

Path Parameters
ParameterTypeRequiredDescription
webhook_idstringRequiredID of the webhook (prefix whk_)
Request HTTP
GET https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” 200 OK JSON
{
  "ok": true,
  "result": {
    "id": "whk_01hx2f8q7n3tdvm5j",
    "url": "https://api.acme.com/hooks/geog",
    "events": ["job.complete", "job.failed"],
    "description": "Production plume results β†’ SOC pipeline",
    "active": true,
    "created_at": "2026-04-21T03:14:22Z"
  },
  "meta": { "request_id": "req_01hx2g6m8p2vsnryc" }
}
Error Responses
StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDNo webhook with this ID exists on the calling account
PATCH /webhooks/{webhook_id} Update a registered webhook (toggle, re-point, retag)

Partially updates a webhook. Send only the fields you want to change. Common uses: toggling active to pause/resume deliveries, re-pointing url after a backend migration, or narrowing the subscribed events.

The signing secret cannot be changed via this endpoint. To rotate it, DELETE the webhook and register a new one with POST /webhooks β€” store the new secret from that response, then cut over your verification code.
Path Parameters
ParameterTypeRequiredDescription
webhook_idstringRequiredID of the webhook (prefix whk_)
Request Body
ParameterTypeRequiredDescription
urlstringOptionalNew HTTPS endpoint. Same validation as registration (HTTPS-only, public, 2xx within 10s).
eventsstring[]OptionalReplace the subscribed event list. Options: job.complete, job.failed, calibration.updated, webhook.disabled
descriptionstringOptionalNew human-readable label (max 200 chars)
activebooleanOptionalSet to false to pause deliveries without deleting the webhook; true to resume.
Request β€” pause a webhook HTTP
PATCH https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Content-Type: application/json

{
  "active": false
}
Response β€” 200 OK JSON
{
  "ok": true,
  "result": {
    "id": "whk_01hx2f8q7n3tdvm5j",
    "url": "https://api.acme.com/hooks/geog",
    "events": ["job.complete", "job.failed"],
    "description": "Production plume results β†’ SOC pipeline",
    "active": false,
    "created_at": "2026-04-21T03:14:22Z"
  },
  "meta": { "request_id": "req_01hx2g7v3k9pmczn4" }
}
Error Responses
StatusCodeDescription
400INVALID_WEBHOOK_URLNew url is malformed, not HTTPS, or resolves to a private/loopback address
400UNSUPPORTED_EVENTOne or more entries in events is not a recognised event type
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDNo webhook with this ID exists on the calling account
DELETE /webhooks/{webhook_id} Permanently delete a registered webhook

Permanently removes a webhook. Deliveries already in flight may still arrive briefly, but no new deliveries will be queued. Use this to revoke a leaked endpoint or to rotate the signing secret (delete, then POST /webhooks to register a fresh one and capture the new secret).

Rotating a signing secret: the secret cannot be regenerated in place. The standard rotation flow is:
  1. POST /webhooks to register a second webhook pointing at the same URL β€” capture the new secret.
  2. Update your verifier to accept either secret during cutover.
  3. DELETE /webhooks/{webhook_id} to remove the old one.
  4. Drop the old secret from your verifier.
Path Parameters
ParameterTypeRequiredDescription
webhook_idstringRequiredID of the webhook (prefix whk_)
Request HTTP
DELETE https://api.geog.ai/v1/webhooks/whk_01hx2f8q7n3tdvm5j
Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’
Response β€” 204 No Content HTTP
HTTP/1.1 204 No Content
Error Responses
StatusCodeDescription
401INVALID_API_KEYMissing or invalid Authorization header
404WEBHOOK_NOT_FOUNDNo webhook with this ID exists on the calling account

STAC Catalog

The STAC Catalog API exposes every completed geo_simulation_jobs row your tenant owns as a SpatioTemporal Asset Catalog (STAC) Item, grouped into Collections keyed by (engine, vertical_id). This makes geog.ai output directly addressable from any STAC client — pystac-client, QGIS, ArcGIS Pro, OpenEO, and Microsoft Planetary Computer's stac-fastapi tooling.

Conformance set advertised at GET /v1/stac/conformance:

  • https://api.stacspec.org/v1.0.0/core
  • https://api.stacspec.org/v1.0.0/collections
  • https://api.stacspec.org/v1.0.0/ogcapi-features
  • https://api.stacspec.org/v1.0.0/item-search
  • http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core
  • http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30
  • http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson

CQL2 filtering is intentionally not advertised on this endpoint — it is tracked separately as roadmap item geog-ai-15.

Tenant scoping & pricing. Every STAC route is wrapped in the standard Phase 3 auth/metering pipeline. Results are intersected against the caller's vertical_ids + tenant_id, and each call bills $0.002 at 2.0 cost units in the read family.

GET
/v1/stac/

Catalog root carrying conformance + child links to every Collection visible to the caller.

GET
/v1/stac/conformance

The bare conformance object — useful for client capability negotiation.

GET
/v1/stac/collections

One Collection per (engine, vertical_id) tuple you have produced jobs for. Each carries extent.spatial.bbox, extent.temporal.interval, a per-tenant license slug (proprietary-{plan}), and a providers chain naming geog.ai as licensor and the engine as processor.

GET
/v1/stac/collections/{collection_id}

collection_id is the composite {engine}__{vertical_id} string — e.g. plume_aermod__oilgas. Use _unscoped as the vertical sentinel for legacy rows that carry no vertical_id.

GET
/v1/stac/collections/{collection_id}/items

Paged Item list scoped to the Collection. Accepts bbox, datetime, limit, cursor exactly like Item Search.

GET
/v1/stac/collections/{collection_id}/items/{item_id}

A single STAC Item. item_id is the geo_simulation_jobs.job_id. Each Item carries three assets:

  • result — the GeoJSON result envelope (application/geo+json).
  • metadata — full job metadata + envelope (application/json).
  • geoparquetspec-compliant GeoParquet 1.1 bundle of the result envelope's features, ready for direct ingestion into QGIS / DuckDB / Snowflake / Athena / Spark (application/vnd.apache.parquet). The href embeds an HMAC-signed token bound to (job_id, tenant_id, exp); the asset block also exposes geog_ai:expires_at so clients know when to re-fetch the parent Item to refresh the URL. Token TTL defaults to 1 h (QHPA_STAC_GEOPARQUET_TTL_S).
GET
/v1/stac/items/{item_id}/geoparquet?token=…

Materializes the Item's result envelope into a GeoParquet 1.1 file (binary geometry column + the GeoParquet geo file-level metadata key) and streams the bytes. The ?token= query param IS the credential — fetch it from the parent Item's assets.geoparquet.href. No Bearer header is required, so a STAC client can hand the URL straight to QGIS or duckdb -c "SELECT * FROM '<url>'".

Bills 2.0 cost units in the read family against the tenant the token was issued for.

POST
/v1/stac/search

Same predicates as GET, sent as a JSON body. Use this surface when query strings would exceed proxy URL-length limits, or when posting array-shaped bbox / collections values. The next link returned on POST searches carries method: "POST" and an inline body object containing the next-page cursor — per STAC Β§15.

Recipe — pystac-client

Python 3 Python
from pystac_client import Client

client = Client.open(
  "https://api.geog.ai/v1/stac/",
  headers={"Authorization": "Bearer geog_live_sk_β€’β€’β€’β€’"},
)

# Browse collections
for col in client.get_collections():
  print(col.id, col.extent.spatial.bboxes[0])

# Item Search across the Permian basin, last 24h
search = client.search(
  collections=["plume_aermod__oilgas"],
  bbox=[-104.0, 31.0, -101.0, 33.0],
  datetime="2026-04-28T00:00:00Z/..",
  limit=100,
)
for item in search.items():
  print(item.id, item.properties["datetime"])

Code Examples

Python β€” full plume detection workflow

Python 3 Python
import requests, time

API_KEY = "geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’"
BASE = "https://api.geog.ai/v1"
HEADERS = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" }

# 1. Resolve spatial context for the triggering sensor
ctx = requests.get(f"{BASE}/context", headers=HEADERS,
                   params={"device_id": "sensor_h2s_023"}).json()
site_id = ctx["result"]["context"]["asset_proximity"][0]["asset_id"]

# 2. Run a plume simulation (async)
job = requests.post(f"{BASE}/simulate/plume", headers={**HEADERS, "X-Site-ID": site_id},
    json={
        "source": { "lat": 31.9642, "lon": -99.9035, "alt_m": 545,
                   "emission_rate_gs": 2.4, "stack_height_m": 12 },
        "duration_min": 60, "tier": 1, "species": "H2S"
    }).json()

job_id = job["job"]["id"]

# 3. Poll for result
while True:
    result = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS).json()
    if result["job"]["status"] == "complete": break
    time.sleep(2)

# 4. Check for permit exceedance
if result["result"]["permit_exceedance"]:
    print("Exceedance detected β€” notifying TCEQ")
    print(result["result"]["exceedance_zones"])

Node.js β€” jurisdiction lookup on sensor event

Node.js JavaScript
const API_KEY = 'geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’';
const BASE = 'https://api.geog.ai/v1';

async function onSensorEvent(deviceId, reading) {
  // Get full spatial context (includes jurisdiction)
  const res = await fetch(`${BASE}/context?device_id=${deviceId}`, {
    headers: { 'Authorization': `Bearer ${API_KEY}` }
  });
  const { result, meta } = await res.json();

  const { jurisdiction, wind_vector } = result.context;

  if (reading > jurisdiction.limit_ppm) {
    console.log(`Exceedance! Notify ${jurisdiction.authority} within ${jurisdiction.notify_within_hr}hr`);
    console.log(`Model confidence: ${meta.confidence} | Calibration age: ${meta.calibration_age_days}d`);
  }
}

cURL β€” quick jurisdiction check

cURL Shell
curl -s \
  -H "Authorization: Bearer geog_live_sk_β€’β€’β€’β€’β€’β€’β€’β€’" \
  "https://api.geog.ai/v1/jurisdiction?lat=31.9686&lon=-99.9018&domains=air,noise" \
  | jq '.result.jurisdiction.air_quality.reporting_thresholds'

Language Clients

Stub SDKs are generated from openapi.yaml and cover every documented endpoint, the standard response envelope, and async-job polling. Drop them into a project to skip the HTTP/auth boilerplate.

Stub status. Method signatures are stable; request/response payloads are intentionally permissive so they keep working as the spec evolves. Tighten types or regenerate from the OpenAPI spec when stricter models are required.

TypeScript / JavaScript — @geog-ai/sdk

Install Shell
npm install @geog-ai/sdk
# or pnpm add @geog-ai/sdk
# or yarn add @geog-ai/sdk
First call TypeScript
import { GeogClient } from "@geog-ai/sdk";

const geog = new GeogClient({ apiKey: process.env.GEOG_API_KEY! });

// 1. Spatial context for a registered device
const ctx = await geog.context({ device_id: "sensor_h2s_023" });

// 2. Async plume simulation + wait for completion
const job = await geog.simulatePlume({
  source: { lat: 31.9642, lon: -99.9035, alt_m: 545,
    emission_rate_gs: 2.4, stack_height_m: 12 },
  duration_min: 60, tier: 1, species: "H2S",
});
const result = await geog.waitForJob(job.job.id);

Python — geog-ai

Install Shell
pip install geog-ai
First call Python
import os
from geog_ai import GeogClient

geog = GeogClient(api_key=os.environ["GEOG_API_KEY"])

# 1. Spatial context for a registered device
ctx = geog.context(device_id="sensor_h2s_023")

# 2. Async plume simulation + wait for completion
job = geog.simulate_plume({
  "source": { "lat": 31.9642, "lon": -99.9035, "alt_m": 545,
    "emission_rate_gs": 2.4, "stack_height_m": 12 },
  "duration_min": 60, "tier": 1, "species": "H2S",
})
result = geog.wait_for_job(job["job"]["id"])

Polling async-job results Typed

Every /simulate/*, /rf/mesh/viability, /rf/optimize/placement and /optimize/* endpoint returns the same AsyncJobAccepted envelope (HTTP 202) β€” just an acknowledgement with a job.id. The actual result lands at GET /jobs/{job_id}, typed as JobResponse<TResult>. Specify the generic so the SDK gives you back a typed result instead of unknown / Any.

Two envelopes, not one.
  • POST /simulate/plume β†’ AsyncJobAccepted ({ ok, job: { id, status: "queued", eta_seconds, … } }). No result. The 202 returns immediately, usually in < 100 ms.
  • GET /jobs/{job_id} β†’ JobResponse<TResult> ({ ok, job: { id, status, … }, result?: TResult, meta }). result is populated only when job.status === "complete"; on "failed" inspect job.error / meta.
Submit β†’ poll β†’ typed result TypeScript
import { GeogClient, type JobResponse } from "@geog-ai/sdk";

// Define (or import) the result shape your simulation returns.
// Tighten this to your needs; the wire payload is the same.
interface PlumeResult {
  contours: Array<{ ppb: number; geometry: unknown }>;
  peak_ppb: number;
  impacted_receptor_ids: string[];
}

const geog = new GeogClient({ apiKey: process.env.GEOG_API_KEY! });

// 1. Submit β€” 202 AsyncJobAccepted (no result yet)
const accepted = await geog.simulatePlume({
  source: { lat: 31.9642, lon: -99.9035, alt_m: 545,
    emission_rate_gs: 2.4, stack_height_m: 12 },
  duration_min: 60, tier: 1, species: "H2S",
});
// accepted.job.status === "queued" | "running"; accepted.result is undefined.

// 2. Poll β€” pass the result generic so `done.result` is typed PlumeResult.
const done: JobResponse<PlumeResult> =
  await geog.waitForJob<PlumeResult>(accepted.job.id, {
    intervalMs: 2000, timeoutMs: 5 * 60_000,
  });

// 3. Typed result β€” narrow on status before reading .result
if (done.job.status === "complete" && done.result) {
  console.log(done.result.peak_ppb, done.result.impacted_receptor_ids);
} else {
  // done.job.status === "failed" β€” inspect done.meta / done.job for details
  throw new Error(`Job ${accepted.job.id} failed`);
}

// Alternatively, poll once with `geog.job<TResult>()`:
const snapshot = await geog.job<PlumeResult>(accepted.job.id);
Submit β†’ poll β†’ typed result Python
import os
from typing import List, TypedDict, cast
from geog_ai import GeogClient
from geog_ai.types import JobResponse

# Define (or import) the result shape your simulation returns.
class PlumeContour(TypedDict):
  ppb: float
  geometry: dict

class PlumeResult(TypedDict):
  contours: List[PlumeContour]
  peak_ppb: float
  impacted_receptor_ids: List[str]

geog = GeogClient(api_key=os.environ["GEOG_API_KEY"])

# 1. Submit β€” 202 AsyncJobAccepted (no result yet)
accepted = geog.simulate_plume({
  "source": { "lat": 31.9642, "lon": -99.9035, "alt_m": 545,
    "emission_rate_gs": 2.4, "stack_height_m": 12 },
  "duration_min": 60, "tier": 1, "species": "H2S",
})
# accepted["job"]["status"] is "queued"/"running"; "result" is absent.

# 2. Poll β€” wait_for_job returns JobResponse[Any]; cast to your generic
# so type-checkers (mypy/pyright) treat done["result"] as PlumeResult.
done = cast(
  JobResponse[PlumeResult],
  geog.wait_for_job(accepted["job"]["id"], interval=2.0, timeout=300),
)

# 3. Typed result β€” narrow on status before reading "result"
if done["job"]["status"] == "complete" and done.get("result"):
  result: PlumeResult = done["result"]
  print(result["peak_ppb"], result["impacted_receptor_ids"])
else:
  raise RuntimeError(f"Job {accepted['job']['id']} failed")

# One-shot snapshot variant (no polling loop):
snapshot = cast(JobResponse[PlumeResult], geog.job(accepted["job"]["id"]))

Swap PlumeResult for FloodResult, RFCoverageResult, MeshViabilityResult, NodePlacementResult, etc. β€” the SDK shape is the same; only your generic changes per endpoint.

Live plume tracking (SSE) Site Calibration

Both clients expose a streaming helper for the /simulate/plume/stream SSE endpoint. They open the connection, parse each event, JSON-decode the payload, and reconnect with exponential backoff on dropped connections (sending Last-Event-ID automatically).

Stream events TypeScript
const ctrl = new AbortController();
const stream = geog.streamPlume({
  source: { lat: 31.9642, lon: -99.9035,
    emission_rate_gs: 2.4, stack_height_m: 12 },
  duration_min: 60, species: "H2S",
}, { signal: ctrl.signal });

for await (const evt of stream) {
  // evt = { event, data, id? }
  if (evt.event === "alert") console.log("⚠", evt.data);
  else console.log(evt.event, evt.data);
}
Stream events Python
for evt in geog.stream_plume({
  "source": { "lat": 31.9642, "lon": -99.9035,
    "emission_rate_gs": 2.4, "stack_height_m": 12 },
  "duration_min": 60, "species": "H2S",
}):
  # evt = {"event": str, "data": Any, "id": Optional[str]}
  if evt["event"] == "alert":
    print("⚠", evt["data"])
  else:
    print(evt["event"], evt["data"])

Try the live stream In-browser demo

The panel below replays a recorded plume scenario as a Server-Sent Events stream and feeds it into the same for await (evt of streamPlume(...)) loop shown above. Every event you see β€” tick, concentration, alert, end β€” is parsed into a PlumeStreamEvent object identical to the one the TypeScript SDK yields. No API key required.

Adjust any input to see how the plume reacts. Ground-level ppb scales with emission rate, and falls off as wind speed or stack height rises (Gaussian-style).

Β· speciesβ€”
Β· emissionβ€”
Β· stackβ€”
Β· windβ€”
idle t00:00 windβ€” m/s peak0 ppb
wind stack Β· Hβ‚‚S 2.4 g/s Β· 12 m geog.ai Β· /simulate/plume/stream (replay)
< 50 ppb 50–200 ppb > 200 ppb (alert)
Press β–Ά Start stream to begin replay. Events will appear here.

Other languages

Need Go, Ruby, Java, C#, or Rust? Generate a client straight from the spec:

Generate from openapi.yaml Shell
# Go
npx @openapitools/openapi-generator-cli generate \
  -i https://geog.ai/docs/openapi.yaml -g go -o ./geog-go

# Ruby
npx @openapitools/openapi-generator-cli generate \
  -i https://geog.ai/docs/openapi.yaml -g ruby -o ./geog-ruby

# Java
npx @openapitools/openapi-generator-cli generate \
  -i https://geog.ai/docs/openapi.yaml -g java -o ./geog-java
TypeScript SDK source & README β†’ Python SDK source & README β†’ All SDK stubs β†’

Endpoint Quick Reference

MethodPathDescriptionPrice
GET/contextDevice SpatialState resolution$0.001
GET/context/batchBatch SpatialState, up to 500 devices$0.001/device
GET/nearbyProximity search with type filter$0.001
GET/withinPolygon containment query$0.001
GET/jurisdictionRegulatory jurisdiction for any coordinate$0.0005
POST/simulate/plumeAtmospheric dispersion (async)$0.05–$0.25
POST/simulate/plume/streamLive plume tracking via SSE (Site Calibration plans)$0.25
POST/simulate/floodFlood & hydrology simulation (async)$0.10
POST/simulate/rf_coverageSingle-node RF coverage (async)$0.08
POST/simulate/noiseNoise propagation ISO 9613-2 (async)$0.08
POST/simulate/thermalThermal / heat diffusion (async)$0.08
POST/simulate/fireFire spread simulation (async)$0.15
GET/rf/coverageCoverage polygon for registered node$0.08
GET/rf/linkPoint-to-point link budget$0.03
GET/rf/gapsRF coverage gap heatmap$0.05
POST/rf/mesh/viabilityFull mesh connectivity matrix (async)$0.15
POST/rf/optimize/placementRF node placement optimizer (async)$1–$5
POST/optimize/node_placementMulti-modal node placement (async)$1–$5
POST/optimize/sensor_placementVertical-aware sensor placement (async)$2–$5
GET/coverageMonitoring coverage fraction for area$0.01
GET/gapsMonitoring gap geographic detail$0.01
GET/trajectoryHistorical trajectory for entity$0.002
POST/trajectory/predictFuture trajectory prediction$0.005
GET/graph/influenceDownstream influence from source node$0.005
GET/graph/evacuateHazard-aware evacuation routing$0.01
GET/graph/upstreamUpstream sources for root cause analysis$0.005
GET/model/calibrationSite model calibration state and historySite plan
Full Vertical API Map β†’ Get API Key β†’