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

# Webhooks

Webhooks let your systems react to what happens on Geopera without polling. Subscribe an
HTTPS endpoint to one or more event types, and Geopera delivers a signed `POST` to it
whenever a matching event occurs — an order is delivered, a processing job finishes, an
upload completes, your credit balance runs low.

| Step | Operation | Scope |
|---|---|---|
| Subscribe | `event_subscriptions.create` | `event_subscriptions:write` |
| List / inspect | `event_subscriptions.list` / `.get` | `event_subscriptions:read` |
| Update / pause | `event_subscriptions.update` | `event_subscriptions:write` |
| Test (real delivery) | `event_subscriptions.test` | `event_subscriptions:write` |
| Remove | `event_subscriptions.delete` | `event_subscriptions:write` |

## Subscribe

```bash
curl -s -X POST https://api.geopera.com/v1/op/event_subscriptions.create \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "order.fulfilled",
    "endpoint_url": "https://your-app.example.com/hooks/geopera",
    "filter_config": {},
    "description": "Notify fulfillment service"
  }'
```

The response includes a **signing secret** (`whsec_…`) — it is shown **once**, on
creation. Store it; you need it to verify deliveries. The endpoint must be **HTTPS** and
publicly resolvable (an SSRF guard rejects private/internal hosts). Use `filter_config`
to narrow deliveries (e.g. `{"entity_type": "item"}` or `{"data.project_id": "…"}`).

## The delivery request

Each delivery is an HTTP `POST` with a JSON body and these headers:

| Header | Meaning |
|---|---|
| `X-Geopera-Signature` | `sha256=<hmac>` — HMAC-SHA256 of the raw body, keyed by your secret |
| `X-Geopera-Event` | the event type (e.g. `order.fulfilled`) |
| `X-Geopera-Event-ID` | the event's unique id (use it to dedupe) |
| `X-Geopera-Delivery-ID` | the unique id of *this* delivery attempt |

Body:

```json
{
  "event_id": "b1c0...",
  "event_type": "order.fulfilled",
  "timestamp": "2026-06-13T04:21:00Z",
  "organization_id": "c4e2...",
  "entity_type": "order",
  "entity_id": "f2b1...",
  "data": { "...": "event-specific fields" }
}
```

Respond with any `2xx` to acknowledge. A non-2xx (or a timeout) is treated as a failed
delivery and retried (see below). Acknowledge quickly and do slow work asynchronously.

## Verify the signature

**Always verify the signature before trusting a webhook.** Recompute the HMAC-SHA256 of
the *raw request body* with your subscription secret and compare it, constant-time, to
the `X-Geopera-Signature` header.

```python
import hmac, hashlib

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")
```

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(signatureHeader || "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Sign over the **exact bytes** you received, before any JSON re-serialization — a
re-encoded body will not match.

## Delivery guarantees

Delivery is **durable and at-least-once**:

- Every event→subscription delivery is recorded as a durable row the moment the event
  occurs, so a delivery is never lost if a server restarts mid-flight.
- Failed deliveries are **retried with exponential backoff** (minutes → hours) until they
  succeed or are exhausted, then **dead-lettered** for inspection.
- Because retries (and rare duplicates) are possible, make your handler **idempotent** —
  dedupe on `X-Geopera-Event-ID`.

Order is **not** guaranteed; use the `timestamp` and your own state if ordering matters.

## Test a subscription

`event_subscriptions.test` sends a real signed `POST` to your endpoint **and returns the
actual outcome**, so you can confirm connectivity before relying on it (it works even
while the subscription is paused):

```bash
curl -s -X POST https://api.geopera.com/v1/op/event_subscriptions.test \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "subscription_id": "sub_…" }'
```

```json
{ "status": "delivered", "delivered": true, "http_status": 200,
  "event_id": "…", "event_type": "order.fulfilled" }
```

A `status` of `failed` (with the HTTP status / error) means your endpoint didn't accept
the POST — fix it and test again.

## Event catalogue

Discover the live list programmatically via the `event-types` endpoint (or the SDK's
`webhooks.event_types()`). The events that fire today include:

| Domain | Events |
|---|---|
| Orders | `order.created`, `order.placed`, `order.being_fulfilled`, `order.fulfilled`, `order.failed`, `order.cancelled` |
| Tasking | `feasibility.requested`, `feasibility.answered`, `quotation.requested`, `quotation.accepted`, `quotation.rejected` |
| Processing jobs | `job.created`, `job.started`, `job.captured`, `job.released`, `job.completed`, `job.failed` |
| Items & data | `item.created`, `item.updated`, `item.deleted`, `asset.deleted`, `upload.completed` |
| Collections & projects | `collection.created`, `collection.updated`, `project.created` |
| Analytics | `analytics.executed` |
| Billing | `topup.succeeded`, `credits.purchased`, `credits.low_balance`, `credits.depleted`, `topup.failed`, `payment_method.attached`, `payment_method.detached` |

Subscribe to one event type per subscription; create several subscriptions (or use the
SDK's `subscribe(events=[…])`) to cover multiple.

## In the SDKs

The Python SDK wraps this surface — see [Python SDK → Webhooks](/api-reference/sdks/python#webhooks-event-subscriptions).
Everything here is also reachable as plain operations from the
[TypeScript client](/api-reference/sdks/typescript) and the
[MCP agent tools](/api-reference/sdks/mcp).
