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.

StepSurfaceScope
Subscribeevent_subscriptions.createevent_subscriptions:write
Receive + verifyyour HTTPS endpoint
List what was deliveredGET /orders/{order_id}/assetsitems:read
Mint a signed URLGET /api/v1/items/{item_id}/assets/{asset_id}/download-urlitems:read
Fetch the bytesthe 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 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:

readytrue 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