<!-- Source: https://docs.geopera.com/api-reference/sdks/cli/ci-recipes · Markdown for LLMs -->

# CI & recipes

Drive the `geopera` CLI from pipelines and scripts with no browser, no interactive prompts, and no on-disk credential store — authenticate from an environment variable, call operations as `geopera <resource> <action>`, stream JSON in and out, and branch on exit codes.

Everything here builds on the standard CLI. For first-time install, login, and command syntax see the [CLI overview](/api-reference/sdks/cli) and the [command reference](/api-reference/sdks/cli/commands). The token formats and header behaviour summarised below are documented in full under [Authentication](/api-reference/authentication) and [CLI configuration](/api-reference/sdks/cli/configuration).

<CardGrid>
  <InfoCard title="Auth from env" icon={KeyRound}>
    Set <code>GEOPERA_API_TOKEN</code> to a <code>gpra_</code> key and every command authenticates with no store, no refresh, nothing to clean up.
  </InfoCard>
  <InfoCard title="Structured commands" icon={Terminal}>
    Prefer <code>geopera &lt;resource&gt; &lt;action&gt;</code> over the raw <code>op</code> dispatch — flags validate up front and read clearly in a pipeline.
  </InfoCard>
  <InfoCard title="JSON in" icon={Braces}>
    Pipe bodies with <code>--json -</code>, load them with <code>--json @file</code>, and override single keys with explicit flags.
  </InfoCard>
  <InfoCard title="jq out" icon={Filter}>
    Every command prints the operation result as JSON to stdout — pipe it straight into <code>jq</code> to extract, reshape, or feed the next step.
  </InfoCard>
  <InfoCard title="Exit codes" icon={CircleSlash}>
    <code>0</code> on success, non-zero on any failure. Test the code, not stderr, and use <code>set -o pipefail</code> so a failed call fails the job.
  </InfoCard>
  <InfoCard title="Profiles" icon={Layers}>
    Namespace staging and prod identities with <code>--profile</code> / <code>GEOPERA_PROFILE</code>, each carrying its own base URL and credentials.
  </InfoCard>
</CardGrid>

## Authentication in CI

The CLI resolves credentials in this order, and the first usable source wins:

1. The `GEOPERA_API_TOKEN` environment variable — used verbatim, bypassing the store entirely.
2. The active profile's `auth` block in `~/.config/geopera/credentials.json`.

For automation you almost always want option 1. It skips the credential store, the browser sign-in flow, and any persistent state on the runner, so an ephemeral container needs nothing on disk and there is no `geopera login` step to run.

### GEOPERA_API_TOKEN (recommended for CI)

Set `GEOPERA_API_TOKEN` to an opaque token and every command authenticates with it directly, writing nothing to disk. The CLI inspects the value to choose the request header:

- A value beginning with `gpra_` is treated as a minted **API key** and sent as the `X-API-Key` header, with no prefix.
- Any other value is treated as a **session token** (a bearer access token from a browser sign-in) and sent as `Authorization: Bearer <token>`.

The API key _is_ the bearer credential — there is no token exchange, sign-in, or refresh step to perform first. You mint a key once and present it on every request.

```bash
export GEOPERA_API_TOKEN="gpra_xxxxxxxxxxxxxxxxxxxx"

geopera whoami
geopera catalog search --collections sentinel-2-l2a --limit 10
```

Because no profile is read or written, there is nothing to clean up after the job. This is the pattern to use in GitHub Actions, GitLab CI, CircleCI, and any other ephemeral runner.

