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:
- The
GEOPERA_API_TOKENenvironment variable — used verbatim, bypassing the store entirely. - The active profile’s
authblock 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 theX-API-Keyheader, 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.
export GEOPERA_API_TOKEN="gpra_xxxxxxxxxxxxxxxxxxxx"
geopera whoami
geopera catalog search --collections sentinel-2-l2a --limit 10Because 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:
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:
printf '%s' "$GEOPERA_KEY" | geopera login --api-key -
geopera whoami > /dev/null # non-zero exit if the key is rejectedGitHub Actions
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.jsonGitLab 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:
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.jsonCalling 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.
# 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.jsonUse 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.
# 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"
donegeopera 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.
# 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-2mBuilding 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 -:
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:
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:
# 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:
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.
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
doneScripting 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:
if geopera whoami > /dev/null 2>&1; then
echo "credentials valid"
else
echo "auth check failed" >&2
exit 1
fiTo 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:
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:
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
fiMultiple 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.
# 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 10geopera logout clears only the active profile’s auth block (keeping its api_url), so logging out of staging leaves prod untouched:
geopera logout --profile stagingSelecting 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:
# 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_TOKENignores stored credentials. If it is set, a--profileselection only affects which storedapi_urlis read for base-URL resolution; the credential always comes from the env token, not the profile’sauthblock. SetGEOPERA_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:
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.
#!/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.
#!/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 1The 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--jsonbody both land in shell history andps. Pipe secrets via stdin (--api-key -,--json -) or pass them in masked env vars. gpra_prefix is significant.GEOPERA_API_TOKENonly routes to theX-API-Keyheader when the value starts withgpra_; 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; reservegeopera 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.jsonwith 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_TOKENis used verbatim and expires; a stored profile refreshes its session token automatically, but the env override never does. Use agpra_API key for unattended pipelines. - Set
pipefail. Withoutset -o pipefail,geopera ... | jqreportsjq’s exit code, hiding a failed API call. Always enable it in CI scripts. GEOPERA_API_TOKENwins over profiles. If it is set, the storedauthblock is ignored entirely; only the storedapi_urlmay still be read. Be explicit about the environment withGEOPERA_API_URLor--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.