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.
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/.
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.
Content-Type: application/json
X-Site-ID: wellpad_07 # Optional β required for calibration-aware responses
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
| Header | Required | Description |
|---|---|---|
| Authorization | Required | Bearer token β your API key |
| X-Site-ID | Optional | Site identifier for calibration-aware routing. When provided, responses include site-specific model parameters rather than regional defaults. Required for calibration state endpoints. |
| X-Idempotency-Key | Optional | UUID for POST requests. Retry-safe β duplicate requests with the same key return the cached result without charging. |
Base URL
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.
"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
}
}
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 Group | Rate Limit | Concurrency | Plan |
|---|---|---|---|
| GET /context, /nearby, /within | 1,000 req/min | β | All |
| GET /jurisdiction, /coverage, /gaps | 500 req/min | β | All |
| POST /simulate/* | 60 req/min | 10 concurrent | All |
| POST /rf/*, /optimize/* | 30 req/min | 5 concurrent | All |
| GET /trajectory, /graph/* | 200 req/min | β | All |
| GET /model/calibration | 60 req/min | β | Site Calibration |
| POST /context/batch | 100 req/min | max 500 IDs/batch | All |
Rate limit headers are included on every response:
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.
"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 Status | Code | Description |
|---|---|---|
| 400 | INVALID_PARAMS | Missing or malformed request parameters |
| 401 | INVALID_API_KEY | API key missing, expired, or revoked |
| 402 | INSUFFICIENT_CREDITS | Account balance depleted |
| 403 | PLAN_LIMIT | Endpoint requires Site Calibration plan |
| 404 | DEVICE_NOT_FOUND | device_id not registered in the system |
| 422 | OUTSIDE_COVERAGE_AREA | Coordinates outside supported terrain coverage |
| 429 | RATE_LIMIT_EXCEEDED | Too many requests; check X-RateLimit-Reset |
| 500 | INTERNAL_ERROR | Transient server error; safe to retry with backoff |
| 503 | SIMULATION_QUEUE_FULL | Concurrency 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.
"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" }
}
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| device_id | string | Required | Registered device identifier |
| include | string[] | Optional | Comma-separated list of context fields to include. Default: all. Options: terrain, jurisdiction, wind, nearby, assets, calibration |
| max_age_s | integer | Optional | Maximum acceptable state age in seconds. If current state is older, triggers a fresh enrichment. Default: 300 |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
X-Site-ID: wellpad_07
"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 }
}
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| device_ids | string[] | Required | JSON-encoded array of device IDs. Max 500 per request. |
| include | string[] | Optional | Fields to include (see /context) |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"ok": true,
"result": {
"count": 3,
"succeeded": 3, "failed": 0,
"items": [ /* array of SpatialState objects */ ]
},
"meta": { "confidence": 0.95, "latency_ms": 112 }
}
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| lat | float | Required | WGS84 latitude of center point |
| lon | float | Required | WGS84 longitude of center point |
| radius_m | integer | Required | Search radius in meters. Max: 50,000m |
| type | string | Optional | Filter by entity type: gas_sensor, water_sensor, rf_node, asset, personnel, drone, or any registered custom type |
| limit | integer | Optional | Maximum results to return. Default: 50, max: 500 |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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 }
}
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?"
| Parameter | Type | Required | Description |
|---|---|---|---|
| geometry | GeoJSON | Required | URL-encoded GeoJSON Polygon or MultiPolygon |
| type | string | Optional | Entity type filter (same options as /nearby) |
| include_context | boolean | Optional | If true, includes full SpatialState for each result. Billed at $0.001/entity. Default: false |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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 }
}
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).
| Parameter | Type | Required | Description |
|---|---|---|---|
| lat | float | Required* | WGS84 latitude (*or device_id) |
| lon | float | Required* | WGS84 longitude (*or device_id) |
| device_id | string | Optional | Alternative to lat/lon β uses the device's registered coordinates |
| domains | string[] | Optional | Limit response to specific domains: air, water, noise, land, fire. Default: all |
"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.
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.
| Field | Type | Required | Description |
|---|---|---|---|
| source | object | Required | {lat, lon, alt_m, emission_rate_gs, stack_height_m} β emission source geometry |
| wind | object | Optional | {speed_ms, bearing_deg} β overrides live met data if provided |
| stability_class | string | Optional | Pasquill-Gifford stability class AβF. Auto-resolved from met data if omitted. |
| duration_min | integer | Required | Simulation duration in minutes. Max 720 for Tier 1, 480 for Tier 2. |
| tier | integer | Optional | 1 (Gaussian, $0.05), 2 (AERMOD, $0.25), 3 (Lagrangian, $0.50). Default: 1 |
| species | string | Optional | Chemical species for species-specific dispersion coefficients (e.g., H2S, CH4, NO2, PM25). Default: generic passive scalar. |
| output_receptors | string[] | Optional | Array of device IDs to include predicted concentrations for in the response |
| webhook_url | string | Optional | Override account-level webhook for this job only |
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"]
}
"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 }
}
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.
403. Concurrent open streams are also rate-limited; 429 responses indicate the per-account stream limit was reached.
| Field | Type | Required | Description |
|---|---|---|---|
| source | object | Required | {lat, lon, alt_m, emission_rate_gs, stack_height_m} β emission source geometry (same shape as /simulate/plume) |
| duration_min | integer | Required | Total simulation duration in minutes. The stream closes with an end event when this elapses. |
| wind | object | Optional | {speed_ms, bearing_deg} β overrides live met data if provided |
| stability_class | string | Optional | Pasquill-Gifford stability class AβF. Auto-resolved from met data if omitted. |
| species | string | Optional | Chemical species (e.g. H2S, CH4, NO2, PM25). Default: generic passive scalar. |
| output_receptors | string[] | Optional | Device IDs whose predicted concentrations should be included in each concentration event |
| tick_interval_s | integer | Optional | Seconds between tick heartbeat events. Range 1β60. Lower values yield smoother UI updates at the cost of bandwidth. Default: 5 |
| alert_thresholds | object | Optional | Concentration 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 |
-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.
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"}}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| watershed_id | string | Required* | Registered watershed identifier (*or area polygon) |
| area | GeoJSON | Required* | Polygon to delineate watershed from (*or watershed_id) |
| precip_mm | float | Required | Total precipitation in mm |
| duration_hr | float | Required | Precipitation duration in hours |
| return_periods | integer[] | Optional | Return intervals for inundation mapping. Default: [2, 10, 100] |
| antecedent_moisture | string | Optional | Soil moisture condition: dry, normal, wet. Default: auto-resolved from recent sensor data |
"watershed_id": "basin_colorado_mid",
"precip_mm": 85,
"duration_hr": 3,
"return_periods": [10, 100]
}
"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 }
]
}
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| node_id | string | Required* | Registered RF node ID (*or lat/lon/alt) |
| lat, lon, alt_m | float | Required* | Node coordinates (*or node_id) |
| frequency_mhz | float | Required | Carrier frequency in MHz |
| tx_power_dbm | float | Required | Transmit power in dBm |
| rssi_threshold_dbm | float | Optional | RSSI threshold for coverage polygon. Default: -90 dBm |
| antenna_height_m | float | Optional | Antenna height above ground. Default: 3m |
| model | string | Optional | itm (default, terrain-aware), hata (urban macro-cell), two_ray (near-field) |
"node_id": "rf_node_A7",
"frequency_mhz": 915,
"tx_power_dbm": 20,
"rssi_threshold_dbm": -95
}
"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 }
}
}
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).
| Field | Type | Required | Description |
|---|---|---|---|
| source_id | string | Required* | Registered noise source ID (*or geometry) |
| octave_bands_db | float[8] | Required | Sound power levels (dB) for 63, 125, 250, 500, 1k, 2k, 4k, 8k Hz octave bands |
| contour_levels_dba | float[] | Optional | dBA levels for contour polygons. Default: [35, 45, 55, 65, 75] |
| receptors | GeoJSON[] | Optional | Specific receptor points to evaluate (e.g., property boundary centroids) |
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]
}
"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 }
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| source | object | Required | {lat, lon, power_kw, temperature_c} β heat source specification |
| duration_min | integer | Required | Simulation duration in minutes |
| threshold_c | float[] | Optional | Temperature thresholds for zone boundary polygons. Default: [40, 60, 80] |
| output_receptors | string[] | Optional | Device IDs to predict temperature at |
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"]
}
"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" }
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| ignition_point | GeoJSON Point | Required | Fire ignition location |
| fuel_model | string | Required | NFFL/Scott-Burgan fuel model code (e.g., GR2, SH5, TU1) |
| weather | object | Optional | {wind_speed_ms, wind_bearing_deg, rh_pct, temp_c} β overrides live met data |
| duration_hr | float | Required | Simulation duration in hours. Max 72hr. |
| output_intervals_hr | float[] | Optional | Times at which to output perimeter polygons. Default: [1, 4, 12, 24] |
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]
}
"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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| node | string | Required | Registered RF node ID |
| threshold_dbm | float | Optional | RSSI threshold. Default: -90 dBm |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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 }
}
Returns the link budget between two registered nodes β predicted RSSI, fade margin, Fresnel zone clearance, and obstruction analysis. Uses ITM for terrain analysis. Returns a pass/fail recommendation for the link given the specified reliability threshold.
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | Required | Source node ID |
| to | string | Required | Target node ID |
| reliability_pct | float | Optional | Required link reliability percentage. Default: 99.0 |
"result": {
"from": "rf_node_A7", "to": "rf_node_B3",
"distance_m": 1840,
"predicted_rssi_dbm": -82.4,
"fade_margin_db": 7.6,
"fresnel_clearance_pct": 94.2,
"obstructions": [],
"link_viable": true,
"reliability_at_threshold": 99.4
}
}
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| area | GeoJSON | Required | Polygon defining the area to analyze |
| threshold_dbm | float | Optional | RSSI below which a cell is considered a gap. Default: -90 dBm |
| node_ids | string[] | Optional | Specific nodes to analyze against. Default: all registered nodes in the area |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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 }
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| node_ids | string[] | Required | Array of registered node IDs to include in the analysis. Max 200. |
| failure_scenarios | object[] | Optional | Array of {node_id, failure_probability} scenarios to evaluate. Used for resilience planning. |
| min_path_reliability_pct | float | Optional | Minimum acceptable end-to-end path reliability. Default: 99.0 |
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 }
]
}
"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" }
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| area | GeoJSON | Required | Polygon defining the deployment area |
| coverage_threshold | float | Required | Target coverage fraction (0.0β1.0) |
| existing_nodes | string[] | Optional | Registered node IDs to include in existing topology. Default: all nodes in area |
| candidate_count | integer | Optional | Number of candidate placements to return. Default: 5, max: 20 |
| frequency_mhz | float | Optional | Target frequency in MHz. Default: 915 |
| constraints | object | Optional | {min_distance_from_edge_m, exclude_polygons, require_road_access} |
"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.
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.
| Field | Type | Required | Description |
|---|---|---|---|
| area | GeoJSON | Required | Deployment area polygon |
| coverage_threshold | float | Required | Target coverage fraction (0.0β1.0) |
| existing_nodes | string[] | Optional | Registered existing node IDs |
| objective | string | Optional | Optimization objective: rf_coverage (default), plume_detection, flood_warning, acoustic_monitoring |
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"
}
"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 }
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| vertical | string | Required | ZOOID vertical ID (e.g., V01, V02, V05) |
| area | GeoJSON | Required | Site or deployment area polygon |
| target_coverage_pct | float | Required | Target monitoring coverage (0.0β1.0) |
| budget_nodes | integer | Optional | Maximum number of additional sensor nodes to place |
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
}
"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" }
}
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| area | GeoJSON | Required | URL-encoded GeoJSON polygon |
| type | string | Required | Sensor type to assess coverage for |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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 }
}
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| area | GeoJSON | Required | URL-encoded GeoJSON polygon |
| threshold_m | float | Optional | Maximum acceptable distance from any sensor. Default: 500m |
| type | string | Optional | Sensor type filter |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| entity_id | string | Required | Registered entity ID (drone, vehicle, or personnel) |
| from | ISO 8601 | Required | Start of time window |
| to | ISO 8601 | Required | End of time window. Max window: 30 days |
| resolution_s | integer | Optional | Temporal resolution of returned positions in seconds. Default: raw (all recorded positions) |
"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 */ }
}
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| entity_id | string | Required | Registered entity ID |
| lookahead_min | integer | Required | Prediction horizon in minutes. Max 120min. |
| mission_plan | GeoJSON | Optional | Planned route as a GeoJSON LineString β used to constrain prediction to known flight plan |
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]] }
}
"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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| source_id | string | Required | Source node ID (device, asset, or event location) |
| edge_type | string | Optional | Edge type to traverse: atmospheric (plume), fluid (pipe/stream), rf (signal), causal (all). Default: causal |
| max_hops | integer | Optional | Maximum graph traversal depth. Default: 5 |
| time_horizon_min | integer | Optional | Restrict to nodes reachable within this time. Default: unlimited |
"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 }
]
}
}
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| from_ids | string[] | Required | Entity or zone IDs to evacuate from (personnel IDs, zone IDs) |
| to_ids | string[] | Optional | Target safe zone IDs. Default: all registered safe zones in the facility |
| avoid_job_ids | string[] | Optional | Simulation job IDs whose result polygons to treat as impassable (live plume, flood, fire footprints) |
| site_id | string | Optional | Site context for indoor/outdoor routing graph selection |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
X-Site-ID: wellpad_07
"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 }
}
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?"
| Parameter | Type | Required | Description |
|---|---|---|---|
| target_id | string | Required | Node to find upstream sources for |
| edge_type | string | Optional | Edge type filter (same options as /graph/influence) |
| max_hops | integer | Optional | Maximum traversal depth. Default: 5 |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| model | string | Required | Model type: plume, rf, flood, noise, thermal |
| site_id | string | Required | Site identifier (same as X-Site-ID header) |
| history_days | integer | Optional | Days of calibration history to include. Default: 30, max: 365 |
"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 }
}
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| job_id | string | Required | Job ID returned from any async endpoint (path parameter) |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"ok": true,
"job": {
"id": "job_01hx2a3f9k8qmzn7p",
"type": "simulate/plume",
"status": "running",
"eta_seconds": 4,
"webhook_delivered": false
},
"result": null
}
"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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Required | HTTPS endpoint that will receive POST deliveries. Must be reachable from the public internet and respond with 2xx within 10 seconds. |
| events | string[] | Optional | Filter 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) |
| description | string | Optional | Human-readable label shown on the dashboard (max 200 chars) |
| active | boolean | Optional | Whether the webhook should start delivering immediately. Default: true |
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
}
| Field | Type | Description |
|---|---|---|
| ok | boolean | Always true on success |
| result.id | string | Unique webhook identifier (prefix whk_) |
| result.url | string | Echoes the registered HTTPS callback URL |
| result.events | string[] | Event types this webhook is subscribed to |
| result.description | string | Echoes the human-readable label |
| result.active | boolean | Whether 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_at | string | null | Timestamp at which active last transitioned from true to false; null while the webhook is active |
| result.disabled_reason | string | null | manual (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.secret | string | Shared secret for HMAC-SHA256 signing of deliveries. Returned only on this initial response β store it securely; rotate by deleting and re-registering |
| result.created_at | string (ISO 8601) | Registration timestamp |
| meta.request_id | string | Unique request identifier for support tickets |
"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" }
}
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_WEBHOOK_URL | URL is missing, malformed, not HTTPS, or resolves to a private/loopback address |
| 400 | UNSUPPORTED_EVENT | One or more entries in events is not a recognised event type |
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 409 | WEBHOOK_LIMIT_EXCEEDED | Account 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:
| Header | Description |
|---|---|
| X-Geog-Signature | Hex-encoded HMAC-SHA256 of {timestamp}.{raw_body} using your webhook secret. Format: t=<unix_ts>,v1=<hex_digest> |
| X-Geog-Event | Event type (job.complete, job.failed, calibration.updated, webhook.disabled) |
| X-Geog-Delivery | Unique delivery ID β safe to use for idempotency on your side |
| X-Geog-Webhook-Id | ID of the registered webhook that produced this delivery |
| X-Geog-Test | Present 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.
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.
# `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
// `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;
}
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.
X-Geog-Event
Every delivery, regardless of event type, uses the same JSON envelope so receivers can dispatch on a single field:
"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:
| event | result shape | Notes |
|---|---|---|
| job.complete | The 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.failed | Same 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.disabled | The 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
"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
"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
"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.
"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" }
}
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.
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.
POST /webhooks → POST /webhooks/{id}/test → a tiny Flask / Express / net/http / Sinatra receiver that verifies X-Geog-Signature, in Python, Node, Go, and Ruby.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Required | Webhook identifier returned by POST /webhooks (prefix whk_) |
| Parameter | Type | Required | Description |
|---|---|---|---|
| event | string | Optional | Event 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. |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
Content-Type: application/json
{
"event": "job.complete"
}
| Field | Type | Description |
|---|---|---|
| ok | boolean | Always true on success |
| result | WebhookDelivery | The synthetic delivery record. status starts as pending; poll GET /webhooks/{id}/deliveries to watch it transition to success or failed. |
| result.test | boolean | Always true for deliveries produced by this endpoint. Real job-driven deliveries report false. |
| result.job_id | string | Synthetic job ID prefixed job_test_ so it cannot collide with a real job |
| meta.request_id | string | Unique request identifier for support tickets |
"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" }
}
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.
| Status | Code | Description |
|---|---|---|
| 400 | UNSUPPORTED_EVENT | Requested event is not in the webhook's subscribed events list, or is not a recognised event type |
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | Webhook does not exist or is owned by another account |
| 409 | WEBHOOK_DISABLED | Webhook is currently inactive (active=false or auto-disabled after sustained failures); re-enable it on the dashboard before sending a test |
| 429 | TEST_RATE_LIMITED | More than 60 test deliveries have been requested for this webhook in the last hour |
| 429 | RATE_LIMITED | Account-level rate limit exceeded |
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.
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)
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"])
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)
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);
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)
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"])
}
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)
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"]
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
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Required | Webhook identifier returned by POST /webhooks (prefix whk_) |
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Optional | Filter to a single status. Options: pending, success, failed, undeliverable. Default: all. |
| event | string | Optional | Filter to a single event type: job.complete, job.failed, calibration.updated, webhook.disabled |
| test | boolean | Optional | Filter 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_after | string (ISO 8601) | Optional | Inclusive 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_before | string (ISO 8601) | Optional | Exclusive 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. |
| limit | integer | Optional | Page size, 1β100. Default: 25 |
| cursor | string | Optional | Opaque pagination cursor returned as meta.next_cursor on the previous page. Omit for the first page. |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
| Field | Type | Description |
|---|---|---|
| ok | boolean | Always true on success |
| result | WebhookDelivery[] | Page of delivery records, newest first |
| result[].id | string | Delivery ID (prefix whd_) β same value sent in the X-Geog-Delivery header |
| result[].webhook_id | string | ID of the registered webhook that produced this delivery |
| result[].event | string | Event type: job.complete, job.failed, calibration.updated, or webhook.disabled |
| result[].job_id | string | null | Source job ID for job.* events; null otherwise |
| result[].status | string | pending (queued / in-flight), success (2xx received), failed (non-2xx with another retry scheduled), or undeliverable (all 7 attempts exhausted) |
| result[].attempt_count | integer | Number of attempts made so far (1β7, including the original send) |
| result[].response_code | integer | null | HTTP status code returned by the receiver on the most recent attempt; null for transport failures |
| result[].response_ms | integer | null | Round-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_excerpt | string | null | First 512 bytes of the receiver's response body on the most recent attempt |
| result[].error | string | null | Transport-level error if the receiver could not be reached (e.g. connection_timeout, dns_failure, tls_handshake_failed) |
| result[].signature_timestamp | integer | Unix timestamp (seconds) used to build the original X-Geog-Signature. Stable across retries and replays. |
| result[].created_at | string (ISO 8601) | When the delivery was first queued |
| result[].last_attempt_at | string | null | Timestamp of the most recent attempt; null while still pending with no attempt yet |
| result[].delivered_at | string | null | Timestamp of the first successful (2xx) attempt; null until status becomes success. Stable across replays. |
| result[].next_attempt_at | string | null | Scheduled time of the next retry; null when status is success or undeliverable |
| meta.next_cursor | string | null | Opaque cursor for the next page; null when there are no more results |
| meta.request_id | string | Unique request identifier for support tickets |
"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"
}
}
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | Webhook does not exist or is owned by another account |
| 429 | RATE_LIMITED | Rate limit exceeded |
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Required | Webhook identifier returned by POST /webhooks (prefix whk_) |
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| status | string | Optional | Filter to a single status. Options: pending, success, failed, undeliverable. Default: all. |
| event | string | Optional | Filter to a single event type: job.complete, job.failed, calibration.updated, webhook.disabled |
| test | boolean | Optional | Filter 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_after | string (ISO 8601) | Optional | Inclusive lower bound on created_at (UTC). Bounded by the 30-day retention window. |
| created_before | string (ISO 8601) | Optional | Exclusive upper bound on created_at (UTC). Combine with created_after for a closed-open [after, before) range. |
| limit | integer | Optional | Page size, 1β100. Default: 25. Identical bounds to the JSON endpoint. |
| cursor | string | Optional | Opaque 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. |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
Accept: text/csv
Body is RFC 4180 CSV with a fixed header row, newest-first, identical to the JSON ordering. Columns, in order:
| Column | Maps to JSON field | Notes |
|---|---|---|
| id | result[].id | Delivery ID (prefix whd_) |
| webhook_id | result[].webhook_id | ID of the registered webhook |
| event | result[].event | Event type |
| job_id | result[].job_id | Source job ID for job.* events; empty for non-job events |
| status | result[].status | pending, success, failed, or undeliverable |
| attempt_count | result[].attempt_count | Integer 1β7 |
| response_code | result[].response_code | HTTP status; empty for transport failures |
| response_ms | result[].response_ms | Round-trip ms; empty for transport failures or while still pending |
| response_body_excerpt | result[].response_body_excerpt | First 512 bytes of the receiver's response. Often contains commas, quotes, or newlines and will be quoted per RFC 4180. |
| error | result[].error | Transport-level error string; empty when an HTTP response was received |
| signature_timestamp | result[].signature_timestamp | Unix timestamp (seconds), stable across retries and replays |
| created_at | result[].created_at | ISO 8601 UTC; when the delivery was first queued |
| last_attempt_at | result[].last_attempt_at | ISO 8601 UTC; empty while still pending with no attempt yet |
| delivered_at | result[].delivered_at | ISO 8601 UTC; empty until status becomes success |
| next_attempt_at | result[].next_attempt_at | ISO 8601 UTC; empty when status is success or undeliverable |
| test | result[].test | true / 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.
| Header | Description |
|---|---|
| Content-Type | Always text/csv; charset=utf-8 |
| Content-Disposition | Always attachment; filename="deliveries-<webhook_id>.csv" |
| X-Next-Cursor | Opaque 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-Id | Unique request identifier for support tickets. Equivalent to meta.request_id on the JSON endpoint. |
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
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+):
# 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
Errors are returned as the standard JSON error envelope (not CSV) so existing client error handling keeps working.
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | Webhook does not exist or is owned by another account |
| 429 | RATE_LIMITED | Rate limit exceeded |
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Required | Webhook identifier (prefix whk_) |
| delivery_id | string | Required | Delivery identifier returned by GET /webhooks/{id}/deliveries (prefix whd_) |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
| Field | Type | Description |
|---|---|---|
| ok | boolean | Always true on success |
| result | WebhookDelivery | Updated 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_id | string | Unique request identifier for support tickets |
"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" }
}
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | Webhook does not exist or is owned by another account |
| 404 | DELIVERY_NOT_FOUND | Delivery does not belong to this webhook or has aged past the 30-day retention window |
| 409 | DELIVERY_ALREADY_PENDING | Delivery is already queued or in-flight; wait for it to settle before replaying |
| 409 | WEBHOOK_DISABLED | Webhook is currently inactive (active=false or auto-disabled after sustained failures); re-enable it on the dashboard before replaying |
| 429 | RATE_LIMITED | Rate limit exceeded |
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string | Required | Webhook identifier returned by POST /webhooks (prefix whk_) |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
| Field | Type | Description |
|---|---|---|
| ok | boolean | Always true on success |
| result.webhook_id | string | Webhook identifier (prefix whk_) |
| result.active | boolean | Current active state of the webhook. Mirrors Webhook.active. |
| result.status | string | Quick 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_at | string | null | When the webhook was last paused. null while active=true. |
| result.disabled_reason | string | null | manual (owner set active=false) or auto_failure_rate (platform auto-disabled after sustained failures). null while active=true. |
| result.window_seconds | integer | Length of the rolling window the counters cover. Currently fixed at 3600 (1 hour). |
| result.as_of | string (ISO 8601) | Trailing edge of the window β "now" on the platform side |
| result.delivery_count | integer | Total logical deliveries (success + failed + undeliverable + pending) in the window. Counters are per logical delivery, not per retry attempt. |
| result.success_count | integer | Logical deliveries that received a 2xx within 10 s on some attempt |
| result.failed_count | integer | Logical deliveries whose latest attempt was non-2xx, timed out, or connection-refused, with another retry still scheduled |
| result.undeliverable_count | integer | Logical deliveries that exhausted all 7 retries in the window without ever returning 2xx |
| result.pending_count | integer | Logical deliveries currently pending (queued or in-flight) |
| result.failure_rate | number (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_threshold | number | Failure 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_failures | integer | Number of consecutive deliveries (newest-first) whose latest attempt is failed or undeliverable. Resets to 0 on the next success. |
| result.last_success_at | string | null | Timestamp of the most recent successful delivery in the window; null if no delivery has succeeded in the last hour |
| result.last_failure_at | string | null | Timestamp of the most recent failed or undeliverable delivery in the window; null if every delivery succeeded |
| meta.request_id | string | Unique request identifier for support tickets |
"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" }
}
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}.
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | Webhook does not exist or is owned by another account |
| 429 | RATE_LIMITED | Account-level rate limit exceeded |
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.
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
webhook.disabled handler (on a separately-monitored receiver) Python Β· Flask# 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
// 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)
const API = "https://api.geog.ai/v1";
const KEY = process.env.GEOG_API_KEY;
const HOOK = "whk_01hx2f8q7n3tdvm5j"; // the production webhook you are guarding
const AUTH = { "Authorization": `Bearer ${KEY}` };
function pageOncall(severity, msg) {
// Wire to PagerDuty / Opsgenie / your incident tool of choice.
console.log(`[${severity}] ${msg}`);
}
async function checkOnce() {
const r = await fetch(`${API}/webhooks/${HOOK}/health`, { headers: AUTH });
if (!r.ok) throw new Error(`health ${r.status}`);
const h = (await r.json()).result;
if (h.status === "degraded") {
pageOncall("warning",
`webhook ${HOOK} degraded: failure_rate=` +
`${(h.failure_rate * 100).toFixed(0)}% ` +
`(${h.failed_count}+${h.undeliverable_count}/${h.delivery_count}); ` +
`auto-disable threshold ${(h.auto_disable_threshold * 100).toFixed(0)}%; ` +
`last success ${h.last_success_at}`);
return;
}
if (h.status === "disabled" && h.disabled_reason === "auto_failure_rate") {
pageOncall("critical",
`webhook ${HOOK} AUTO-DISABLED at ${h.disabled_at} ` +
`(failure_rate=${(h.failure_rate * 100).toFixed(0)}%, ` +
`consecutive_failures=${h.consecutive_failures}). ` +
`No deliveries until you re-enable via ` +
`PATCH /webhooks/${HOOK} {"active": true}.`);
return;
}
// status == "disabled" + reason == "manual": someone paused it on purpose.
// status == "healthy": nothing to do.
}
setInterval(async () => {
try { await checkOnce(); }
catch (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.
console.error("health poll failed:", e.message);
}
}, 60_000); // once a minute matches the platform's own evaluation tick
webhook.disabled handler (on a separately-monitored receiver) Node.js Β· Express// 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",
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");
// 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 (req.header("X-Geog-Event") !== "webhook.disabled") {
return res.sendStatus(200); // ack so the platform stops retrying, but ignore
}
const { result } = JSON.parse(req.body.toString("utf8"));
// result.disabled_reason is "auto_failure_rate" β the only reason the
// platform fires this event. (manual disables don't emit it.)
pageOncall("critical",
`webhook ${result.id} (${result.url}) auto-disabled at ` +
`${result.disabled_at} (reason=${result.disabled_reason}). ` +
`Re-enable with PATCH /webhooks/${result.id} {"active": true}.`);
res.sendStatus(200);
});
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.
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.
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.
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
@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
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
bbox = payload["bbox_4326"] # β matches the job.complete schema
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
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);
});
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')
const bbox = result.bbox_4326; // β matches the job.complete schema
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
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.
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.
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.
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
# 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
// 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);
});
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.
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.
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| active | boolean | Optional | Filter to only active (true) or paused (false) webhooks. Omit to return all. |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
| Field | Type | Description |
|---|---|---|
| ok | boolean | Always true on success |
| result | Webhook[] | Array of webhook records (without secret) |
| meta.request_id | string | Unique request identifier for support tickets |
"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" }
}
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| webhook_id | string | Required | ID of the webhook (prefix whk_) |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
"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" }
}
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | No webhook with this ID exists on the calling account |
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.
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.
| Parameter | Type | Required | Description |
|---|---|---|---|
| webhook_id | string | Required | ID of the webhook (prefix whk_) |
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | Optional | New HTTPS endpoint. Same validation as registration (HTTPS-only, public, 2xx within 10s). |
| events | string[] | Optional | Replace the subscribed event list. Options: job.complete, job.failed, calibration.updated, webhook.disabled |
| description | string | Optional | New human-readable label (max 200 chars) |
| active | boolean | Optional | Set to false to pause deliveries without deleting the webhook; true to resume. |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
Content-Type: application/json
{
"active": false
}
"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" }
}
| Status | Code | Description |
|---|---|---|
| 400 | INVALID_WEBHOOK_URL | New url is malformed, not HTTPS, or resolves to a private/loopback address |
| 400 | UNSUPPORTED_EVENT | One or more entries in events is not a recognised event type |
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | No webhook with this ID exists on the calling account |
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).
POST /webhooksto register a second webhook pointing at the same URL β capture the newsecret.- Update your verifier to accept either secret during cutover.
DELETE /webhooks/{webhook_id}to remove the old one.- Drop the old secret from your verifier.
| Parameter | Type | Required | Description |
|---|---|---|---|
| webhook_id | string | Required | ID of the webhook (prefix whk_) |
Authorization: Bearer geog_live_sk_β’β’β’β’β’β’β’β’
| Status | Code | Description |
|---|---|---|
| 401 | INVALID_API_KEY | Missing or invalid Authorization header |
| 404 | WEBHOOK_NOT_FOUND | No 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/corehttps://api.stacspec.org/v1.0.0/collectionshttps://api.stacspec.org/v1.0.0/ogcapi-featureshttps://api.stacspec.org/v1.0.0/item-searchhttp://www.opengis.net/spec/ogcapi-features-1/1.0/conf/corehttp://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30http://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.
Catalog root carrying conformance + child links to every Collection visible to the caller.
The bare conformance object — useful for client capability negotiation.
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.
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.
Paged Item list scoped to the Collection. Accepts
bbox, datetime, limit,
cursor exactly like Item Search.
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).geoparquet— spec-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). Thehrefembeds an HMAC-signed token bound to(job_id, tenant_id, exp); the asset block also exposesgeog_ai:expires_atso clients know when to re-fetch the parent Item to refresh the URL. Token TTL defaults to 1 h (QHPA_STAC_GEOPARQUET_TTL_S).
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.
STAC Β§15 Item Search. Supported predicates:
bbox=min_lng,min_lat,max_lng,max_lat— pushed into PostGIS asST_Intersects(geometry, ST_MakeEnvelope(...)).datetime=instantordatetime=start/end(use..for open ends).collections=a__b,c__d— comma-separated Collection ids.limit(1–500, default 50) andcursorfor opaque HMAC-signed pagination.
Responses include
numberMatched (total across all pages, via
COUNT(*)) and numberReturned
(this page).
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
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
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
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
-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.
TypeScript / JavaScript — @geog-ai/sdk
# or pnpm add @geog-ai/sdk
# or yarn add @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
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.
POST /simulate/plumeβAsyncJobAccepted({ ok, job: { id, status: "queued", eta_seconds, β¦ } }). Noresult. The 202 returns immediately, usually in < 100 ms.GET /jobs/{job_id}βJobResponse<TResult>({ ok, job: { id, status, β¦ }, result?: TResult, meta }).resultis populated only whenjob.status === "complete"; on"failed"inspectjob.error/meta.
// 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);
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).
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);
}
"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).
Other languages
Need Go, Ruby, Java, C#, or Rust? Generate a client straight from the spec:
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
Endpoint Quick Reference
| Method | Path | Description | Price |
|---|---|---|---|
| GET | /context | Device SpatialState resolution | $0.001 |
| GET | /context/batch | Batch SpatialState, up to 500 devices | $0.001/device |
| GET | /nearby | Proximity search with type filter | $0.001 |
| GET | /within | Polygon containment query | $0.001 |
| GET | /jurisdiction | Regulatory jurisdiction for any coordinate | $0.0005 |
| POST | /simulate/plume | Atmospheric dispersion (async) | $0.05β$0.25 |
| POST | /simulate/plume/stream | Live plume tracking via SSE (Site Calibration plans) | $0.25 |
| POST | /simulate/flood | Flood & hydrology simulation (async) | $0.10 |
| POST | /simulate/rf_coverage | Single-node RF coverage (async) | $0.08 |
| POST | /simulate/noise | Noise propagation ISO 9613-2 (async) | $0.08 |
| POST | /simulate/thermal | Thermal / heat diffusion (async) | $0.08 |
| POST | /simulate/fire | Fire spread simulation (async) | $0.15 |
| GET | /rf/coverage | Coverage polygon for registered node | $0.08 |
| GET | /rf/link | Point-to-point link budget | $0.03 |
| GET | /rf/gaps | RF coverage gap heatmap | $0.05 |
| POST | /rf/mesh/viability | Full mesh connectivity matrix (async) | $0.15 |
| POST | /rf/optimize/placement | RF node placement optimizer (async) | $1β$5 |
| POST | /optimize/node_placement | Multi-modal node placement (async) | $1β$5 |
| POST | /optimize/sensor_placement | Vertical-aware sensor placement (async) | $2β$5 |
| GET | /coverage | Monitoring coverage fraction for area | $0.01 |
| GET | /gaps | Monitoring gap geographic detail | $0.01 |
| GET | /trajectory | Historical trajectory for entity | $0.002 |
| POST | /trajectory/predict | Future trajectory prediction | $0.005 |
| GET | /graph/influence | Downstream influence from source node | $0.005 |
| GET | /graph/evacuate | Hazard-aware evacuation routing | $0.01 |
| GET | /graph/upstream | Upstream sources for root cause analysis | $0.005 |
| GET | /model/calibration | Site model calibration state and history | Site plan |