<!-- Source: https://docs.geopera.com/api-reference/guides/alerts-and-notifications · Markdown for LLMs -->

# 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](#notifications). Alert rules, by contrast, work with any
principal that belongs to an organization. See [Authentication](/api-reference/authentication)
and [Scopes](/api-reference/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`.

```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": "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`:

```json
{
	"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.

```http
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" }
```

```json
{
	"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:

```http
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" }
```

```json
{
	"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`.

```http
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`).

```http
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 }
```

```json
{
	"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`.

```http
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.list` returns `[]`,
- `notifications.unread_count` returns `{ "count": 0 }`,
- the write ops find nothing and return `404` (or `marked_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`).

```http
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 }
```

```json
[
	{
		"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.

```http
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`.

```http
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:

```json
{ "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:

1. **Create the alert rule** with `"webhook"` in `notification_channels` (this guide).
2. **Subscribe an endpoint** to the relevant event type and verify the HMAC signature —
   see the [Webhooks guide](/api-reference/guides/webhooks).

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

```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"]
}
```

```http
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)`.

```python
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.

```typescript
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](/api-reference/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](/api-reference/guides/webhooks) — subscribe to and verify event deliveries.
- [Operations](/api-reference/operations) — authoritative request/response schema for every operation.
- [Scopes](/api-reference/scopes) — the `alerts:*` and `notifications:*` permissions.
- [Pagination](/api-reference/pagination) — `limit`/`offset` conventions for `alerts.events.list` and `notifications.list`.
- [Errors](/api-reference/errors) — the `problem+json` error envelope.
