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:

  1. First request for a key — the server claims it, executes the operation, persists the response, and returns it.
  2. 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.
  3. 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.
bash
KEY=$(uuidgen)   # generate once, reuse for every retry of THIS order

Passing the header

HTTP

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
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "id": "f2b1c0de-...",
  "status": "awaiting_data",
  "orderType": "archive",
  "totalCredits": 4200
}

curl

bash
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.

python
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:

python
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:

typescript
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:

OperationWhat it does
orders.archive.placePlace an archive order (reserves credits)
orders.placePlace an order via the generic order path
orders.cancelCancel an order; refunds or voids the reservation
orders.tasking.placePlace a tasking order
orders.tasking.feasibility_decideAccept/reject a feasibility study (commits spend)
orders.tasking.quotation_decideAccept/reject a tasking quotation (commits spend)
processing.createCreate a processing job (reserves credits)
processing.create_and_dispatchCreate and dispatch a processing job
processing.dispatchDispatch an existing processing job
processing.executeRun a processing job
processing.job.registerRegister a processing job
clip.create_from_itemCreate + dispatch a clip job from an item (reserves CU)
clip.create_from_areaCreate + dispatch a clip job from an area
billing.topupOff-session card charge that grants credits
billing.approvals.approveApprove a pending spend
billing.run_monthly_for_orgRun 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-Key is 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.place and on billing.topup are unrelated claims. Keys also never collide across organizations.
  • billing.topup dedups at Stripe too. Your Idempotency-Key is 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 Required with a client_secret in the problem body, complete the bank challenge and retry with the same Idempotency-Key so 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.

python
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:

json
{
	"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.