Client reference
The @geopera/sdk package exposes a single fully typed client whose primary surface is a tree of resource namespaces — client.catalog.search(body), client.orders.archive.place(body) — with invoke and callOperation underneath as the low-level escape hatch.
Every capability on the Geopera platform is an operation reachable at POST /v1/op/{operation_id}. There are no REST-style routes: no path parameters, no query strings, no GET/PUT/PATCH/DELETE. List, get, create, and delete intent live in the operation name (catalog.search, orders.archive.get, orders.cancel), and every input travels in the JSON request body. The SDK mirrors that model directly — each namespace method, invoke, and callOperation issue exactly one POST and infer the body and return type from the operation id, so calls are checked at compile time and track the live API by construction.
Exports
The package’s public surface is small and stable. It is built around one fully typed client:
import { GeoperaClient, GeoperaError, callOperation, DEFAULT_BASE_URL } from '@geopera/sdk';
import type {
GeoperaClientOptions,
Namespaces,
OperationMethod,
InvokeOptions,
OperationId,
OperationInput,
OperationOutput,
operations
} from '@geopera/sdk';| Export | Kind | Purpose |
|---|---|---|
GeoperaClient | class | Reusable, fully-typed client with resource namespaces. |
callOperation | function | One-shot functional call (throwaway client). |
GeoperaError | class | Thrown on any non-2xx response; carries the problem+json body. |
DEFAULT_BASE_URL | const | "https://api.geopera.com". |
GeoperaClientOptions | type | Constructor options. |
Namespaces | type | The shape of the resource-namespace tree on a client. |
OperationMethod<K> | type | The signature of a single namespace method (operation K). |
InvokeOptions | type | Per-call options — currently { signal?: AbortSignal }. |
OperationId | type | Union of every valid operation_id. |
OperationInput<K> | type | Request body type for operation K. |
OperationOutput<K> | type | Success response type for operation K. |
operations | type | The full operations map keyed by operation_id. |
The four runtime values (GeoperaClient, callOperation, GeoperaError, DEFAULT_BASE_URL) are regular ESM exports. Everything else is a type, erased at build time — import them with import type so your bundler drops them cleanly. There are no default exports, no namespaces with side effects, and no globals: importing the package does nothing until you construct a client or call callOperation.
GeoperaError is covered in detail on the errors page. The operations catalogue lists every OperationId and its body and response shapes.
DEFAULT_BASE_URL
export const DEFAULT_BASE_URL = 'https://api.geopera.com';This is the production base URL used when you do not pass baseUrl. It is exported so you can reference it directly — for example, to assert in a test that a client points at production, or to build the same URL elsewhere in your code without hardcoding the string.
import { GeoperaClient, DEFAULT_BASE_URL } from '@geopera/sdk';
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
const isProd = client.baseUrl === DEFAULT_BASE_URL; // true unless baseUrl was overriddenGeoperaClient
The primary entry point. Construct one client and reuse it for the lifetime of your process — it holds your token, headers, and fetch implementation, and there is no connection state to leak or pool to drain. Constructing a client is cheap (no I/O, no network), but reusing one keeps your token validation and header set in a single place.
import { GeoperaClient } from '@geopera/sdk';
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
const results = await client.catalog.search({
collections: ['sentinel-2-l2a'],
limit: 10
});Namespaces
Every operation is reachable as a method on a resource namespace derived from its id. The dotted operation id becomes a property path: catalog.search is client.catalog.search, and deeply nested operations nest the same way — orders.archive.place is client.orders.archive.place, orders.tasking.templates.save is client.orders.tasking.templates.save.
// catalog.search
const results = await client.catalog.search({
collections: ['sentinel-2-l2a'],
limit: 10
});
// orders.archive.place — deep nesting
const order = await client.orders.archive.place({
// ...order body
});How the namespace tree is built
The namespace surface is not a hand-written object — it is a Proxy. Reading an unknown string property off the client descends one path segment and returns another callable proxy node; calling a node invokes the operation at the accumulated dotted path. So client.orders is a node for the orders prefix, client.orders.archive extends it, and client.orders.archive.place(body) is exactly client.invoke("orders.archive.place", body).
A few consequences are worth knowing:
- Known members pass through. Real instance members —
invoke,baseUrl— and anysymbolkey are returned as-is, never shadowed by a namespace node. Only unknown string properties descend into the tree. - The client is not a thenable. The proxy explicitly passes
thenthrough (it is not present on the instance, so it reads asundefined), which meansawait clientdoes not hang or resolve to a half-built node. You only everawaitthe promise returned by a leaf call. - Operation ids form a collision-free tree. No operation id is a strict prefix of another, so a node is never simultaneously a callable operation and a parent group. You will not find an
orders.archivethat is both callable and has.placeunderneath. - Typos surface in the types, not at runtime. Because the tree is a proxy,
client.catalog.serach(misspelled) is aNamespaces-typed access that TypeScript rejects at compile time. If you reach it through anas anycast, the misspelled path is sent to the API verbatim and comes back as aGeoperaError(an unknown operation), not a thrownTypeError.
Method signature
Each leaf method has the signature:
type OperationMethod<K extends OperationId> = (
body: OperationInput<K>,
options?: InvokeOptions
) => Promise<OperationOutput<K>>;The body is type-checked against that operation’s input model and the resolved promise is typed as its output model — both are inferred, so you get completion and errors as you type. Every namespace method delegates to invoke, so they share its exact request, error, and cancellation behaviour.
When an operation’s input model is fully optional (every field optional, i.e. the input type accepts {}), OperationMethod makes the body argument itself optional, so you can call it with no arguments:
// Operation whose input model is fully optional — body may be omitted.
const me = await client.account.whoami();
// Equivalent, passing an explicit empty body.
const meExplicit = await client.account.whoami({});For operations that require fields, the body argument is required and TypeScript will flag a missing or incomplete body.
Cancellation and timeouts
The second argument is InvokeOptions, currently an optional AbortSignal for cancellation and timeouts. The SDK does not impose its own timeout, so if you want one, wire an AbortController to a timer:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const out = await client.catalog.search(
{ collections: ['sentinel-2-l2a'], limit: 50 },
{ signal: controller.signal }
);
// use out
} finally {
clearTimeout(timer);
}When the signal aborts, the underlying fetch rejects with an AbortError (DOMException with name === "AbortError"), not a GeoperaError — no response ever arrived. Distinguish the two when you catch:
try {
await client.catalog.search({ collections: ['sentinel-2-l2a'] }, { signal });
} catch (err) {
if (err instanceof GeoperaError) {
// The server answered with a non-2xx status.
} else if (err instanceof Error && err.name === 'AbortError') {
// The request was cancelled / timed out before a response.
} else {
// Network failure, DNS, TLS, etc. — fetch rejected.
throw err;
}
}You can reuse one AbortSignal across several concurrent calls to cancel them as a group, or pass AbortSignal.timeout(30_000) on runtimes that support it for a one-liner timeout without managing a controller.
The full namespace tree is typed as Namespaces; you rarely name it directly, since the methods are reached through the client instance.
Constructor
new GeoperaClient(opts: GeoperaClientOptions)The constructor validates its inputs eagerly and throws synchronously in two cases:
- Missing token — if
optsis falsy oropts.tokenis empty ("",undefined,null):GeoperaClient: \token` is required (a Geopera API key or session token).` - No
fetchavailable — if neitheropts.fetchnorglobalThis.fetchexists (for example Node below 18 without a polyfill):GeoperaClient: no global \fetch` available — pass `fetch` (Node below 18).`
Because these throw at construction rather than on the first request, a misconfigured client fails fast and loudly — there is no deferred error to chase later. The token is not validated against the API at construction (that would require a network round trip); an invalid or revoked key surfaces as a GeoperaError with a 401 status on the first call. See authentication.
On success it normalizes baseUrl by stripping trailing slashes, so https://api.geopera.com/ and https://api.geopera.com behave identically.
GeoperaClientOptions
interface GeoperaClientOptions {
/** A Geopera API key (`gpra_...`) or a session token. */
token: string;
/** API base URL. Defaults to `https://api.geopera.com`. */
baseUrl?: string;
/** Extra headers sent on every request. */
headers?: Record<string, string>;
/** Custom fetch (test stub, undici, etc.). Defaults to the global `fetch`. */
fetch?: typeof fetch;
}| Field | Type | Default | Notes |
|---|---|---|---|
token | string | — (required) | Either a minted API key with the gpra_ prefix or a session token (obtained by signing in to Geopera). Sent as Authorization: Bearer <token>. See authentication. |
baseUrl | string | DEFAULT_BASE_URL | Trailing slashes are stripped. Point this at a staging host when needed. |
headers | Record<string, string> | {} | Merged into every request. Useful for tracing headers. |
fetch | typeof fetch | globalThis.fetch | Inject a stub for tests or undici on older runtimes. |
The API key is the bearer token: there is no token exchange, sign-in handshake, or refresh step for keys. A browser sign-in instead yields a short-lived session token, which you pass in exactly the same token slot. Both go out as Authorization: Bearer <token>.
Header precedence. The headers you supply are spread after the built-in headers (Authorization, Content-Type, Accept) on each request, so a key you provide overrides the SDK default for that name. This is deliberate — it lets you replace Content-Type or Accept when you must — but it also means a stray Authorization in headers silently replaces the bearer token. Reserve headers for additive concerns such as X-Request-Id or X-Request-Source:
const client = new GeoperaClient({
token: process.env.GEOPERA_TOKEN!,
headers: {
'X-Request-Source': 'ingest-worker',
'X-Request-Id': crypto.randomUUID()
}
});Because headers is captured once at construction, every request from that client carries the same set. For a per-request header (such as a unique request id), use one client per logical unit of work, or fall back to callOperation, whose headers apply only to that single call.
Custom fetch. The injected fetch must match the global fetch signature. Use it to:
- run on Node below 18, by passing
undici’sfetch; - stub the network in unit tests, returning canned
Responseobjects; - thread requests through a proxy or add instrumentation by wrapping the global.
// Test stub: assert the URL/headers and return a canned response.
const client = new GeoperaClient({
token: 'gpra_test',
fetch: async (url, init) => {
expect(String(url)).toBe('https://api.geopera.com/v1/op/catalog.search');
return new Response(JSON.stringify({ features: [] }), {
status: 200,
headers: { 'content-type': 'application/json' }
});
}
});baseUrl
readonly baseUrl: string;The resolved, slash-normalized base URL. It is read-only — set it through the constructor, not after construction. Every request is built as POST {baseUrl}/v1/op/{operation_id}, so overriding baseUrl is how you point a client at a staging or self-hosted environment while keeping every operation id and body identical.
invoke
invoke is the low-level method that every namespace method calls. Reach for it directly when you need a dynamic operation id (one chosen at runtime), or any operation that the namespace tree does not yet surface.
invoke<K extends OperationId>(
op: K,
body: OperationInput<K>,
options?: InvokeOptions,
): Promise<OperationOutput<K>>The generic K is pinned to the literal op you pass, so body is type-checked against that operation’s input model and the resolved promise is typed as its output model. The equivalent of client.catalog.search(body) is:
const results = await client.invoke('catalog.search', {
collections: ['sentinel-2-l2a'],
limit: 10
});Mechanically, invoke:
- issues
POST {baseUrl}/v1/op/{op}with headersAuthorization: Bearer <token>,Content-Type: application/json,Accept: application/json, followed by your constructorheaders(which therefore win on conflict); - serializes the request with
JSON.stringify(body ?? {}), so a nullish body becomes{}on the wire; - reads the full response as text, then parses it as JSON when the text is non-empty (an empty
202/204-style body resolves toundefined); - on a non-2xx status, throws
GeoperaErrorcarrying the status, the parsed problem+json body, and the operation id; - otherwise returns the parsed body typed as
OperationOutput<K>.
The third argument is InvokeOptions, carrying an optional AbortSignal for cancellation and timeouts — identical to the namespace methods:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const out = await client.invoke(
'catalog.search',
{ collections: ['sentinel-2-l2a'], limit: 50 },
{ signal: controller.signal }
);
// use out
} finally {
clearTimeout(timer);
}Dynamic ids stay type-safe. Because op is a typed OperationId, even a runtime-chosen id is checked against the union — but the input and output collapse to the union of all operations unless you narrow K. Narrow it for precise typing:
// `op` is known statically through a generic — body/result stay precise.
function run<K extends OperationId>(client: GeoperaClient, op: K, body: OperationInput<K>) {
return client.invoke(op, body);
}There is no GET/PUT/PATCH/DELETE surface and no path or query parameters: list, get, and delete intent live in the operation name, and all inputs travel in the JSON body. See concepts for the operation model and operations for the naming conventions.
callOperation
A functional one-shot equivalent of invoke for scripts and single calls. Like invoke, it is a low-level entry point — prefer the namespace methods when you have a long-lived client.
function callOperation<K extends OperationId>(
op: K,
body: OperationInput<K>,
token: string,
options?: {
baseUrl?: string;
headers?: Record<string, string>;
fetch?: typeof fetch;
signal?: AbortSignal;
}
): Promise<OperationOutput<K>>;import { callOperation } from '@geopera/sdk';
const results = await callOperation(
'catalog.search',
{ collections: ['sentinel-2-l2a'], limit: 10 },
process.env.GEOPERA_TOKEN!
);callOperation constructs a fresh GeoperaClient on every call: it splits signal out of options into the per-call invoke options and forwards the rest (baseUrl, headers, fetch) to the constructor alongside token. That means each call re-validates the token, re-resolves fetch, and applies its own headers — convenient for a single request, but the per-call client allocation makes it the wrong tool for a loop.
The shape mirrors the constructor options minus token (passed positionally) plus signal (which belongs to the call, not the client). Anything you would set on GeoperaClientOptions you can set here per call.
Reuse a
GeoperaClientfor many calls. When you make more than one request, build one client and call its namespace methods repeatedly instead of callingcallOperationin a loop. Same token validation, samefetch, no per-call construction.
// Prefer this for repeated calls.
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
for (const collection of collections) {
await client.catalog.search({ collections: [collection], limit: 10 });
}Exported types
The SDK is fully typed, so the types track the live API by construction. They are derived from the generated operations map, which means adding or changing an operation upstream flows into OperationId, OperationInput, OperationOutput, and the namespace tree with no hand-maintenance.
Namespaces
type Namespaces;The shape of the resource-namespace tree exposed on a GeoperaClient instance — catalog, orders, and so on, each carrying its operation methods (and nested namespaces for deep ids like orders.archive.place). It is built recursively from OperationId: each dotted id is split into path segments, and every leaf is an OperationMethod for that id. The client instance is declared to extend Namespaces, so you usually access methods through client.<resource>.<action> rather than naming Namespaces directly.
OperationMethod
type OperationMethod<K extends OperationId> =
Record<string, never> extends OperationInput<K>
? (body?: OperationInput<K>, options?: InvokeOptions) => Promise<OperationOutput<K>>
: (body: OperationInput<K>, options?: InvokeOptions) => Promise<OperationOutput<K>>;The signature of a single namespace method. Every leaf in the Namespaces tree is an OperationMethod for its operation id, so client.catalog.search is exactly OperationMethod<"catalog.search">.
The conditional is the part worth noting: when the operation’s input model is fully optional — i.e. Record<string, never> (an empty {}) is assignable to it — the body argument is itself optional and the method can be called with no arguments. For every other operation the body is required.
InvokeOptions
interface InvokeOptions {
/** Cancel the request / enforce a timeout. */
signal?: AbortSignal;
}The per-call options accepted by namespace methods and by invoke. It currently carries only an optional AbortSignal. It is a distinct, named type so you can build helpers that thread call options through without restating the shape.
operations
type operations;The full map of every operation, keyed by operation_id, generated from the API surface. Each entry describes the operation’s requestBody and responses. You rarely use it directly; OperationId, OperationInput, and OperationOutput are derived from it and are the types you reach for.
OperationId
type OperationId = keyof operations;The union of every valid operation id (for example "catalog.search", "orders.archive.place"). Passing any other string to invoke or callOperation is a compile-time error, and reading an unknown property off the namespace tree is similarly rejected. Use it to constrain your own helpers to real operations.
OperationInput
type OperationInput<K extends OperationId> = operations[K] extends {
requestBody: { content: { 'application/json': infer B } };
}
? B
: Record<string, never>;The request body type for operation K, resolved from its application/json request body. Operations that take no body resolve to Record<string, never> (i.e. {}) — which is exactly the condition that makes OperationMethod’s body argument optional.
OperationOutput
type OperationOutput<K extends OperationId> = operations[K] extends {
responses: infer R;
}
? R extends { 200: { content: { 'application/json': infer O } } }
? O
: R extends { 201: { content: { 'application/json': infer O } } }
? O
: R extends { 202: { content: { 'application/json': infer O } } }
? O
: unknown
: unknown;The success body type for operation K. Operations answer 200, 201, or 202 depending on the route (creation and money routes typically answer 201/202), and OperationOutput resolves whichever 2xx the operation declares, preferring 200, then 201, then 202, and falling back to unknown if none is typed.
You can name these types in your own signatures without restating the shapes:
import type { OperationInput, OperationOutput } from '@geopera/sdk';
type SearchBody = OperationInput<'catalog.search'>;
type SearchResult = OperationOutput<'catalog.search'>;
async function search(client: GeoperaClient, body: SearchBody): Promise<SearchResult> {
return client.catalog.search(body);
}Worked example
A small CLI-style script that constructs one client, runs a search with a timeout, and surfaces problem+json errors and cancellations cleanly:
import { GeoperaClient, GeoperaError } from '@geopera/sdk';
import type { OperationOutput } from '@geopera/sdk';
async function main() {
const token = process.env.GEOPERA_TOKEN;
if (!token) {
throw new Error('Set GEOPERA_TOKEN to a gpra_ key or a session token.');
}
// One client, reused for every call.
const client = new GeoperaClient({
token,
headers: { 'X-Request-Source': 'search-script' }
});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const results: OperationOutput<'catalog.search'> = await client.catalog.search(
{ collections: ['sentinel-2-l2a'], limit: 10 },
{ signal: controller.signal }
);
console.log(JSON.stringify(results, null, 2));
} catch (err) {
if (err instanceof GeoperaError) {
// RFC 9457 problem+json is preserved on err.problem.
console.error(`Operation '${err.operation}' failed (HTTP ${err.status})`);
console.error(err.problem);
process.exitCode = 1;
return;
}
if (err instanceof Error && err.name === 'AbortError') {
console.error('Search timed out after 30s.');
process.exitCode = 1;
return;
}
throw err;
} finally {
clearTimeout(timer);
}
}
main();Related
- Authentication —
gpra_API keys and session tokens. - Scopes — what a token is allowed to invoke.
- Errors — the problem+json body carried by
GeoperaError. - Concepts — the operation model behind
POST /v1/op/{operation_id}. - Operations — the operation naming model.
- Operations catalogue — every
OperationIdand its types.