When `GEOPERA_API_TOKEN` is set, the only thing the active profile still contributes is its stored `api_url` for base-URL resolution. The credential always comes from the env token. If you want the base URL to come from the environment too, set `GEOPERA_API_URL` alongside it (see [Selecting an environment](#selecting-an-environment-by-env-var)).

> Store the API key as a masked/secret CI variable, never in the repo. An API key authenticates exactly like a signed-in session, so scope it to only the operations the pipeline needs. See [Scopes](/api-reference/scopes).

A pinned `GEOPERA_API_TOKEN` API key never expires mid-job and needs no refresh. A session token supplied this way is used as-is and is **not** auto-refreshed — once it expires the CLI returns an auth error rather than minting a new one. Prefer an API key for any long-running or unattended pipeline; reserve a session token in the env for short, interactive debugging.

### Headless login by piping the key

If you do want a persisted profile on the runner — for example a long-lived self-hosted agent that should survive across jobs — store the key so it never touches your shell history or the process table. Pass `-` to `--api-key` to read the key from stdin:

```bash
printf '%s' "$GEOPERA_KEY" | geopera login --api-key -
```

This writes an `api_key` profile to `~/.config/geopera/credentials.json` (directory `0700`, file `0600`, both enforced on every write). The stored auth block is `{"type": "api_key", "api_key": "gpra_..."}`, and the key is later sent as `X-API-Key` on every request. Avoid the inline form `geopera login --api-key gpra_...`, which leaves the secret in shell history and in `ps` output.

`geopera login --api-key -` validates nothing about the _format_ of the key beyond storing it; the first authenticated command (or an explicit `geopera whoami`) is what proves the key works. Add a `whoami` smoke check right after login so a bad key fails the setup step rather than the first real operation:

```bash
printf '%s' "$GEOPERA_KEY" | geopera login --api-key -
geopera whoami > /dev/null   # non-zero exit if the key is rejected
```

### GitHub Actions

```yaml
name: nightly-catalog-sync
on:
  schedule:
    - cron: '0 3 * * *'
jobs:
  sync:
    runs-on: ubuntu-latest
    env:
      GEOPERA_API_TOKEN: ${{ secrets.GEOPERA_API_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install geopera-cli
      - run: geopera whoami
      - run: |
          geopera catalog search \
            --collections sentinel-2-l2a \
            --bbox -122.5 --bbox 37.7 --bbox -122.3 --bbox 37.9 \
            --datetime "2026-01-01/2026-06-01" \
            > results.json
      - uses: actions/upload-artifact@v4
        with:
          name: catalog-results
          path: results.json
```

### GitLab CI

The same env-token pattern works unchanged on any runner. Mask the variable in **Settings → CI/CD → Variables** and reference it as a job-level env var:

```yaml
nightly-catalog-sync:
  image: python:3.12-slim
  variables:
    GEOPERA_API_TOKEN: $GEOPERA_API_TOKEN # masked project variable
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  before_script:
    - pip install geopera-cli
    - geopera whoami
  script:
    - |
      geopera catalog search \
        --collections sentinel-2-l2a \
        --datetime "2026-01-01/2026-06-01" \
        > results.json
  artifacts:
    paths:
      - results.json
```

## Calling operations

Every operation has a structured command that mirrors its id: `catalog.search` is `geopera catalog search`, and a deeply-nested operation like `orders.archive.place` is `geopera orders archive place`. Prefer these in CI — they are self-documenting, validate flags up front, and keep simple requests free of JSON quoting.

```bash
# Scalar fields are flags. Arrays repeat the flag; required fields show [required] in --help.
geopera catalog search --collections sentinel-2-l2a --limit 10

# Array fields: repeat the flag once per element.
geopera catalog search \
  --collections sentinel-2-l2a \
  --collections sentinel-1-grd \
  --limit 25

# Booleans are paired --x / --no-x.
geopera orders list --include-archived --no-include-cancelled

# Deep resource paths map directly onto subcommands.
geopera orders archive place --json @order.json
```

Use `geopera <resource> <action> --help` to see the flags, types, and required fields for any operation. Every operation is a `POST` under the hood — there are no read-only "get" routes to call differently; a `list` or `search` is still issued as a POST and still returns its result as JSON on stdout.

### When to reach for `geopera op`

The raw `geopera op <operation_id> '<json>'` form still works as a low-level escape hatch (documented under [Low-level dispatch](/api-reference/sdks/cli/op)). Reach for it only when the operation id is **dynamic** — chosen at runtime, read from a config matrix, or driven by a list the script doesn't know at author time — or for an operation not yet surfaced as a subcommand in your installed CLI snapshot. For everything fixed and known, use the structured command above.

```bash
# Dynamic dispatch: the operation id comes from a variable, so a
# subcommand can't be named statically.
for op in catalog.search catalog.federated_search; do
  geopera op "$op" "$body" > "${op//./_}.json"
done
```

`geopera op` reads its body from the positional argument, from `--file path`, or from `-` (stdin), the same three ways the structured `--json` flag accepts a body.

## Passing JSON bodies

Scalar request fields are plain `--flags` (arrays repeat the flag; booleans are `--x` / `--no-x`). For complex or nested bodies, pass JSON with `--json` — inline, from a file with `@`, or from stdin with `-`. Flags override keys taken from `--json`, so you can pin a base body in a file and tweak one field on the command line.

```bash
# 1. Inline JSON
geopera orders estimate --json '{"aoi": {"type": "Point", "coordinates": [-122.4, 37.8]}}'

# 2. From a file
geopera orders estimate --json @./body.json

# 3. From stdin
cat body.json | geopera orders estimate --json -

# 4. File body with a flag override (the flag wins over the file's "product" key)
geopera orders estimate --json @./body.json --product optical-rgb-2m
```

Building the body programmatically and piping it in keeps complex JSON out of your shell quoting. Use `jq -n` to construct it safely, then feed it through `--json -`:

```bash
jq -n \
  --argjson bbox '[-122.5, 37.7, -122.3, 37.9]' \
  --arg datetime "2026-01-01/2026-06-01" \
  '{collections: ["sentinel-2-l2a"], bbox: $bbox, datetime: $datetime}' \
  | geopera catalog search --json -
```

The same `jq -n` approach is the safe way to inject runtime values into a body without string interpolation — pass each value with `--arg` (string) or `--argjson` (already-JSON) so quoting and escaping are handled for you, and a value that happens to contain quotes or braces can never break out of the JSON:

```bash
AOI_LON="-122.4194"
AOI_LAT="37.7749"

jq -n \
  --argjson lon "$AOI_LON" \
  --argjson lat "$AOI_LAT" \
  --arg product "optical-rgb-2m" \
  '{aoi: {type: "Point", coordinates: [$lon, $lat]}, product: $product}' \
  | geopera orders estimate --json -
```

## Parsing output with jq

Every command prints the operation's JSON result to stdout. Pipe it straight into `jq` to extract fields, reshape, or feed the next step:

```bash
# Pull a single field (-r drops the surrounding quotes)
geopera orders estimate --json @body.json | jq -r '.price.total'

# Collect ids from a list operation
geopera orders list --limit 50 | jq -r '.items[].id'

# Reshape into CSV
geopera orders list --limit 50 \
  | jq -r '.items[] | [.id, .status, .created_at] | @csv'

# Filter, then count
geopera orders list --limit 100 \
  | jq '[.items[] | select(.status == "failed")] | length'
```

Capture a value into a shell variable for use in a later step, and guard against a missing field with jq's `// empty` so a typo surfaces as an empty string rather than the literal `null`:

```bash
quote_id=$(geopera orders estimate --json @body.json | jq -r '.quote_id // empty')
[ -n "$quote_id" ] || { echo "no quote_id in response" >&2; exit 1; }
```

List operations are paginated; loop on the returned cursor to drain every page. See [Pagination](/api-reference/pagination) for the cursor contract.

```bash
cursor=""
while : ; do
  if [ -z "$cursor" ]; then
    page=$(geopera orders list --limit 100)
  else
    page=$(geopera orders list --limit 100 --cursor "$cursor")
  fi
  echo "$page" | jq -r '.items[].id'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done
```

## Scripting against exit codes

The CLI exits `0` on success and non-zero on failure — auth errors, validation errors, transport errors, or any [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem response from the API. Test the exit code directly rather than scraping stderr:

```bash
if geopera whoami > /dev/null 2>&1; then
  echo "credentials valid"
else
  echo "auth check failed" >&2
  exit 1
fi
```

To both capture output and branch on the result, separate the streams. On failure the error problem document is written to stderr, leaving stdout clean for the success payload:

```bash
set -euo pipefail   # fail the whole pipeline on any error

if ! out=$(geopera orders estimate --json @body.json 2> err.txt); then
  echo "estimate failed:" >&2
  cat err.txt >&2
  exit 1
fi

echo "$out" | jq -r '.price.total'
```

With `set -o pipefail`, a failed command propagates its exit status through a `| jq` pipe so the job fails instead of silently continuing on jq's `0`. Error payloads are `application/problem+json`; the `type`, `title`, `status`, and `detail` fields are documented in [Errors](/api-reference/errors).

You can also assign your own exit codes to distinguish _kinds_ of failure for the CI system to act on — for example separating an auth failure from a transient transport error from a deliberate policy block:

```bash
set -uo pipefail

if ! out=$(geopera orders estimate --json @body.json 2> err.txt); then
  if grep -q '"status": 401' err.txt; then
    echo "authentication failed — check GEOPERA_API_TOKEN" >&2
    exit 77   # distinct code: bad credentials, not a transient error
  fi
  echo "estimate request failed:" >&2
  cat err.txt >&2
  exit 1
fi
```

## Multiple profiles (staging vs prod)

When a runner needs more than one identity, namespace them with profiles. Each profile is a top-level key in `~/.config/geopera/credentials.json` and carries its own `api_url` and `auth` block. Select one with `--profile` or the `GEOPERA_PROFILE` environment variable; both override the `default` profile.

```bash
# Log in once per environment (api key read from stdin)
printf '%s' "$STAGING_KEY" | geopera login --profile staging \
  --api-key - --api-url https://staging.api.geopera.com
printf '%s' "$PROD_KEY" | geopera login --profile prod \
  --api-key - --api-url https://api.geopera.com

# Target a profile per command
geopera whoami --profile staging
GEOPERA_PROFILE=prod geopera orders list --limit 10
```

`geopera logout` clears only the active profile's `auth` block (keeping its `api_url`), so logging out of `staging` leaves `prod` untouched:

```bash
geopera logout --profile staging
```

### Selecting an environment by env var

In ephemeral CI, prefer `GEOPERA_API_TOKEN` per job over persisted profiles — set the right secret for the right environment and skip the store. The API base URL resolves by precedence: the `--api-url` flag, then `GEOPERA_API_URL`, then the stored profile's `api_url`, then `https://api.geopera.com`. So you can pick the environment entirely with env vars and reuse one job script:

```bash
# Same job script, environment chosen entirely by env vars
GEOPERA_API_TOKEN="$STAGING_KEY" GEOPERA_API_URL=https://staging.api.geopera.com \
  geopera orders list --limit 10
```

> `GEOPERA_API_TOKEN` ignores stored credentials. If it is set, a `--profile` selection only affects which stored `api_url` is read for base-URL resolution; the credential always comes from the env token, not the profile's `auth` block. Set `GEOPERA_API_URL` (or `--api-url`) to be explicit about the target environment instead of relying on a profile being present on the runner.

A matrix build can map each environment to its own masked secret and let the env vars do the selection:

```yaml
strategy:
  matrix:
    include:
      - env: staging
        api_url: https://staging.api.geopera.com
      - env: prod
        api_url: https://api.geopera.com
steps:
  - run: geopera orders list --limit 10
    env:
      GEOPERA_API_TOKEN: ${{ secrets[format('GEOPERA_KEY_{0}', matrix.env)] }}
      GEOPERA_API_URL: ${{ matrix.api_url }}
```

## Worked example: gate a deploy on a price check

A complete, copy-paste pipeline step that estimates an order, fails the build if the quote exceeds a budget, and otherwise records the quote id. It uses an API key from the environment, builds the body with `jq`, calls the structured `orders estimate` command, branches on the exit code, and parses the result.

```bash
#!/usr/bin/env bash
set -euo pipefail

: "${GEOPERA_API_TOKEN:?set GEOPERA_API_TOKEN to a gpra_ API key}"
BUDGET_USD=500

# Build the request body without shell-quoting headaches.
body=$(jq -n '{
  aoi: {type: "Point", coordinates: [-122.4194, 37.7749]},
  product: "optical-rgb-2m"
}')

# Run the command; capture stdout, route errors to a file, branch on exit code.
if ! result=$(printf '%s' "$body" | geopera orders estimate --json - 2> est_err.txt); then
  echo "estimate request failed:" >&2
  cat est_err.txt >&2
  exit 1
fi

total=$(echo "$result" | jq -r '.price.total')

# Numeric guard on the returned price.
if (( $(echo "$total > $BUDGET_USD" | bc -l) )); then
  echo "quote \$$total exceeds budget \$$BUDGET_USD — blocking deploy" >&2
  exit 2
fi

echo "quote ok: \$$total"
echo "$result" | jq -r '.quote_id'
```

The script exits `0` (under budget, quote id printed), `2` (over budget, deploy blocked), or `1` (request failed) — three distinct codes a CI system can act on.

## Worked example: place an idempotent order and poll to completion

A second end-to-end recipe that places a write operation safely (so a retried job never double-orders), then polls the order until it leaves the pending state. The idempotency key is derived from the commit SHA so the same pipeline run always reuses the same key on retry.

```bash
#!/usr/bin/env bash
set -euo pipefail

: "${GEOPERA_API_TOKEN:?set GEOPERA_API_TOKEN to a gpra_ API key}"

# A stable key for this logical request — a retry of the same job reuses it,
# so the order is placed at most once. See /api-reference/idempotency.
idem_key="deploy-${CI_COMMIT_SHA:-local}-order"

order_id=$(
  jq -n --arg p "optical-rgb-2m" \
    '{aoi: {type: "Point", coordinates: [-122.4194, 37.7749]}, product: $p}' \
  | geopera orders place --json - --idempotency-key "$idem_key" \
  | jq -r '.id'
)
echo "placed order $order_id"

# Poll until the order is no longer pending/processing.
for _ in $(seq 1 60); do
  status=$(geopera orders get --order-id "$order_id" | jq -r '.status')
  echo "status: $status"
  case "$status" in
    completed) echo "order complete"; exit 0 ;;
    failed|cancelled) echo "order ended as $status" >&2; exit 1 ;;
  esac
  sleep 10
done

echo "timed out waiting for order $order_id" >&2
exit 1
```

The idempotency key here is illustrative — pass it however the operation accepts it (a flag where the request schema exposes one, or as a key in the `--json` body). See [Idempotency](/api-reference/idempotency) for the exact contract and [Rate limits](/api-reference/rate-limits) for retry/back-off guidance.

## Gotchas

- **Never inline a key.** `geopera login --api-key gpra_...` and putting a token inside a `--json` body both land in shell history and `ps`. Pipe secrets via stdin (`--api-key -`, `--json -`) or pass them in masked env vars.
- **`gpra_` prefix is significant.** `GEOPERA_API_TOKEN` only routes to the `X-API-Key` header when the value starts with `gpra_`; any other value is sent as a bearer session token. A mistyped prefix silently sends your API key as a bearer and 401s.
- **The key is the whole credential.** There is no exchange or sign-in step for an API key — present `gpra_...` and you are authenticated. Don't add a token-fetch step to your pipeline.
- **Prefer structured commands.** Use `geopera <resource> <action>` for fixed operations; reserve `geopera op <id>` for dynamic dispatch where the operation id is decided at runtime. See [Low-level dispatch](/api-reference/sdks/cli/op).
- **Flags override `--json`.** When you combine `--json @file.json` with explicit flags, the flags win. Use this to keep a base body in a file and vary one field per job.
- **Env session tokens do not refresh.** A session token placed in `GEOPERA_API_TOKEN` is used verbatim and expires; a stored profile refreshes its session token automatically, but the env override never does. Use a `gpra_` API key for unattended pipelines.
- **Set `pipefail`.** Without `set -o pipefail`, `geopera ... | jq` reports `jq`'s exit code, hiding a failed API call. Always enable it in CI scripts.
- **`GEOPERA_API_TOKEN` wins over profiles.** If it is set, the stored `auth` block is ignored entirely; only the stored `api_url` may still be read. Be explicit about the environment with `GEOPERA_API_URL` or `--api-url`.
- **Rate limits apply per token.** Parallel matrix jobs share one key's budget; see [Rate limits](/api-reference/rate-limits). For repeatable writes, send an idempotency key — see [Idempotency](/api-reference/idempotency).
