Alerts & notifications
Alert rules watch your catalog and fire an alert event when a band-math expression
crosses a threshold on a new item; notifications are the per-user inbox that surfaces
those events (and other platform activity) in your app. Both are plain operations — every
call is a POST /v1/op/{operation_id} with a JSON body.
There are two distinct, org- vs user-scoped resources here:
| Resource | Owned by | Scope namespace |
|---|---|---|
| Alert rules & events | your organization | alerts:read / alerts:write |
| Notifications | the authenticated user | notifications:read / notifications:write |
Notifications are keyed to a human user. An API key (gpra_…) or service principal
has no human user behind it, so notification reads return an empty inbox and writes find
nothing — see Notifications. Alert rules, by contrast, work with any
principal that belongs to an organization. See Authentication and Scopes for the token model.
Operations at a glance
| Operation | Side effect | Scope | Purpose |
|---|---|---|---|
alerts.create_rule | compute | alerts:write | Create an alert rule |
alerts.update_rule | compute | alerts:write | Update a rule (partial) |
alerts.delete_rule | destructive | alerts:write | Delete a rule |
alerts.test_rule | read | alerts:read | Dry-run a rule against one item |
alerts.acknowledge_event | compute | alerts:write | Acknowledge a fired event |
alerts.rules.list | read | alerts:read | List rules |
alerts.rule.get | read | alerts:read | Get one rule |
alerts.events.list | read | alerts:read | List fired events |
notifications.list | read | notifications:read | List the caller’s notifications |
notifications.unread_count | read | notifications:read | Count unread notifications |
notifications.mark_read | compute | notifications:write | Mark one notification read |
notifications.mark_all_read | compute | notifications:write | Mark all read |
notifications.dismiss | compute | notifications:write | Hide one notification |
Alert rules
A rule pins an expression (raw band references like b1, b2) to a threshold, plus the
item events it should react to. When a matching item appears, the platform reads the
item’s asset COG, evaluates the expression, and — if the comparison passes — records an
alert event and fans it out over the rule’s notification_channels.
Create a rule
alerts.create_rule is org-scoped: the organization comes from your authenticated
principal, never from the body. Only name, expression, comparison, and threshold are required; everything else has a default.
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required. Human label for the rule. |
expression | string | — | Required. Band math over raw refs, e.g. (b4 - b3) / (b4 + b3). |
comparison | string | — | Required. One of lt, gt, eq, between. |
threshold | number | — | Required. The bound to compare against. |
threshold_upper | number | null | Required only when comparison is between. |
description | string | null | Optional free text. |
collection_id | string | null | Restrict the rule to one collection. |
event_types | string[] | ["item.created"] | Item events that trigger evaluation. |
asset_key | string | "data" | Which item asset to read the COG from. |
band_mapping | object | {} | Map of band name → 1-based index. |
notification_channels | string[] | ["webhook"] | How fired events are delivered. |
cooldown_minutes | integer | 60 | Minimum gap between fires for this rule. |
comparison must be one of lt, gt, eq, between; any other value is a 400.
Choosing between requires threshold_upper — omitting it is also a 400.
POST /v1/op/alerts.create_rule HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{
"name": "NDVI drop on north paddock",
"description": "Flag scenes where mean NDVI falls below 0.3",
"collection_id": "col_a1b2c3",
"event_types": ["item.created"],
"expression": "(b4 - b3) / (b4 + b3)",
"comparison": "lt",
"threshold": 0.3,
"asset_key": "data",
"band_mapping": { "red": 3, "nir": 4 },
"notification_channels": ["webhook", "in_app"],
"cooldown_minutes": 120
}The response wraps the stored row under alert_rule:
{
"alert_rule": {
"id": "ar_7f3c91",
"organization_id": "org_5d2e",
"name": "NDVI drop on north paddock",
"expression": "(b4 - b3) / (b4 + b3)",
"comparison": "lt",
"threshold": 0.3,
"threshold_upper": null,
"asset_key": "data",
"notification_channels": ["webhook", "in_app"],
"cooldown_minutes": 120,
"active": true,
"created_by": "9c1b...-uuid",
"created_at": "2026-06-20T02:10:00Z"
}
}created_by is the human user’s UUID when a JWT principal creates the rule, and null when an API key or service principal creates it.
Test a rule before you trust it
alerts.test_rule evaluates a rule against a single item without persisting anything —
no event, no notification. It reads the item’s asset_key COG, runs the expression, and
reports the mean computed value and whether the comparison would trip. Use it to validate
an expression and threshold against a known scene.
POST /v1/op/alerts.test_rule HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{ "rule_id": "ar_7f3c91", "item_id": "item_abc123" }{
"computed_value": 0.214,
"threshold": 0.3,
"comparison": "lt",
"would_trigger": true,
"expression": "(b4 - b3) / (b4 + b3)",
"item_id": "item_abc123"
}A missing rule is a 404; a missing asset, an unreadable COG, an expression that fails to
evaluate, or a result with no finite pixels each surface as a 400 with a descriptive detail.
List, get, and update
alerts.rules.list returns every rule in your organization, newest first, under an { alert_rules, count } envelope. Narrow with the optional active and collection_id filters:
POST /v1/op/alerts.rules.list HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{ "active": true, "collection_id": "col_a1b2c3" }{
"alert_rules": [{ "id": "ar_7f3c91", "name": "NDVI drop on north paddock", "active": true }],
"count": 1
}alerts.rule.get takes a single rule_id and returns the row under alert_rule. Because
rules are org-scoped, a rule_id that exists under a different organization returns 404 — the row is invisible to you, never leaked.
alerts.update_rule is a partial update: send rule_id plus only the fields you want to
change. An empty patch is a 400, and if you include comparison it is re-validated
against the same four values. The response echoes the updated row under alert_rule.
POST /v1/op/alerts.update_rule HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{ "rule_id": "ar_7f3c91", "active": false, "cooldown_minutes": 240 }alerts.delete_rule (side effect destructive) takes a single rule_id and returns { "deleted": true }. Deleting a rule you don’t own — or one already gone — is a 404.
Alert events
When a rule fires, it records an alert event. List them with alerts.events.list,
which returns { alert_events, count } ordered by triggered_at descending. Filter by rule_id and acknowledged, and page with limit (clamped to 1–200, default 50)
and offset (≥ 0).
POST /v1/op/alerts.events.list HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{ "rule_id": "ar_7f3c91", "acknowledged": false, "limit": 25, "offset": 0 }{
"alert_events": [
{
"id": "ae_4488",
"rule_id": "ar_7f3c91",
"computed_value": 0.214,
"triggered_at": "2026-06-20T03:02:11Z",
"acknowledged": false
}
],
"count": 1
}Clear an event from the unacknowledged queue with alerts.acknowledge_event; it stamps acknowledged / acknowledged_at and returns the row under alert_event. An unknown or
cross-org event_id is a 404.
POST /v1/op/alerts.acknowledge_event HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{ "event_id": "ae_4488" }Notifications
Notifications are the authenticated user’s in-app inbox. They are per-user UI state, not
lineage artifacts, so they are keyed to a human user UUID. A gpra_ API key or service
principal has no human user, so:
notifications.listreturns[],notifications.unread_countreturns{ "count": 0 },- the write ops find nothing and return
404(ormarked_count: 0).
To work with an inbox, authenticate with a user JWT, not an API key.
Read the inbox
notifications.list returns a bare JSON array (not an envelope), newest first. Pass unread_only to skip read items, and page with limit (default 50) and offset (default 0).
POST /v1/op/notifications.list HTTP/1.1
Host: api.geopera.com
Authorization: Bearer eyJhbGci...session-token
Content-Type: application/json
{ "unread_only": true, "limit": 20, "offset": 0 }[
{
"id": "nt_aa10",
"title": "Alert: NDVI drop on north paddock",
"body": "Mean NDVI 0.214 fell below 0.3 on item_abc123",
"read": false,
"dismissed": false,
"created_at": "2026-06-20T03:02:12Z"
}
]notifications.unread_count is a lightweight poll for a badge — it takes an empty body
and returns { "count": N } of unread, undismissed notifications.
POST /v1/op/notifications.unread_count HTTP/1.1
Host: api.geopera.com
Authorization: Bearer eyJhbGci...session-token
Content-Type: application/json
{}Mark read & dismiss
notifications.mark_read and notifications.dismiss each take notification_id and
return the updated row (its id plus any other fields). mark_read flips it to read; dismiss hides it from the list entirely. A notification that isn’t yours, doesn’t exist,
or is already in the target state is a 404.
POST /v1/op/notifications.mark_read HTTP/1.1
Host: api.geopera.com
Authorization: Bearer eyJhbGci...session-token
Content-Type: application/json
{ "notification_id": "nt_aa10" }notifications.mark_all_read takes an empty body and clears the whole unread queue,
returning the count it touched:
{ "marked_count": 7 }How alerts relate to webhooks
A rule’s notification_channels decide where a fired event goes. "in_app" populates the
notification inbox described above; "webhook" delivers the event to your subscribed
HTTPS endpoints. The webhook fan-out runs through the same durable, signed,
at-least-once delivery pipeline as every other platform event — alerts do not ship
their own delivery mechanism.
So a full “alert me in my own systems” loop is two independent setups:
- Create the alert rule with
"webhook"innotification_channels(this guide). - Subscribe an endpoint to the relevant event type and verify the HMAC signature — see the Webhooks guide.
Treat webhook deliveries as at-least-once: dedupe on the delivery’s event id, exactly as
the webhooks guide describes. For polling-based UIs, prefer the "in_app" channel plus notifications.unread_count.
Worked example: monitor, then surface
This walks the whole loop — create a rule, dry-run it, then react to fired events and acknowledge them.
HTTP
POST /v1/op/alerts.create_rule HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{
"name": "Burn-scar NBR alert",
"expression": "(b4 - b6) / (b4 + b6)",
"comparison": "lt",
"threshold": -0.1,
"collection_id": "col_a1b2c3",
"notification_channels": ["webhook", "in_app"]
}POST /v1/op/alerts.events.list HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_your_api_key
Content-Type: application/json
{ "acknowledged": false, "limit": 50 }Python
The Python package is geopera (install with pip install geopera). Every operation is
invoked through client.op(operation_id, body).
from geopera import Geopera
client = Geopera(token="gpra_your_api_key")
# 1. Create the rule.
created = client.op(
"alerts.create_rule",
{
"name": "Burn-scar NBR alert",
"expression": "(b4 - b6) / (b4 + b6)",
"comparison": "lt",
"threshold": -0.1,
"collection_id": "col_a1b2c3",
"notification_channels": ["webhook", "in_app"],
},
)
rule_id = created["alert_rule"]["id"]
# 2. Dry-run it against a known item before trusting it.
dry = client.op("alerts.test_rule", {"rule_id": rule_id, "item_id": "item_abc123"})
print(dry["computed_value"], "would_trigger:", dry["would_trigger"])
# 3. Drain the unacknowledged event queue.
events = client.op(
"alerts.events.list",
{"rule_id": rule_id, "acknowledged": False, "limit": 50},
)
for event in events["alert_events"]:
client.op("alerts.acknowledge_event", {"event_id": event["id"]})TypeScript
The TypeScript SDK is @geopera/sdk (install with npm install @geopera/sdk). It mirrors
the Python idiom: client.op(operationId, body) returns a promise.
import { Geopera } from '@geopera/sdk';
const client = new Geopera({ token: 'gpra_your_api_key' });
// 1. Create the rule.
const created = await client.op('alerts.create_rule', {
name: 'Burn-scar NBR alert',
expression: '(b4 - b6) / (b4 + b6)',
comparison: 'lt',
threshold: -0.1,
collectionId: 'col_a1b2c3',
notificationChannels: ['webhook', 'in_app']
});
const ruleId = created.alert_rule.id;
// 2. Dry-run it.
const dry = await client.op('alerts.test_rule', {
ruleId,
itemId: 'item_abc123'
});
console.log(dry.computed_value, 'would_trigger:', dry.would_trigger);
// 3. Acknowledge everything outstanding.
const events = await client.op('alerts.events.list', {
ruleId,
acknowledged: false,
limit: 50
});
for (const event of events.alert_events) {
await client.op('alerts.acknowledge_event', { eventId: event.id });
}Errors & edge cases
All failures are RFC 9457 problem+json — see Errors for the
envelope. The ones specific to these operations:
| Status | When |
|---|---|
400 | Invalid comparison; between without threshold_upper; empty update patch; alerts.test_rule against a missing/unreadable asset or an expression that yields no finite pixels. |
403 | The principal belongs to no organization (User not in an organization) for any alert operation. |
404 | A rule, event, or notification that doesn’t exist — or exists under another org/user. Cross-tenant ids are never distinguishable from missing ones. |
Notification writes from a non-human principal silently no-op: 404 for single-item ops
and marked_count: 0 for notifications.mark_all_read. If you need machine-driven
alerting, route through the "webhook" channel rather than the inbox.
Related
- Webhooks — subscribe to and verify event deliveries.
- Operations — authoritative request/response schema for every operation.
- Scopes — the
alerts:*andnotifications:*permissions. - Pagination —
limit/offsetconventions foralerts.events.listandnotifications.list. - Errors — the
problem+jsonerror envelope.