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

# Calling operations

The Python SDK gives you a typed `client` with one method per operation, grouped by resource, so `client.catalog.search(body)` is all you need to make a call; `detailed=True` hands you the full typed `Response` when you want the status code, and the per-operation modules underneath stay available as a lower-level escape hatch.

## The resource.action pattern

Construct a client once, then call operations as `client.<resource>.<action>(body)`. The resource and action come straight from the operation ID — `catalog.search` becomes `client.catalog.search`, and `billing.credits.balance` becomes `client.billing.credits.balance`:

```python
from geopera import Geopera

client = Geopera(token="gpra_...")

result = client.catalog.search({"host_name": "earthsearch-aws", "limit": 10})
print(result)  # -> CatalogSearchOutput (typed)
```

Every operation is a POST under the hood — the SDK posts your body to `/v1/op/<operation_id>` — but you never see that. Because the verb (search, get, place, estimate, …) lives inside the operation name, you never construct a URL, choose an HTTP method, or build a path; you call the method that names the thing you want to do. Browse the full catalogue at [/api-reference/operations](/api-reference/operations).

### Body: a dict or the typed input model

The body is either a plain `dict`, as above, or the operation's typed input model — both are accepted, and both route through exactly the same call:

```python
from geopera import Geopera
from geopera.models import CatalogSearchInput

client = Geopera(token="gpra_...")

result = client.catalog.search(
    CatalogSearchInput(
        host_name="earthsearch-aws",
        collections=["sentinel-2-l2a"],
        limit=10,
    )
)
```

When you pass a `dict`, the client converts it to the operation's input model with that model's `from_dict` before sending, so an unknown or mistyped field is caught at conversion time rather than silently dropped. When you pass the model directly, you get editor autocomplete and type checking on every field. Use the model when you are writing code by hand; use a `dict` when the body is data you already have (loaded from JSON, assembled from user input, copied from the API reference).

The return value is the operation's parsed output. For most operations that is a typed output model (`CatalogSearchOutput`, `TaskingEstimateOutput`, …). A handful of operations return an untyped JSON object, in which case the parsed value is a plain `dict`; the [operations catalogue](/api-reference/operations) lists the exact output model for each operation.

### Deeply nested operations

Operation IDs with more than two segments nest the same way: each dot in the ID is one more attribute access. `orders.archive.place` is `client.orders.archive.place`, and `orders.tasking.estimate` is `client.orders.tasking.estimate`:

```python
order = client.orders.archive.place(
    {
        "host_name": "earthsearch-aws",
        "item_ids": ["S2B_MSIL2A_20240101..."],
    }
)

estimate = client.orders.tasking.estimate(
    {
        "host_name": "planet",
        "geometry": {"type": "Point", "coordinates": [-122.4, 37.8]},
        "datetime": "2026-07-01T00:00:00Z/2026-07-31T23:59:59Z",
    }
)
```

There is no depth limit and no registration step: attribute access builds up a dotted path lazily, and the path is resolved to a module only when you call the node. `client.billing.credits.balance` and `client.billing.credits.transactions` share the same `client.billing.credits` node; calling it is what selects the operation.

### Operations with no input

Some operations take no meaningful input. Pass an empty dict (or omit the argument entirely — `None` is treated as an empty body):

```python
balance = client.billing.credits.balance({})
# equivalent:
balance = client.billing.credits.balance()
```

Internally these operations accept the `Empty` model, and the client builds it for you from `{}` (or from `None`). You never need to import `Empty` when you call through `client.<resource>.<action>`.

### `detailed=True`: the typed Response without leaving the fluent client

By default `client.<resource>.<action>(body)` returns the parsed payload — exactly what you want most of the time. When you need the HTTP status code or response headers, pass `detailed=True` and you get the full typed `Response` back, without dropping down to the module layer:

```python
from geopera import Geopera
from geopera.models import CatalogSearchOutput, Problem

client = Geopera(token="gpra_...")

resp = client.catalog.search(
    {"host_name": "earthsearch-aws", "collections": ["sentinel-2-l2a"]},
    detailed=True,
)

if resp.status_code == 200:
    out: CatalogSearchOutput = resp.parsed
    print("ok:", out)
elif isinstance(resp.parsed, Problem):
    print(resp.status_code, resp.parsed.title, "-", resp.parsed.detail)
```

