Idempotency
Send an Idempotency-Key header on side-effecting operations so a retry after a timeout or dropped connection replays the original result instead of executing a second time.
Every operation is invoked as POST /v1/op/{operation_id} with a JSON body. Most of those are reads or pure compute and are naturally safe to retry. The ones that move money or dispatch real work — the external_spend tier — are not: running them twice would place two orders, reserve credits twice, or charge a card twice. Idempotency keys close that gap.
How it works
When you include an Idempotency-Key header, the server performs an atomic claim keyed by the tuple (org, path, key) before it does any work:
- First request for a key — the server claims it, executes the operation, persists the response, and returns it.
- Identical retry within the window — same path, same key, same request body — the server skips execution and replays the stored response byte-for-byte. The operation runs exactly once.
- Key reuse with a different body — same path and key but a different JSON payload — the server refuses with
409 Conflict. A key is bound to the exact request it first saw.
The claim is scoped per organization and per operation path, so the same key value is independent across different ops and across orgs. Replays are served for a 24-hour window; after that the key expires and is free to use again.
This is the same contract every money-path operation now honors, matching the legacy REST routes they replaced: a repeat with the same key yields one order, one credit reservation, one charge — never a duplicate.
Choosing a key
- Generate a unique key per logical operation, not per HTTP attempt. A UUID v4 is ideal. Reuse the same key across all retries of that one operation.
- Keys are opaque strings. Make them unguessable and unique to the request.
- Do not reuse a key for a genuinely new order or top-up — that is what causes a replay (you get the old result) or, if the body changed, a
409.
KEY=$(uuidgen) # generate once, reuse for every retry of THIS orderPassing the header
HTTP
POST /v1/op/orders.archive.place HTTP/1.1
Host: api.geopera.com
Authorization: Bearer gpra_...
Content-Type: application/json
Idempotency-Key: 9d1f8c2a-7b3e-4a16-9f0c-2e1d4b6a8c00
{
"projectId": "your-project-id",
"captures": [
{ "id": "scene-abc", "geometry": { "type": "Polygon", "coordinates": [] } }
],
"licenseType": "standard",
"splitByDate": false
}A retry with the identical header and body replays the first response:
HTTP/1.1 202 Accepted
Content-Type: application/json
{
"id": "f2b1c0de-...",
"status": "awaiting_data",
"orderType": "archive",
"totalCredits": 4200
}curl
curl -s -X POST https://api.geopera.com/v1/op/orders.archive.place \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9d1f8c2a-7b3e-4a16-9f0c-2e1d4b6a8c00" \
-d '{
"projectId": "your-project-id",
"captures": [
{ "id": "scene-abc", "geometry": { "type": "Polygon", "coordinates": [] } }
],
"licenseType": "standard",
"splitByDate": false
}'Python
The geopera package lets you attach the header per call. Pass a fresh key for each new spend operation and reuse it across retries.
import uuid
from geopera import Geopera
client = Geopera(token="gpra_...")
key = str(uuid.uuid4()) # one key for this order, reused on retry
order = client.op(
"orders.archive.place",
{
"projectId": "your-project-id",
"captures": [
{"id": "scene-abc", "geometry": {"type": "Polygon", "coordinates": []}}
],
"licenseType": "standard",
"splitByDate": False,
},
headers={"Idempotency-Key": key},
)
print(order["id"], order["status"])Because the call is configured with the underlying httpx client, you can also set the header on a transient client when you want it applied to a batch of identical retries:
import httpx
with httpx.Client(headers={"Idempotency-Key": key}) as http:
resp = http.post(
"https://api.geopera.com/v1/op/orders.archive.place",
headers={"Authorization": f"Bearer {token}"},
json=body,
)TypeScript
@geopera/sdk accepts per-invoke options, including headers:
import { randomUUID } from 'node:crypto';
import { Geopera } from '@geopera/sdk';
const client = new Geopera({ token: 'gpra_...' });
const key = randomUUID(); // reuse this exact value on every retry
const order = await client.op(
'orders.archive.place',
{
projectId: 'your-project-id',
captures: [{ id: 'scene-abc', geometry: { type: 'Polygon', coordinates: [] } }],
licenseType: 'standard',
splitByDate: false
},
{ headers: { 'Idempotency-Key': key } }
);
console.log(order.id, order.status);Which operations accept it
Idempotency keys apply to the external_spend side-effect tier — the operations that reserve credits, charge a card, or dispatch real work. These are the 17 spend operations:
| Operation | What it does |
|---|---|
orders.archive.place | Place an archive order (reserves credits) |
orders.place | Place an order via the generic order path |
orders.cancel | Cancel an order; refunds or voids the reservation |
orders.tasking.place | Place a tasking order |
orders.tasking.feasibility_decide | Accept/reject a feasibility study (commits spend) |
orders.tasking.quotation_decide | Accept/reject a tasking quotation (commits spend) |
processing.create | Create a processing job (reserves credits) |
processing.create_and_dispatch | Create and dispatch a processing job |
processing.dispatch | Dispatch an existing processing job |
processing.execute | Run a processing job |
processing.job.register | Register a processing job |
clip.create_from_item | Create + dispatch a clip job from an item (reserves CU) |
clip.create_from_area | Create + dispatch a clip job from an area |
billing.topup | Off-session card charge that grants credits |
billing.approvals.approve | Approve a pending spend |
billing.run_monthly_for_org | Run monthly billing for one org (manual; org admin) |
Reads (read) and pure compute (compute) don’t need a key — they don’t mutate billing state and are already safe to call again. For the full breakdown of side-effect tiers, see Core concepts.
Edge cases and gotchas
- The key binds to the body. Reusing a key with any change to the JSON payload returns
409 Conflict. If you legitimately want to place a different order, mint a new key. - Header is optional. Omitting
Idempotency-Keyis allowed, but then a retry can double-execute. Always send one on the spend tier. - 24-hour window. Replays are guaranteed within 24 hours of the first request. A retry after the window may execute fresh.
- Per-org, per-path scope. The same key string used on
orders.archive.placeand onbilling.topupare unrelated claims. Keys also never collide across organizations. billing.topupdedups at Stripe too. YourIdempotency-Keyis threaded into the Stripe charge, and the resulting PaymentIntent id dedups the credit grant — so the charge and the grant are each protected end to end.- 3-D Secure retries. If a top-up or card-backed order returns
402 Payment Requiredwith aclient_secretin the problem body, complete the bank challenge and retry with the sameIdempotency-Keyso you settle the original intent rather than starting a new one.
A complete worked example
Place an archive order with bounded retries that are safe even when the network drops mid-request.
import time
import uuid
import httpx
from geopera import Geopera
client = Geopera(token="gpra_...")
body = {
"projectId": "your-project-id",
"captures": [
{"id": "scene-abc", "geometry": {"type": "Polygon", "coordinates": []}}
],
"licenseType": "standard",
"splitByDate": False,
}
# One key for the whole retry loop — this is the unit of "exactly once".
key = str(uuid.uuid4())
for attempt in range(3):
try:
order = client.op(
"orders.archive.place",
body,
headers={"Idempotency-Key": key},
)
print("order:", order["id"], order["status"])
break
except httpx.TransportError:
# Connection died; the order may or may not have been placed.
# Retrying with the SAME key replays the first response if it landed,
# or places it cleanly if it never did. Never a duplicate.
if attempt == 2:
raise
time.sleep(2 ** attempt)If the first attempt actually reached the server and reserved credits, the retry returns that same order. If it never landed, the retry places it once. Either way the org is charged exactly one time.
Errors
A key collision surfaces as RFC 9457 problem+json with status 409:
{
"type": "https://geopera.com/problems/idempotency-conflict",
"title": "Idempotency-Key conflict",
"status": 409,
"detail": "This Idempotency-Key was already used with a different request body."
}See Errors for the full problem+json shape, and Authentication for how the Bearer gpra_... token authorizes the spend.