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

# Auto-downloading orders

When an order finishes, Geopera can call your systems instead of you polling ours.
This guide wires the whole chain: a webhook subscription on `order.fulfilled`, a
receiver that verifies and acknowledges the delivery, and a downloader that fetches
every delivered asset into your own storage.

| Step                     | Surface                                          | Scope                       |
| ------------------------ | ------------------------------------------------ | --------------------------- |
| Subscribe                | `event_subscriptions.create`                     | `event_subscriptions:write` |
| Receive + verify         | your HTTPS endpoint                              | —                           |
| List what was delivered  | `GET /orders/{order_id}/assets`                  | `items:read`                |
| Mint a signed URL        | `GET /api/v1/items/{item_id}/assets/{asset_id}/download-url` | `items:read`    |
| Fetch the bytes          | the signed URL                                   | — (no auth; it expires)     |

Everything after the webhook works with an API key sent as
`Authorization: Bearer $GEOPERA_API_KEY`. A key with **read** permission is enough
for the download half; creating the subscription needs **write**.

## 1. Subscribe to `order.fulfilled`

```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",
    "description": "Auto-download delivered orders"
  }'
```

The response contains the subscription's **signing secret** (`whsec_…`), shown
**once** — store it now. If you lose it, delete the subscription and create a new
one; it cannot be recovered. The endpoint must be HTTPS on a publicly resolvable
host (private and internal addresses are rejected).

Fire a real test delivery before relying on it:

```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_…" }'
```

If you also want an early signal, subscribe a second time to
`order.being_fulfilled` — it fires when the first delivery batch lands, with a
`newItemCount` in its payload. Fulfilment of a large order can arrive in batches;
`order.fulfilled` is the terminal "everything is in" signal to download on.

## 2. What the webhook carries — and deliberately does not

```json
{
	"event_id": "b1c0…",
	"event_type": "order.fulfilled",
	"timestamp": "2026-08-25T02:14:09Z",
	"organization_id": "c4e2…",
	"entity_type": "order",
	"entity_id": "9f31…",
	"data": {
		"fromStatus": "BEING_FULFILLED",
		"toStatus": "FULFILLED",
		"creditsCaptured": 1240,
		"creditsRefunded": null,
		"failureReason": null
	}
}
```

The order id is **`entity_id`**. The payload carries no item ids, asset ids, or
download links — a webhook is a doorbell, not the parcel. The manifest of what was
delivered comes from the API in the next step, over your authenticated connection,
which is also what makes a forged or replayed webhook harmless: it can only ever
prompt you to fetch your own data.

Verify the `X-Geopera-Signature` header (HMAC-SHA256 over the **raw body**, keyed by
your `whsec_…` secret) before acting, and dedupe on `X-Geopera-Event-ID` — delivery
is at-least-once, so retries and rare duplicates are normal. The
[webhooks guide](/api-reference/guides/webhooks) covers both in detail.

## 3. List what was delivered

```bash
curl -s https://api.geopera.com/orders/$ORDER_ID/assets \
  -H "Authorization: Bearer $GEOPERA_API_KEY"
```

The response is a STAC `FeatureCollection`: one feature per delivered item, each
with its assets keyed by name:

```json
{
	"order_id": "9f31…",
	"collection_id": "c77a…",
	"project_id": "41d2…",
	"item_count": 8,
	"type": "FeatureCollection",
	"features": [
		{
			"type": "Feature",
			"id": "JL1GF03D…_scene",
			"item_id": "0b6e…",
			"collection": "c77a…",
			"geometry": { "type": "Polygon", "coordinates": [] },
			"bbox": [116.1, -25.4, 116.4, -25.1],
			"properties": { "datetime": "2025-03-22T02:11:04Z", "gsd": 0.5 },
			"stac_extensions": [],
			"assets": {
				"visual": {
					"id": "5a90…",
					"media_type": "image/tiff; application=geotiff; profile=cloud-optimized",
					"roles": ["data"],
					"file_size_bytes": 734003200,
					"band_names": ["Red", "Green", "Blue"],
					"href": "/api/v1/items/0b6e…/assets/5a90…/download",
					"ready": true
				}
			}
		}
	]
}
```

Two fields matter more than they look:

**`ready`** — `true` only when the asset's bytes actually exist in storage. An order
can be fulfilled while a metadata row's file is still landing; skip `ready: false`
assets and let your reconciliation sweep (below) pick them up.

**`href`** — an authenticated API path, not a public link. Every download is
authorised, rate-limited and egress-logged; nothing in this response leaks a
fetchable URL.

## 4. Mint a signed URL and fetch

Per asset, either follow the `href` (it 302-redirects to a signed URL) or ask for
the URL as JSON:

```bash
curl -s "https://api.geopera.com/api/v1/items/$ITEM_ID/assets/$ASSET_ID/download-url" \
  -H "Authorization: Bearer $GEOPERA_API_KEY"
```

```json
{
	"url": "https://storage.googleapis.com/…",
	"expires_in_seconds": 900,
	"asset_id": "5a90…",
	"size_bytes": 734003200,
	"media_type": "image/tiff; application=geotiff; profile=cloud-optimized"
}
```

The signed URL lives **15 minutes**. Mint it immediately before each fetch rather
than minting a batch up front — on a large order the last URL of a batch is expired
before you reach it. Check the finished file against `size_bytes`.

