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:

typescript
import { GeoperaClient, GeoperaError, callOperation, DEFAULT_BASE_URL } from '@geopera/sdk';

import type {
	GeoperaClientOptions,
	Namespaces,
	OperationMethod,
	InvokeOptions,
	OperationId,
	OperationInput,
	OperationOutput,
	operations
} from '@geopera/sdk';
ExportKindPurpose
GeoperaClientclassReusable, fully-typed client with resource namespaces.
callOperationfunctionOne-shot functional call (throwaway client).
GeoperaErrorclassThrown on any non-2xx response; carries the problem+json body.
DEFAULT_BASE_URLconst"https://api.geopera.com".
GeoperaClientOptionstypeConstructor options.
NamespacestypeThe shape of the resource-namespace tree on a client.
OperationMethod<K>typeThe signature of a single namespace method (operation K).
InvokeOptionstypePer-call options — currently { signal?: AbortSignal }.
OperationIdtypeUnion of every valid operation_id.
OperationInput<K>typeRequest body type for operation K.
OperationOutput<K>typeSuccess response type for operation K.
operationstypeThe 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

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

typescript
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 overridden

GeoperaClient

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.

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

typescript
// 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 any symbol key 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 then through (it is not present on the instance, so it reads as undefined), which means await client does not hang or resolve to a half-built node. You only ever await the 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.archive that is both callable and has .place underneath.
  • Typos surface in the types, not at runtime. Because the tree is a proxy, client.catalog.serach (misspelled) is a Namespaces-typed access that TypeScript rejects at compile time. If you reach it through an as any cast, the misspelled path is sent to the API verbatim and comes back as a GeoperaError (an unknown operation), not a thrown TypeError.

Method signature

Each leaf method has the signature:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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

typescript
new GeoperaClient(opts: GeoperaClientOptions)

The constructor validates its inputs eagerly and throws synchronously in two cases:

  • Missing token — if opts is falsy or opts.token is empty ("", undefined, null): GeoperaClient: \token` is required (a Geopera API key or session token).`
  • No fetch available — if neither opts.fetch nor globalThis.fetch exists (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

typescript
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;
}
FieldTypeDefaultNotes
tokenstring— (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.
baseUrlstringDEFAULT_BASE_URLTrailing slashes are stripped. Point this at a staging host when needed.
headersRecord<string, string>{}Merged into every request. Useful for tracing headers.
fetchtypeof fetchglobalThis.fetchInject 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:

typescript
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’s fetch;
  • stub the network in unit tests, returning canned Response objects;
  • thread requests through a proxy or add instrumentation by wrapping the global.
typescript
// 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

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

typescript
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:

typescript
const results = await client.invoke('catalog.search', {
	collections: ['sentinel-2-l2a'],
	limit: 10
});

Mechanically, invoke:

  1. issues POST {baseUrl}/v1/op/{op} with headers Authorization: Bearer <token>, Content-Type: application/json, Accept: application/json, followed by your constructor headers (which therefore win on conflict);
  2. serializes the request with JSON.stringify(body ?? {}), so a nullish body becomes {} on the wire;
  3. reads the full response as text, then parses it as JSON when the text is non-empty (an empty 202/204-style body resolves to undefined);
  4. on a non-2xx status, throws GeoperaError carrying the status, the parsed problem+json body, and the operation id;
  5. 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:

typescript
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:

typescript
// `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.

typescript
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>>;
typescript
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 GeoperaClient for many calls. When you make more than one request, build one client and call its namespace methods repeatedly instead of calling callOperation in a loop. Same token validation, same fetch, no per-call construction.

typescript
// 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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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:

typescript
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:

typescript
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

  • Authenticationgpra_ 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 OperationId and its types.