Advanced usage
Recipes for production traffic with @geopera/sdk — pointing the client at staging, swapping in a custom fetch, attaching per-request headers, cancelling requests, and walking paginated operations by hand, all driven through the typed namespace methods.
This page assumes you already have a working client from the TypeScript SDK overview. The recommended surface is the typed resource methods — client.catalog.search(body), client.orders.archive.place(body) — which are checked at compile time and track the live API by construction. Each one resolves to a single POST ${baseUrl}/v1/op/{operation_id} with a JSON body. There are no other HTTP verbs and no GET routes: list, get, estimate and place semantics all live inside the operation_id. See Operations for the full registry.
client.invoke("catalog.search", body) is the same call, expressed by string id. It is the low-level escape hatch you reach for only when the operation id is computed at runtime; everywhere else, prefer the namespace method.
Client options
The constructor accepts exactly four fields. Only token is required.
| Option | Type | Default | Purpose |
|---|---|---|---|
token | string | — | A gpra_ API key, or a browser session token. Sent verbatim as the bearer. |
baseUrl | string | https://api.geopera.com | API origin. The /v1/op/ prefix is appended internally and is not configurable. |
headers | Record<string, string> | {} | Extra headers merged into every request. |
fetch | typeof fetch | globalThis.fetch | The fetch implementation to call. |
import { GeoperaClient } from '@geopera/sdk';
const client = new GeoperaClient({
token: process.env.GEOPERA_TOKEN!,
baseUrl: 'https://staging.api.geopera.com',
headers: { 'X-Client-Trace': 'ingest-worker' }
});
const results = await client.catalog.search({
collections: ['sentinel-2-l2a'],
limit: 10
});The token is the bearer credential as-is — there is no token exchange, refresh, or sign-in round trip for an API key. A gpra_ key and a browser session token are used identically. See Authentication for how to obtain one and Scopes for what a given token is allowed to invoke.
The constructor throws synchronously in two cases: when token is missing or empty, and when no fetch is available and you did not supply one (see Node without a global fetch).
Overriding the base URL
Pass baseUrl to target a different environment such as staging or a regional gateway. Trailing slashes are trimmed, so https://staging.api.geopera.com/ and https://staging.api.geopera.com behave identically.
const staging = new GeoperaClient({
token: process.env.GEOPERA_STAGING_TOKEN!,
baseUrl: 'https://staging.api.geopera.com'
});
const out = await staging.catalog.search({
collections: ['sentinel-2-l2a'],
limit: 10
});Only the origin is overridable. The request path is always ${baseUrl}/v1/op/${operation_id} — you cannot change the /v1/op/ prefix, insert path segments, or switch HTTP methods. client.baseUrl is exposed read-only on the instance if you need to log or assert the resolved origin. If you need a completely different routing scheme, call fetch yourself; the SDK is a thin, typed wrapper and hides nothing.
A staging token only works against staging, and a production token only works against production. Tokens are environment-scoped, so pairing the wrong token with a given baseUrl yields a 401 GeoperaError, not a silent fallback.
Injecting a custom fetch
The fetch option lets you supply any function with the standard fetch signature. This is how you support older Node, swap in undici for connection pooling, run on an edge runtime, or stub the network in tests. The client stores it once at construction and uses it for every request — both namespace methods and invoke go through it.
Node without a global fetch
On Node below 18 there is no globalThis.fetch, so the constructor throws unless you pass one. Use undici:
import { GeoperaClient } from '@geopera/sdk';
import { fetch as undiciFetch } from 'undici';
const client = new GeoperaClient({
token: process.env.GEOPERA_TOKEN!,
fetch: undiciFetch as unknown as typeof fetch
});The cast is only to reconcile undici’s types with the DOM fetch type; at runtime the call is direct. On Node 18 and newer the global fetch is present and no override is needed.
Connection pooling with undici
For high-throughput servers, give undici a tuned Agent and bind it into a fetch wrapper. Reusing one dispatcher keeps connections warm across many operations:
import { GeoperaClient } from '@geopera/sdk';
import { Agent, fetch as undiciFetch } from 'undici';
const dispatcher = new Agent({ connections: 64, pipelining: 1 });
const pooledFetch = ((input, init) =>
undiciFetch(input as any, { ...(init as any), dispatcher })) as typeof fetch;
const client = new GeoperaClient({
token: process.env.GEOPERA_TOKEN!,
fetch: pooledFetch
});The wrapper must forward init unchanged — the SDK passes method, headers, body, and signal through init, and dropping any of them (notably signal) breaks cancellation. Spread init first, then add dispatcher, as shown.
Test stubs
Because fetch is just a function, you can assert on the exact request and return a canned response without touching the network. This is the cleanest way to unit-test code that wraps the client:
import { GeoperaClient } from '@geopera/sdk';
const calls: { url: string; init?: RequestInit }[] = [];
const stubFetch: typeof fetch = async (input, init) => {
calls.push({ url: String(input), init });
return new Response(JSON.stringify({ items: [], total: 0 }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
const client = new GeoperaClient({ token: 'gpra_test', fetch: stubFetch });
await client.catalog.search({ collections: ['sentinel-2-l2a'], limit: 5 });
// calls[0].url === "https://api.geopera.com/v1/op/catalog.search"
// calls[0].init?.method === "POST"
// JSON.parse(String(calls[0].init?.body)) === { collections: ["sentinel-2-l2a"], limit: 5 }To exercise error handling, return a non-2xx Response with a problem+json body and assert your code catches a GeoperaError:
const failingFetch: typeof fetch = async () =>
new Response(
JSON.stringify({
type: 'about:blank',
title: 'Payment Required',
detail: 'Insufficient credits'
}),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);The client reads the response body as text and JSON.parses it (an empty body becomes undefined), so stubs must return valid JSON or an empty body — never malformed JSON, which would surface as a SyntaxError rather than a GeoperaError.
Edge runtimes
On Cloudflare Workers, Vercel Edge, and Deno the global fetch is already Web-standard, so no override is needed — construct the client normally. If your platform exposes a runtime-specific fetch (for example a Workers service binding’s fetch), pass it through the fetch option the same way. Construct one client per request rather than at module top level if your token is request-scoped.
Custom headers
Headers set on the constructor are merged into every request. The SDK always sends Authorization: Bearer <token>, Content-Type: application/json, and Accept: application/json; your headers are spread in after those, so a key you set with the same name overrides the default. Avoid re-setting Authorization or Content-Type unless you specifically intend to override them.
const client = new GeoperaClient({
token: process.env.GEOPERA_TOKEN!,
headers: {
'X-Client-Trace': 'batch-pipeline',
'X-Tenant': 'acme-corp'
}
});Constructor headers are fixed for the life of the client and apply to every call. There is no per-call headers argument on namespace methods or on invoke — those accept only (body, { signal }). When you need a header that varies per request, such as an Idempotency-Key, you have two options:
- Construct a short-lived client whose
headersinclude the per-request value, then make the call. - Use the functional
callOperationhelper, which takes aheadersmap in its per-call options. This is usually the cleaner choice for a one-off request-scoped header.
Idempotency-Key
Operations that create or charge — placing orders, starting jobs — should carry an Idempotency-Key so a retry after a network blip does not double-submit. The key is just a header, so set it via callOperation:
import { callOperation } from '@geopera/sdk';
const order = await callOperation(
'orders.archive.place',
{ project_id: projectId, captures: [{ id: 'scene-abc', geometry }] },
process.env.GEOPERA_TOKEN!,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
);callOperation(op, body, token, options) is the functional one-shot equivalent of a namespace method: it builds a single-use client from token plus any of baseUrl, headers, and fetch, then invokes op. Its options also accept a signal. Because it constructs a fresh client each call, prefer it for occasional request-scoped headers, not for a hot path — reuse a long-lived GeoperaClient there.
Generate one key per logical attempt and reuse it across retries of that attempt — that is what makes the retry safe. See Idempotency for the full contract and replay semantics.
If you prefer to keep using namespace methods, the equivalent is a short-lived client:
import { GeoperaClient } from '@geopera/sdk';
const scoped = new GeoperaClient({
token: process.env.GEOPERA_TOKEN!,
headers: { 'Idempotency-Key': crypto.randomUUID() }
});
const order = await scoped.orders.archive.place({
project_id: projectId,
captures: [{ id: 'scene-abc', geometry }]
});Request cancellation
Every namespace method takes an options object as its second argument with a single signal field; invoke takes the same options as its third argument. Wire signal to an AbortController to cancel an in-flight request, or to AbortSignal.timeout(ms) to bound how long a single call may run.
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5_000);
try {
const results = await client.catalog.search(
{ collections: ['sentinel-2-l2a'], limit: 100 },
{ signal: controller.signal }
);
console.log(results);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.warn('search cancelled');
} else {
throw err;
}
} finally {
clearTimeout(t);
}For a pure timeout without manual control, AbortSignal.timeout is shorter:
await client.catalog.search(
{ collections: ['sentinel-2-l2a'], limit: 100 },
{ signal: AbortSignal.timeout(5_000) }
);A single AbortSignal can drive several calls — fan out concurrent operations under one controller and abort them all at once:
const controller = new AbortController();
const opts = { signal: controller.signal };
const [scenes, projects] = await Promise.all([
client.catalog.search({ collections: ['sentinel-2-l2a'], limit: 50 }, opts),
client.projects.list({ limit: 50 }, opts)
]);An aborted request rejects with an AbortError (a DOMException), not a GeoperaError — GeoperaError is thrown only for non-2xx HTTP responses. Branch on the two separately. The signal is forwarded straight to the underlying fetch, so a custom fetch must honour init.signal for cancellation to work (undici and all Web-standard runtimes do).
Manual pagination
The SDK does not auto-paginate. Listing-style operations take pagination fields in their JSON body — typically limit plus either offset or page — and return a page of results alongside a total. Drive the loop yourself, advancing the offset until you have collected everything. See Pagination for the field conventions and response envelope.
import { GeoperaClient, type OperationInput, type OperationOutput } from '@geopera/sdk';
async function listAll(client: GeoperaClient, collections: string[]) {
const limit = 100;
let offset = 0;
const all: OperationOutput<'catalog.search'>['items'] = [];
for (;;) {
const body: OperationInput<'catalog.search'> = { collections, limit, offset };
const page = await client.catalog.search(body);
all.push(...page.items);
offset += page.items.length;
if (page.items.length < limit || all.length >= page.total) break;
}
return all;
}The OperationInput<K> and OperationOutput<K> helper types are exported from the package and give you the exact request and response shape for any operation id K. The field names (items/total, offset vs page) are operation-specific and fully typed — let your editor’s autocomplete confirm what each operation expects. Always stop on a short page rather than assuming a fixed count, and respect rate limits when paging large result sets.
To stream results to a consumer instead of buffering everything, yield each page from an async generator:
async function* pages(client: GeoperaClient, collections: string[], limit = 100) {
let offset = 0;
for (;;) {
const page = await client.catalog.search({ collections, limit, offset });
yield page.items;
offset += page.items.length;
if (page.items.length < limit || offset >= page.total) break;
}
}
for await (const batch of pages(client, ['sentinel-2-l2a'])) {
// process one page at a time, bounded memory
}When to drop to invoke
The namespace methods cover every operation in the registry. You only need invoke — or callOperation — when the operation id is not a literal in your source, for example when it is read from config, computed in a dispatcher, or passed in from a caller:
import { GeoperaClient, type OperationId } from '@geopera/sdk';
async function run(client: GeoperaClient, op: OperationId, body: unknown) {
// `op` is dynamic, so the dotted accessor isn't available — use invoke.
return client.invoke(op, body as never);
}Note that a runtime field such as a job_id does not require invoke: it travels in the request body, not the path, so client.processing.jobs.get({ job_id }) is the right call. Reach for invoke only when the operation id itself is dynamic.
Recipe: estimate, then place with idempotency
A common two-step flow: price the work first, then commit it with an idempotency key so the placement survives retries.
import { GeoperaClient, GeoperaError, callOperation } from '@geopera/sdk';
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
const captures = [{ id: 'scene-abc', geometry }];
// 1. Estimate cost — read-only, no idempotency key needed.
const estimate = await client.orders.archive.estimate({
project_id: projectId,
captures
});
console.log(`Estimated ${estimate.total_credits} credits`);
// 2. Place the order, reusing one key across any retries of this attempt.
const idempotencyKey = crypto.randomUUID();
try {
const order = await callOperation(
'orders.archive.place',
{ project_id: projectId, captures },
process.env.GEOPERA_TOKEN!,
{ headers: { 'Idempotency-Key': idempotencyKey } }
);
console.log(order.id, order.status);
} catch (err) {
if (err instanceof GeoperaError && err.status === 402) {
console.error('Insufficient credits:', err.problem);
} else {
throw err;
}
}GeoperaError carries the HTTP status, the parsed problem+json body as problem, and the failing operation id. Branch on status — for example 402 payment required, 409 conflict — and read problem.detail for the human-readable message.
Recipe: poll a processing job
Long-running operations return a job whose status you poll a status operation for. Use AbortSignal.timeout to bound each poll and a wall-clock deadline to bound the whole loop. The job_id rides in the body, so this stays on namespace methods throughout:
import { GeoperaClient, type OperationInput } from '@geopera/sdk';
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
async function runAndWait(input: OperationInput<'processing.ndvi.run'>) {
const job = await client.processing.ndvi.run(input);
const deadline = Date.now() + 10 * 60_000; // 10 minutes
for (;;) {
const status = await client.processing.jobs.get(
{ job_id: job.id },
{ signal: AbortSignal.timeout(15_000) }
);
if (status.state === 'succeeded') return status;
if (status.state === 'failed') {
throw new Error(`job ${job.id} failed: ${status.error ?? 'unknown'}`);
}
if (Date.now() > deadline) {
throw new Error(`job ${job.id} did not finish within the deadline`);
}
await new Promise((r) => setTimeout(r, 5_000));
}
}Poll on an interval (here 5s) rather than in a tight loop, and always enforce a deadline so a stuck job cannot hang your worker. The operation ids and status field names above are illustrative — confirm the exact ones for your job type against Operations and your editor’s autocomplete.
Recipe: retry transient failures
GeoperaError exposes status, so you can retry only the codes worth retrying — 429 (rate limited) and 5xx (transient server errors) — and fail fast on the rest. Pair retries with an idempotency key for any operation that creates or charges, so a retried request is deduplicated server-side.
import {
GeoperaClient,
GeoperaError,
type OperationId,
type OperationInput,
type OperationOutput
} from '@geopera/sdk';
async function withRetry<K extends OperationId>(
fn: () => Promise<OperationOutput<K>>,
attempts = 4
): Promise<OperationOutput<K>> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (err) {
const retryable = err instanceof GeoperaError && (err.status === 429 || err.status >= 500);
if (!retryable || i >= attempts - 1) throw err;
const backoff = Math.min(2 ** i * 250, 4_000) + Math.random() * 250;
await new Promise((r) => setTimeout(r, backoff));
}
}
}
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
const body: OperationInput<'catalog.search'> = {
collections: ['sentinel-2-l2a'],
limit: 100
};
const results = await withRetry<'catalog.search'>(() => client.catalog.search(body));Use exponential backoff with jitter, as above, and honour any Retry-After you find in problem when present. Never retry a 4xx other than 429 — those are client errors that a retry will not fix. See Errors and Rate limits for the full status taxonomy.