Downloads pass through three gates worth knowing about: a per-asset daily frequency
cap and a per-organization bandwidth breaker (both return `429` — back off and
resume), and a billing gate (`402` when the organization's subscription is past
due). A `410` means the item passed its retention window.

## 5. A complete receiver

Acknowledge fast, download in the background — a webhook delivery that waits on a
multi-gigabyte transfer will time out and be retried as a failure:

```python
import hashlib, hmac, os, pathlib

import httpx
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request

GEOPERA = "https://api.geopera.com"
API_KEY = os.environ["GEOPERA_API_KEY"]        # read permission is enough here
SECRET = os.environ["GEOPERA_WEBHOOK_SECRET"]  # the whsec_… shown once at create
DEST = pathlib.Path("/data/geopera")
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

EXT = {"tiff": ".tif", "geo+json": ".geojson", "jpeg": ".jpg", "png": ".png"}

app = FastAPI()
seen: set[str] = set()  # replace with a durable store in production


def verify(raw: bytes, signature: str) -> bool:
    expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")


def ext_for(media_type: str) -> str:
    return next((v for k, v in EXT.items() if k in (media_type or "")), ".bin")


def download_order(order_id: str) -> None:
    with httpx.Client(timeout=60.0) as client:
        manifest = client.get(f"{GEOPERA}/orders/{order_id}/assets", headers=HEADERS)
        manifest.raise_for_status()
        for feature in manifest.json()["features"]:
            item_id, name = feature["item_id"], feature["id"]
            for key, asset in feature["assets"].items():
                if not asset["ready"]:
                    continue  # the reconciliation sweep re-checks these
                target = DEST / order_id / name / f"{key}{ext_for(asset['media_type'])}"
                if target.exists() and target.stat().st_size == asset["file_size_bytes"]:
                    continue  # already have it — reruns are cheap and safe
                # Mint the signed URL immediately before fetching: it lives 15 minutes.
                minted = client.get(
                    f"{GEOPERA}/api/v1/items/{item_id}/assets/{asset['id']}/download-url",
                    headers=HEADERS)
                minted.raise_for_status()
                target.parent.mkdir(parents=True, exist_ok=True)
                with client.stream("GET", minted.json()["url"]) as r:
                    r.raise_for_status()
                    with open(target, "wb") as f:
                        for chunk in r.iter_bytes():
                            f.write(chunk)


@app.post("/hooks/geopera")
async def receive(request: Request, background: BackgroundTasks,
                  x_geopera_signature: str = Header(""),
                  x_geopera_event_id: str = Header("")):
    raw = await request.body()
    if not verify(raw, x_geopera_signature):
        raise HTTPException(status_code=401)
    if x_geopera_event_id in seen:  # retries and duplicates are normal
        return {"ok": True}
    seen.add(x_geopera_event_id)
    event = await request.json()
    if event["event_type"] == "order.fulfilled":
        background.add_task(download_order, event["entity_id"])
    return {"ok": True}  # any 2xx acknowledges; non-2xx or a timeout is retried
```

Because `download_order` skips files it already holds, the same function serves
webhook deliveries, retries, and the reconciliation sweep alike.

## 6. Reconcile — the sweep that catches everything else

Webhook deliveries are retried with backoff and then dead-lettered; a receiver that
is down for long enough misses the notification. Do not let the webhook be the only
path to your data. Run a periodic sweep — hourly is plenty — that lists fulfilled
orders and downloads anything missing:

```python
def reconcile(client: httpx.Client) -> None:
    page = 0
    while True:
        r = client.post(f"{GEOPERA}/v1/op/orders.list", headers=HEADERS,
                        json={"status": "FULFILLED", "page": page, "size": 50})
        r.raise_for_status()
        body = r.json()          # a page envelope: content / last / totalElements
        for order in body["content"]:
            download_order(order["id"])
        if body["last"]:
            break
        page += 1
```

The sweep also picks up assets that were `ready: false` at webhook time, and orders
fulfilled while your endpoint was being deployed. With the sweep in place, the
webhook's job is latency, not completeness — which is the right division of labour
for both.

## Things worth knowing

**One subscription, one event type.** Subscribe separately for
`order.being_fulfilled` or `job.completed` if you want them. For processing jobs,
`job.completed` *does* carry `item_ids` in its payload; order events do not.

**Keep the handler idempotent.** Dedupe on `X-Geopera-Event-ID`, make downloads
skip-if-present, and treat a re-delivered event as a free consistency check.

**Respond within 10 seconds.** That is the delivery timeout; any 2xx counts as
acknowledged, anything else is a failed attempt.

**The secret is per-subscription and shown once.** Rotating it means recreating the
subscription. Custom `headers` on the subscription can carry your own routing token
in addition to the signature.

**Downloads are metered.** Signed-URL issuance is egress-logged, and the 429s from
the frequency and bandwidth gates are backpressure, not errors — retry after a
delay rather than hammering.

## Related

- [Webhooks](/api-reference/guides/webhooks) — signature verification, delivery
  guarantees, and the full event catalogue.
- [Ordering archive imagery](/api-reference/guides/ordering) — placing and tracking
  the orders this guide downloads.
- [Bulk ordering with cloud-aware selection](/api-reference/guides/batch-ordering) —
  ordering at the scale where auto-download stops being optional.