`detailed=True` on the fluent client and `sync_detailed` on a module return the same `Response` object (described in full [below](#reading-the-raw-response-with-sync_detailed)) — `detailed=True` is simply the ergonomic way to reach it. Without it you get `resp.parsed`; with it you get `resp`.

### Per-call header overrides

The fluent call forwards any extra keyword argument whose name the underlying operation accepts. In practice the one you may use is `x_api_key`, which sets the `x-api-key` header for that single call (handy when an operation routes to a third-party host that needs its own key, while your Geopera token stays in the client):

```python
result = client.catalog.search(
    {"host_name": "some-host", "collections": ["sentinel-2-l2a"]},
    x_api_key="host-specific-key",
)
```

Keyword arguments the operation does not declare are ignored rather than raising, so passing an option an operation does not support is a no-op, not an error. Most operations need nothing beyond `body`.

## The lower-level escape hatch: per-operation modules

Under the typed `client` sits a flat catalogue of per-operation modules — one module per operation under `geopera.api.operations`, each wrapping a single operation call and exposing four callables. Reach for this layer when you want to import an operation as a plain function (for dependency injection or testing), keep `sync` and `asyncio` forms side by side, or pin an exact import path. Anything you can do here you can also do through the fluent client; this is the typed plumbing it calls.

These functions take an `AuthenticatedClient`, not a `Geopera`. The `Geopera` client exposes its underlying `AuthenticatedClient` as `client.client`, so you can reuse the same configured connection:

```python
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput

client = Geopera(token="gpra_...")

result = catalog_search.sync(
    client=client.client,
    body=CatalogSearchInput(
        host_name="earthsearch-aws",
        collections=["sentinel-2-l2a"],
        limit=10,
    ),
)
print(result)  # -> CatalogSearchOutput (typed)
```

See [the client setup page](/api-reference/sdks/python) for how to build a standalone `AuthenticatedClient` if you want to use this layer on its own.

### The module path: dots (and dashes) become underscores

There is exactly one module per operation, and the module name is the operation ID with every dot — and every dash — replaced by an underscore:

| operationId                 | Module                                             |
| --------------------------- | -------------------------------------------------- |
| `catalog.search`            | `geopera.api.operations.catalog_search`            |
| `orders.tasking.estimate`   | `geopera.api.operations.orders_tasking_estimate`   |
| `analytics.calculate_index` | `geopera.api.operations.analytics_calculate_index` |
| `billing.credits.balance`   | `geopera.api.operations.billing_credits_balance`   |

The input and output models (`CatalogSearchInput`, `CatalogSearchOutput`, `Problem`, `HTTPValidationError`, `Empty`, …) all live in `geopera.models`.

### The four callables

Importing an operation module gives you four entry points:

| Callable           | Sync/async | Returns                                      |
| ------------------ | ---------- | -------------------------------------------- |
| `sync`             | blocking   | the parsed result, a parsed error, or `None` |
| `sync_detailed`    | blocking   | a `Response` wrapper                         |
| `asyncio`          | awaitable  | the parsed result, a parsed error, or `None` |
| `asyncio_detailed` | awaitable  | a `Response` wrapper                         |

`sync` and `asyncio` are the convenience forms: they hand you the parsed payload directly. The `*_detailed` forms hand you a `Response` object so you can inspect the HTTP status code and headers alongside the parsed body. Internally `sync` is literally `sync_detailed(...).parsed`, and `asyncio` is `(await asyncio_detailed(...)).parsed`, so the two always agree on what was parsed.

All four take keyword-only arguments. The two you will always use are `client=` and (for operations that accept input) `body=`. Operations also accept the optional `x_api_key=` keyword for a per-call header override, the same one the fluent client forwards.

### Building the body model at this layer

Unlike the fluent client, the module functions do **not** convert a `dict` for you — `body=` must be the typed input model (or, for input-less operations, `Empty()`). Construct the model from your data with `Model.from_dict({...})` if you are starting from a dict:

```python
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput

# from keyword arguments
body = CatalogSearchInput(host_name="earthsearch-aws", limit=10)

# or from a dict you already have
raw = {"host_name": "earthsearch-aws", "limit": 10}
body = CatalogSearchInput.from_dict(raw)

result = catalog_search.sync(client=client.client, body=body)
```

## Reading the raw response with `sync_detailed`

Use `sync_detailed` (or `detailed=True` on the fluent client) when you need the HTTP status code — for example to branch on success versus a business error. It returns a `Response` with these fields:

| Field         | Type                       | Notes                                            |
| ------------- | -------------------------- | ------------------------------------------------ |
| `status_code` | `http.HTTPStatus`          | compares cleanly against an `int`, e.g. `== 200` |
| `parsed`      | the parsed model or `None` | same value `sync` would have returned            |
| `content`     | `bytes`                    | the raw response body                            |
| `headers`     | mapping                    | response headers                                 |

```python
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import CatalogSearchInput, CatalogSearchOutput, Problem

client = Geopera(token="gpra_...")

resp = catalog_search.sync_detailed(
    client=client.client,
    body=CatalogSearchInput(host_name="earthsearch-aws", collections=["sentinel-2-l2a"]),
)

if resp.status_code == 200:
    data: CatalogSearchOutput = resp.parsed
    print("results:", data)
elif isinstance(resp.parsed, Problem):
    print("error:", resp.parsed.title, "-", resp.parsed.detail)
```

Each operation documents the exact set of statuses it parses: a 200 success model, `Problem` for 401 / 403 / 404 / 500, and `HTTPValidationError` for 422. Errors are RFC 9457 `problem+json`; the SDK parses business and permission failures into a `Problem` model and 422 input-validation failures into `HTTPValidationError`. See [errors](/api-reference/errors) for the full problem schema and the catalogue of error types.

## Async variants

`asyncio` and `asyncio_detailed` are the awaitable equivalents and accept the same keyword arguments. Use the underlying client as an async context manager so its connection pool is cleaned up:

```python
import asyncio
from geopera import AuthenticatedClient
from geopera.api.operations import orders_tasking_estimate
from geopera.models import TaskingEstimateInput

async def main():
    async with AuthenticatedClient(
        base_url="https://api.geopera.com", token="gpra_..."
    ) as client:
        estimate = await orders_tasking_estimate.asyncio(
            client=client,
            body=TaskingEstimateInput(
                host_name="planet",
                geometry={"type": "Point", "coordinates": [-122.4, 37.8]},
                datetime="2026-07-01T00:00:00Z/2026-07-31T23:59:59Z",
            ),
        )
        print(estimate)

asyncio.run(main())
```

`asyncio_detailed` returns the same `Response` wrapper as its sync counterpart, so the status-code branching pattern above carries over unchanged. The fluent client (`client.<resource>.<action>`) is synchronous; when you need async, call the module's `asyncio` / `asyncio_detailed` against `client.client` (or a standalone `AuthenticatedClient`).

## Worked example: search, then handle every outcome

This walks the full happy-path-plus-errors flow with `sync_detailed` so each HTTP outcome is handled explicitly. The same `Response` is what `client.catalog.search(body, detailed=True)` returns, so this pattern applies to either entry point.

```python
from geopera import Geopera
from geopera.api.operations import catalog_search
from geopera.models import (
    CatalogSearchInput,
    CatalogSearchOutput,
    HTTPValidationError,
    Problem,
)

client = Geopera(token="gpra_...")

resp = catalog_search.sync_detailed(
    client=client.client,
    body=CatalogSearchInput(
        host_name="earthsearch-aws",
        collections=["sentinel-2-l2a"],
        limit=25,
    ),
)

match resp.parsed:
    case CatalogSearchOutput() as out:
        print("ok:", out)
    case HTTPValidationError() as verr:
        # 422 — the body failed schema validation
        print("invalid input:", verr.detail)
    case Problem() as prob:
        # 401 / 403 / 404 / 500 — auth, scope, or business errors
        print(f"{resp.status_code} {prob.title}: {prob.detail}")
    case None:
        # undocumented status and raise_on_unexpected_status was False
        print("unexpected:", resp.status_code, resp.content)
```

A 401 or 403 `Problem` usually means a missing or wrong token, or a token lacking the scope for this operation — see [authentication](/api-reference/sdks/python) and [scopes](/api-reference/scopes). The token you pass is the bearer credential itself; there is no exchange or refresh step — a `gpra_...` API key (or a browser sign-in session token used the same way) goes straight into the `Authorization: Bearer` header.

## Worked example: chaining operations

Operations compose by feeding one result into the next. Here a catalog search picks items, those item IDs place an archive order, and the order ID checks status — three fluent calls, no URLs:

```python
from geopera import Geopera

client = Geopera(token="gpra_...")

# 1. find imagery
hits = client.catalog.search(
    {
        "host_name": "earthsearch-aws",
        "collections": ["sentinel-2-l2a"],
        "limit": 5,
    }
)

# 2. place an archive order for the matched items
item_ids = [feature["id"] for feature in hits["features"]]
order = client.orders.archive.place(
    {"host_name": "earthsearch-aws", "item_ids": item_ids}
)

# 3. read back the credit balance the order drew down
balance = client.billing.credits.balance({})
print("remaining credits:", balance)
```

If any step can fail in a way you want to branch on, swap that one call for `detailed=True` and inspect `resp.status_code` before continuing.

## Gotchas

- **Prefer `client.<resource>.<action>`.** The typed client is the recommended surface for almost everything, including the raw `Response` (via `detailed=True`). Reach for the per-operation modules when you want the async forms, an importable function, or a fixed import path.
- **`detailed=True` is fluent; `sync_detailed` is the module.** Both yield the same `Response`. You do not need to leave the fluent client just to read a status code.
- **The fluent client converts dicts; the modules do not.** `client.catalog.search({...})` accepts a `dict` and builds the input model for you. `catalog_search.sync(client=..., body=...)` requires the actual model — build it with `CatalogSearchInput.from_dict({...})` if you only have a dict.
- **The modules want `client.client`.** The per-operation functions take an `AuthenticatedClient`. From a `Geopera` instance, pass `client.client`.
- **No positional arguments on the modules.** Every per-operation callable is keyword-only. Always pass `client=` and `body=`.
- **`body=` is required even when empty at the module layer.** Input-less operations take `body=Empty()` when called through a module, but `{}` (or nothing) through the fluent client.
- **`sync` hides the status code.** If you need to distinguish, say, a 403 from a 404, use `detailed=True` / `sync_detailed` / `asyncio_detailed` and read `resp.status_code` — both error statuses parse to a `Problem`.
- **`None` is a real return value.** With the default `raise_on_unexpected_status=False`, an undocumented status yields `parsed=None` rather than an exception. Set `raise_on_unexpected_status=True` on the client to turn those into `errors.UnexpectedStatus` instead.
- **`status_code` is an `HTTPStatus`.** It compares equal to the corresponding `int` (`resp.status_code == 200`), so you rarely need to import `HTTPStatus` yourself.
- **A few outputs are untyped.** Most operations parse 200 into a typed output model, but some return a raw JSON object, so `resp.parsed` is a plain `dict` for those. Check the [operations catalogue](/api-reference/operations) for the exact output type.
- **Some operation IDs already contain an underscore.** The dot-and-dash-to-underscore transform is mechanical, but a handful of operation IDs (such as `analytics.calculate_index` and `usage.recalculate_storage`) already include an underscore in the last segment. The module name keeps that underscore — `analytics_calculate_index`, `usage_recalculate_storage` — so it is not the result of a dot. If an import does not resolve as you expect, confirm the exact module name in `geopera.api.operations` rather than assuming the segment boundaries.

## Related

- [Operations catalogue](/api-reference/operations) — every operation ID and its input/output models
- [Errors](/api-reference/errors) — the `problem+json` schema returned as `Problem`
- [Scopes](/api-reference/scopes) — which token scopes each operation requires
- [Concepts](/api-reference/concepts) — the RPC-over-HTTP model behind every call
