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
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:
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
{
"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
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:
{
"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:
curl -s "https://api.geopera.com/api/v1/items/$ITEM_ID/assets/$ASSET_ID/download-url" \
-H "Authorization: Bearer $GEOPERA_API_KEY"{
"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:
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 retriedBecause 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:
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 += 1The 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 — signature verification, delivery guarantees, and the full event catalogue.
- Ordering archive imagery — placing and tracking the orders this guide downloads.
- Bulk ordering with cloud-aware selection — ordering at the scale where auto-download stops being optional.