Off-Nadir Delta — Python SDK
Official Python client for the Off-Nadir Delta geospatial event-intelligence API and MCP server. Query geolocated, source-linked event signals, search satellite imagery (STAC), and run AI assessments and ask the analyst agent — all from Python. The AI tools are available on every plan, including Free — gated only by your token balance.
- Sync (
Client) and async (AsyncClient) — one dependency (httpx). - Fully typed responses (Pydantic v2), forward-compatible with additive API fields.
- Automatic cursor pagination, retry with backoff, and a uniform error model.
- A thin MCP client (
McpClient/AsyncMcpClient) for the JSON-RPC MCP endpoint.
Requires Python 3.9+. Licensed under Apache-2.0.
Install
pip install offnadir-delta
Authentication
Create an API key from your account's Developer API page. Keys start with ond_ and are
shown once. Pass it explicitly or via the OFFNADIR_DELTA_API_KEY environment variable.
from offnadir_delta import Client
client = Client(api_key="ond_...") # or: Client() -> reads OFFNADIR_DELTA_API_KEY
The API meters usage in tokens against the same wallet as the web app. Every metered
response reports what it cost under meta.tokens. Check your balance for free first:
usage = client.usage()
print(usage.tokens.remaining, "tokens left, LLM access:", usage.plan.api_llm_access)
Quickstart
from offnadir_delta import Client
with Client() as client:
# One page of Severity 6+ signals placed to a town or finer in an AOI over a week
# (bbox = [min_lon, min_lat, max_lon, max_lat]).
page = client.signals.list(
bbox=[22, 44, 40, 53],
start_date="2026-09-21",
end_date="2026-09-27",
min_severity_band=6,
stages=["localized", "pinpointed"],
limit=100,
)
print(page.meta.count, "signals,", page.meta.tokens.charged, "tokens charged")
for s in page.signals:
print(s.occurred_at, s.country_code, s.title, s.severity_score, s.stage)
Auto-pagination
signals.iterate() follows the cursor for you (each page is a separately-metered request, charged
by the rows it returns — page.meta.tokens.charged reports it):
for signal in client.signals.iterate(bbox=[22, 44, 40, 53], recency="24h", min_severity_band=6):
print(signal.title)
The filters are the app's, with the same choices — the API accepts nothing the app does not offer, and a retired parameter is refused (400) with its replacement named:
| Where in the app | Parameter | Choices |
|---|---|---|
| Watchfloor date range | start_date, end_date |
YYYY-MM-DD, both or neither |
| Watchfloor time window | recency |
"15m", "1h", "24h" (default without a date range) |
| Watchfloor search | q |
every word must match; "two words"; -word excludes |
| Watchfloor category / market | categories, markets |
lists |
| Watchfloor stage | stages |
reported, localized, pinpointed |
| Watchfloor severity | min_severity_band |
4, 6, 9 |
| Watchfloor publishers | min_publishers |
2, 3, 5 |
| Watchfloor location | placement |
"all", "map" (drawable as a point), "list" |
| Watchfloor sort | sort |
"latest" (default), "oldest", "geoint" |
The app's Map uses these same filters (since 2.0.0). The Map's own filters — observable, sensors,
collection_levels, escalation_trends — were removed from the app and from the API; read
collection.observable, collection.sensor, collection.level and escalation_trend on each signal.
updated_since (only signals refolded at or after an ISO 8601 timestamp) is for incremental
sync. On Free, the Watchfloor filters beyond category (q, stages, min_severity_band,
min_publishers, markets, placement) and the 15m / 1h windows are not applied, and
page.meta.filter_clamp lists what was dropped — the same rule as the Watchfloor.
Fetch one signal by id
signal = client.signals.get("3f0c…") # the `id` (a UUID) from a list() result
print(signal.title, signal.severity_score)
Satellite imagery (STAC search)
Metadata only — no image bytes, no signed URLs. bbox is required.
scenes = client.imagery.search(
bbox=[22, 44, 40, 53],
collection="sentinel-2-l2a",
cloud_cover_max=20,
start_date="2026-09-14",
end_date="2026-09-27", # omit both for the last 7 days
)
for scene in scenes:
print(scene.id, scene.datetime, scene.cloud_cover)
Optical-observability weather
Cloud cover is what gates a Sentinel-2 optical pass. Get a per-day outlook (cloud
at the ~10:30 local overpass, best clear day) to decide optical vs SAR collection.
Requires a deployment with a commercial Open-Meteo licence. Weather data by
Open-Meteo.com (CC BY 4.0) — surface result.weather.attribution.
result = client.weather.observability(lat=48.85, lon=2.35, end_date="2026-07-22")
print("best optical day:", result.weather.best_optical_day)
for day in result:
print(day.date, day.optical_verdict, day.overpass_cloud_cover_pct)
Collection planning & tasking
The collection manager's half of the product: what is worth imaging, which sensor class answers it, and when a satellite can next see it.
# The deterministic plan for ONE event — each collection searched exactly once against
# the event footprint, so the all-weather (SAR) look cannot be missed.
plan = client.collection.plan(event_id=signal.id, analysis_goal="damage_assessment")
for step in plan.plan.steps:
print(step.collection, step.usable_count, step.sar_pair_status)
# Where observation is most worthwhile. Counts are reported separately from the returned
# slice, so "2 returned" is never mistaken for "2 exist".
ranked = client.collection.priority(bbox=[22, 44, 40, 53], top_n=5)
print(ranked.priority.returned, "of", ranked.priority.total_available)
for target in ranked.targets:
print(target.headline, target.rs_level, target.collection_ready, target.readiness_blockers)
# Exhaustive per-sensor survey — the misses are reported too, not just the hits.
survey = client.collection.observability(sensor="sentinel-1", top_n=10).survey
print(survey.observable_count, "observable /", survey.not_observable_count, "not")
# Next viewing opportunities. `collection_mode` is the field that matters: a systematic
# satellite acquires on a fixed plan, an agile one only images if somebody tasks it.
for p in client.collection.passes(lat=26.6, lon=56.5, max_passes=5).passes:
print(p.satellite, p.collection_mode, p.peak_elevation_deg, p.sunlit)
Standing orders
Put an area under continuous watch — the app's "Watch this area": the area is checked weekly,
and you are notified only when something crossed the reporting bar. It takes the area, a
name and notify_email. Creating and listing are free — only a check that
actually fires runs the Analyst and is metered. The order joins the Watchlist, and you pause
or remove it there, as in the app.
order = client.standing_orders.create(bbox=[55, 25, 57, 27], name="hormuz")
print(order.summary) # states the monthly token ceiling up front
client.watches.pause(order.watch_id) # pause, keep the order
client.watches.delete(order.watch_id) # removes everything bound to that watch
Notifications (free)
Every change a watch reports — an event development, a newly related event, an anomaly, a new
event at a site, a new scene, a fired standing order — is one notification with a read state. It
is the same list as the bell in the app, so marking one read here clears it there too. A value
your plan does not open is locked, with lock_plan naming the plan that opens it.
page = client.notifications.list(unread_only=True)
print(page.unread_count)
for n in page:
print(n.at, n.watch["name"], n.line)
client.notifications.mark_read(ids=[n.id for n in page]) # or mark_read(all=True)
Webhook deliveries (free)
Watch notifications can also be POSTed to an https endpoint of your own, signed so the receiver can tell they came from Off-Nadir Delta unchanged. Deliveries run with the hourly notifications; each change says what moved, with its value before and after where your plan opens it. One channel per account (it replaces a connected Slack or Discord channel).
channel = client.notifications.channel.connect_webhook("https://example.com/delta-hook")
secret = channel.signing_secret # returned only here; store it
# In the receiver — pass the raw body bytes, not re-serialised JSON:
from offnadir_delta import WebhookSignatureError, verify_webhook
try:
event = verify_webhook(raw_body, headers["X-Delta-Signature"], secret)
except WebhookSignatureError:
... # answer 400
for change in event["changes"]:
print(event["watch"]["name"], change["line"]) # plus label / from / to when the plan opens them
# Missed some while the receiver was down? Send them again (up to 7 days per call):
client.notifications.channel.replay(since="2026-10-18T00:00:00Z")
A complete receiver using only the standard library is in examples/webhook_receiver.py.
Measured events (metered per page, by rows returned)
Six kinds of event are made from a measurement or an official record rather than from reporting.
They appear in signals.list(categories=[...]) like any other event, and each has a reader that
returns the values behind it. A record states what was recorded and never a cause.
| Reader | What a record is |
|---|---|
client.thermal_anomalies |
a heat source far above its own recent level (satellite) |
client.transit_anomalies |
ship transits at a strait or canal against their usual level (an index, never ship counts) |
client.nightlight_anomalies |
a city's night lights below half of its own usual level (satellite) |
client.so2_anomalies |
sulfur dioxide above the local background around a volcano (satellite) |
client.quake_exposures |
a USGS earthquake with registered facilities within a set distance — distances, not shaking or damage |
client.cyclone_exposures |
registered places inside a tropical cyclone's forecast wind radii — a forecast (Atlantic, eastern Pacific and central Pacific only), not an observation |
for q in client.quake_exposures.iterate(start_date="2026-10-01", end_date="2026-10-07"):
if q.detail_withheld: # the plan returns when and where, not the values
continue
eq = q.earthquake
print(eq["magnitude"], eq["depth_km"], q.radius_km, [f["name"] for f in q.facilities])
page = client.cyclone_exposures.list()
print(page.meta["notes"]) # what the record does and does not establish
print(page.meta["attribution"]) # the source credit — show it with the data
for storm in page.cyclone_exposures:
print(storm.storm, storm.places_inside_now_count, storm.places_inside_forecast_count)
thermal_anomalies and the four below it take bbox, start_date / end_date, limit and
cursor; transit_anomalies.list() takes no arguments. On a plan without the full event detail
every record is still returned with detail_withheld=True — None in a field then means "not
shown", not "unknown".
Version probe (no API key)
Checking which build is live must not require a credential, so this one works without a client. The three hashes fingerprint the tool contract — compare them to detect a stale cached roster.
from offnadir_delta.resources.version import probe
v = probe()
print(v.version, v.tool_count, v.schema_hash)
AI assessment & analyst
Available on every plan, including Free — gated only by your token balance (an
insufficient balance raises InsufficientTokensError). Check client.usage() first.
signal = client.signals.list(recency="24h").signals[0]
assessment = client.intelligence.assess(event_id=signal.id, kind="quick") # 5 quick / 15 deep tokens
print(assessment.content)
answer = client.intelligence.analyst(
"What is escalating in the Black Sea this week?",
bbox=[22, 44, 40, 53],
) # metered 5-123 tokens
print(answer.brief)
assess is cached per (account, event, kind), so re-assessing the same event is free.
A signal with no satellite-imageable footprint raises NotObservableError (422) before
any token is charged — pre-filter on signal.collection.observable from signals.list.
A signal whose observability has not been assessed yet (signal.collection.level is null)
raises NotAssessedError (422), also before any charge — it is not a verdict that the event
is invisible.
Long analyst runs (async jobs)
The analyst is hybrid-async: a run that finishes within ~95s returns the AnalystBrief
directly (as above); a longer run returns an AnalystJobPending and completes in the
background — poll it for free, and pay only when it completes. Pass an idempotency_key
so a re-send resolves to the same run instead of being charged again (without one,
analyst is not idempotent and is never retried automatically):
from offnadir_delta import AnalystBrief
result = client.intelligence.analyst(
"Compare naval activity across the Black Sea and Baltic this month",
idempotency_key="black-sea-baltic-2026-07-17",
)
if not isinstance(result, AnalystBrief): # AnalystJobPending (HTTP 202)
job = client.intelligence.get_job(result.job_id) # free; status: running|done|error
print(job.status)
Or let the SDK poll to completion for you (sync and async):
answer = client.intelligence.analyst_and_wait(
"Compare naval activity across the Black Sea and Baltic this month",
idempotency_key="black-sea-baltic-2026-07-17",
poll_interval=5.0,
timeout=300.0,
)
print(answer.brief)
On timeout this raises APITimeoutError, but the run keeps going server-side —
re-fetch the brief later via get_job(job_id) (or the same idempotency_key) at no
extra charge.
Daily World Brief (free)
brief = client.brief.get() # latest; or client.brief.get("2026-07-11")
Terrain elevation (free)
Heights come from the Copernicus DEM GLO-30. Free and unmetered — a deterministic read of a public dataset.
point = client.elevation.point(44.9695, 24.3636)
print(point.point.elevation_m, "m") # 260.0
area = client.elevation.area([24.24, 44.93, 24.48, 45.01])
print(area.stats.relief_m, "m of relief") # max - min
ring = [[24.3, 44.9], [24.4, 44.9], [24.4, 45.0], [24.3, 44.9]]
inside = client.elevation.polygon(ring) # statistics over the ring interior
print(inside.stats.measured_over) # "polygon_interior"
Three things the answer carries that you should not flatten away:
- it is a surface model (
surface_type == "dsm"): buildings and tree canopy are included, so it is not bare ground; downsampled=Truemeans a large box was read below native posting —min_m/max_mare smoothed inward, so the true relief is at leastrelief_m;covered=False(point) ortiles_missing > 0(area) means open water, where the model has no value.elevation_misNonethere — that is absence, not 0 m.
Terrain-derived analysis (free)
Two computations from the DEM itself, beyond reading heights out of it.
# How much of an area radar cannot use, for a given geometry.
g = client.terrain.sar_geometry(
[138.70, 35.34, 138.76, 35.38], incidence_deg=35, look_azimuth_deg=270
).sar_geometry
print(g.layover_fraction, g.shadow_fraction, g.mean_local_incidence_deg)
# Can this position see that one?
los = client.terrain.profile(35.3604, 138.7271, 35.5171, 138.7519).profile.line_of_sight
print(los.clear, los.blocked_at_m, los.blocking_elevation_m)
incidence_deg and look_azimuth_deg have no defaults on purpose: the answer changes with both.
Measured on one volcanic flank, layover was 2.6% looking west and 0.7% looking east at the same 35°,
so a default would answer about a geometry you did not ask for. look_azimuth_deg is the compass
bearing the sensor looks along the ground range — a right-looking descending pass looks roughly west
(270).
Both results carry sample_spacing_m, and it belongs in any conclusion drawn from them: a coarser
grid reads flatter, and therefore more observable, than the ground is.
How an index changed over the archive (metered per scene)
The retrospective half of monitoring: same measurement, run backwards over Sentinel-2 rather than forwards over new acquisitions. Estimate first — it is free, and it is the only way to know the cost before you spend it.
plan = client.index_series.estimate(
"ndvi", "2013-01-01", "2026-08-01", bbox=[67.64, 33.31, 67.74, 33.84]
).estimate
print(plan.scenes_found, plan.scenes_measurable, plan.estimated_tokens)
for note in plan.notes:
print(note) # the archive floor, and whether this will be a sample
series = client.index_series.measure(
"ndvi", "2020-01-01", "2026-08-01", polygon=[[67.6, 33.3], [67.7, 33.3], [67.7, 33.4]]
).series
for s in series:
print(s.date, s.mean, s.cloudCover)
print(series.trend.change, series.trend.basis)
Three things the response states rather than hides, and which belong in anything built on it:
- the Sentinel-2 archive begins 2015-06-27. An earlier
startis moved forward andclamped_to_archivesays so — those years are genuinely unavailable, not empty; - at most 24 scenes are measured per call, so a longer period comes back as a sample, with
every unmeasured scene listed in
skippedwith its reason; trendcompares the first and last measured scene only (basissays exactly that). It is not a fitted rate, so do not attach a slope or a confidence to it.
Only a scene actually read is charged; a failed read appears in skipped and costs nothing.
Counting vessels in a SAR scene (metered)
Radar sees through cloud and at night, so a count works where optical returns nothing. Search the catalog, pick a scene, then detect on it.
scene = client.imagery.search(bbox=[37.79, 44.69, 37.83, 44.72], collection="sentinel-1-grd")[0]
result = client.ships.detect("sentinel-1-grd", scene.id, bbox=[37.79, 44.69, 37.83, 44.72])
print(result.count)
for c in result.caveats:
print(c)
caveats is part of the answer, not a footnote. It states when the land mask was unavailable
(shoreline structures may be counted as vessels), how far from the coast detections were excluded
(the default excludes vessels alongside a quay), and when the scene covered only part of the area —
a partial-coverage count must never be compared with a full one as though the difference were
vessels. A scene the worker refuses is not charged.
This measures one scene at one time; to track a berth over time, create a monitored area with
metric ships instead.
Continuous measurement (Delta Monitor)
Free to create and to read; metered only per scene actually measured.
for area in client.monitoring.list():
print(area.name, area.metric, area.latest_value, area.anomaly)
client.monitoring.create([139.7, 35.6, 139.8, 35.7], "ndwi", name="Berth 4")
series = client.monitoring.get(area_id) # every point traceable to its STAC item
Data freshness / pipeline status (free)
Pre-flight whether the underlying data is fresh enough before spending on a metered query.
status = client.status.get() # or client.status.current()
print(status.pipeline_status, "— data current through", status.data_current_through)
Async
Every call has an await-able equivalent on AsyncClient:
import asyncio
from offnadir_delta import AsyncClient
async def main():
async with AsyncClient() as client:
async for signal in client.signals.iterate(bbox=[22, 44, 40, 53], recency="24h"):
print(signal.title)
asyncio.run(main())
Error handling
All failures raise a subclass of OffnadirError carrying .status_code, .code, and
.request_id (quote it in support requests):
from offnadir_delta.errors import (
AuthenticationError, PermissionDeniedError, InvalidRequestError,
NotObservableError, NotAssessedError, InsufficientTokensError, RateLimitError, APIError,
)
try:
client.signals.list(bbox=[22, 44, 40, 53])
except InsufficientTokensError as e:
print("Need", e.required, "have", e.available)
except RateLimitError as e:
print("Retry after", e.retry_after, "seconds")
Transient failures (429, 5xx, connection errors) are retried automatically with backoff
(honoring Retry-After); configure with Client(max_retries=...). The last response's
rate-limit headers are available on client.last_rate_limit.
MCP
The API also exposes a stateless MCP server at
/api/v1/mcp (JSON-RPC 2.0). MCP hosts such as Claude connect to it directly over
OAuth 2.1 — see the docs. For programmatic use with a
static API key, this SDK ships a thin client:
from offnadir_delta import McpClient
with McpClient() as mcp:
mcp.initialize()
print([t["name"] for t in mcp.list_tools()])
result = mcp.call_tool("query_signals", {"bbox": [22, 44, 40, 53], "recency": "24h"})
brief = mcp.read_resource("brief://latest")
Tools: query_signals, search_imagery,
assess_weather_observability (optical outlook; commercial weather licence only),
get_world_brief, get_usage, assess_signal (metered), ask_analyst (metered) — all
available on every plan, gated only by token balance.
Development
pip install -e ".[dev]"
ruff check src tests
mypy src
pytest
Tests run fully offline (HTTP mocked with respx) — they never call the live API or spend
tokens.
License
Metadata
Release files for offnadir-delta 5.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| offnadir_delta-5.2.0.tar.gz | 126.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| offnadir_delta-5.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 225.7 kB
Release files / offnadir_delta-5.2.0.tar.gz
| Download URL | offnadir_delta-5.2.0.tar.gz |
|---|---|
| Size | 126.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
018250c034056af008ffc09ac73f8636ffafa5db1333cdf20fea83ef82538a2a
|
|
BLAKE2b-256 checksum How to use checksums |
a8b40ad1a2038c5c46ac01abf982b736e0350e11d34f897bbeef024a255bf218
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.13
|
Release files / offnadir_delta-5.2.0-py3-none-any.whl
| Download URL | offnadir_delta-5.2.0-py3-none-any.whl |
|---|---|
| Size | 99.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d4d111228a0a546ef526de2b9975c62afff8d596b22daafef8268f6642e51505
|
|
BLAKE2b-256 checksum How to use checksums |
a566408c73c5e3d4bd87afcc37a14fc4125adadfdd6f87eb591510d5a8e674c9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.13
|
Security for offnadir-delta 5.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.