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 and the command reference. The token formats and header behaviour summarised below are documented in full under Authentication and CLI configuration.

Auth from env

Structured commands

JSON in

jq out

Exit codes

Profiles

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

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.

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

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 for the exact contract and 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.
  • 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. For repeatable writes, send an idempotency key — see Idempotency.