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

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

| 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](/api-reference/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](/api-reference/errors) for the full problem+json shape, and [Authentication](/api-reference/authentication) for how the `Bearer gpra_...` token authorizes the spend.
