TypeScript SDK Quickstart

Go from an empty project to your first authenticated, type-checked operation call with @geopera/sdk in a couple of minutes: every capability is exposed as a typed resource namespace — you write client.catalog.search({...}) and client.orders.archive.place({...}) — with the request body and the awaited return type inferred from the method you call, so the whole API surface is checked at compile time and tracks the live API by construction.

Install

The package is published to npm as @geopera/sdk. It ships ESM + CJS, is tree-shakeable, ships its own .d.ts types, and has no runtime dependencies — it is a thin, typed wrapper over fetch.

bash
npm install @geopera/sdk
# or: pnpm add @geopera/sdk
# or: yarn add @geopera/sdk

It runs anywhere fetch is available: Node 18+, Deno, Bun, edge runtimes, and the browser. On Node versions older than 18 (which lack a global fetch), pass your own fetch in the client options — see Advanced configuration. If no fetch is reachable and you do not supply one, the constructor throws immediately rather than failing on the first request.

Authenticate

The client takes a single token. That token is either a minted Geopera API key (prefix gpra_) or a session token obtained by signing in to Geopera. The platform accepts both and runs every call as that principal, with its scopes, audit trail, and provenance. The token is sent verbatim as Authorization: Bearer <token> on every request — there is no exchange, refresh, or grant step to perform in the SDK.

typescript
import { GeoperaClient } from '@geopera/sdk';

// Headless / server-to-server — a Geopera API key (create one in the portal).
const client = new GeoperaClient({ token: 'gpra_...' });

// Or a signed-in user's session token.
const client = new GeoperaClient({ token: userSessionToken });

Keep API keys server-side; never ship a gpra_ key in browser bundles. In the browser, construct the client with a short-lived user session token instead. For how tokens are issued and what governs a call, see Authentication and Scopes.

The token is required — constructing a client without one throws synchronously:

typescript
new GeoperaClient({} as any);
// Error: GeoperaClient: `token` is required (a Geopera API key or session token).

Your first call

Every operation is a typed method on a resource namespace: the operation id catalog.search is client.catalog.search(...), and deeply nested ids map to deeper namespaces — orders.archive.place is client.orders.archive.place(...). Here is a complete, runnable example that searches the catalog:

typescript
import { GeoperaClient } from '@geopera/sdk';

const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });

const res = await client.catalog.search({
	collections: ['sentinel-2-l2a'],
	limit: 10
});
//    ^? typed as the catalog.search output model

console.log(res);

That is the entire happy path: one import, one constructor, one await. The base URL defaults to https://api.geopera.com, so there is nothing else to wire up. Every call is a POST to /v1/op/{operationId} under the hood — there are no GET routes, no path parameters, and no query strings to assemble. Browse every namespace and method in the Operations reference.

How type inference works

Each namespace method is generated from its operation, so TypeScript resolves the request body (the operation’s input model) and the success model (its output) from the method you call alone. These types are derived from the same OpenAPI document that drives the REST API and the Python SDK, so there is nothing hand-maintained to drift.

Three things follow from this:

  • A wrong body is a compile-time error. Misname a field, omit a required one, or pass the wrong type and your editor flags it before you ever run the code.
  • The result is typed for you. The awaited value of client.catalog.search(...) is exactly the catalog.search output model — autocomplete and noUncheckedIndexedAccess work end to end.
  • Discoverable by namespace. Type client.orders. and your editor lists every order operation, each with its own typed body and return.
typescript
// ✅ Body checked against the operation's input model.
await client.catalog.search({ collections: ['sentinel-2-l2a'], limit: 10 });

// ❌ Compile error: `limt` is not a known field, and `collections` must be string[].
await client.catalog.search({ collections: 'sentinel-2-l2a', limt: 10 });

Namespaces encode the verb — there are no HTTP verbs or path params to choose. List, get, create, and place semantics live in the method name (for example client.organizations.create(...), client.orders.archive.place(...)). The namespace tree is built from a Proxy: accessing a property descends one path segment, and calling a node invokes the operation at the accumulated path. Operation ids are collision-free as a tree, so a node is never simultaneously a group and a callable — client.orders is a namespace you can keep descending, while client.orders.archive.place is the method you call.

The Proxy is also Promise-safe: it deliberately does not expose a then, so awaiting a namespace node accidentally (or returning a client.x.y reference from an async function) will not be mistaken for a thenable and silently resolved. You always have to call the method to fire a request.

Output resolves the first present 2xx

Operations do not all answer 200. The resolved return type reflects whichever 2xx the operation declares, in priority order 200 → 201 → 202: if the operation documents a 200 body, that is the awaited type; otherwise the 201 body; otherwise the 202 body. In practice money and asynchronous routes return 201 (created) or 202 (accepted), and the awaited type reflects whatever that operation actually declares:

typescript
// e.g. a create route that answers 201 — the awaited type is its 201 model.
const org = await client.organizations.create({ name: 'Acme' });

// e.g. an async/long-running route that answers 202 — the awaited type is its 202 model.
const job = await client.orders.tasking.place({
	/* ...tasking body... */
});

You do not configure this; it is built into the method’s return type. Your code just sees the correct success model regardless of which 2xx status the operation uses. At runtime the client treats every res.ok status the same way — it parses the JSON body and returns it — so a 200, 201, and 202 all flow through the same path and are typed accordingly.

A complete, error-handled example

A non-2xx response throws a GeoperaError. Everything else returns the parsed, typed body. Here is an end-to-end program that authenticates, runs a call with a timeout, and handles each failure mode distinctly:

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

const token = process.env.GEOPERA_TOKEN;
if (!token) throw new Error('Set GEOPERA_TOKEN in the environment.');

const client = new GeoperaClient({ token });

async function searchRecentScenes() {
	const controller = new AbortController();
	const timeout = setTimeout(() => controller.abort(), 10_000);

	try {
		const res = await client.catalog.search(
			{ collections: ['sentinel-2-l2a'], limit: 10 },
			{ signal: controller.signal }
		);
		return res; // typed as the catalog.search output model
	} catch (err) {
		if (err instanceof GeoperaError) {
			// Platform answered with a non-2xx status + RFC 9457 problem+json body.
			switch (err.status) {
				case 401:
				case 403:
					console.error('Auth/scope problem on', err.operation, err.problem);
					break;
				case 422:
					console.error('Invalid request body:', err.problem);
					break;
				case 429:
					console.error('Rate limited — back off and retry:', err.problem);
					break;
				default:
					console.error('Operation failed:', err.status, err.operation, err.problem);
			}
			throw err;
		}
		if (err instanceof DOMException && err.name === 'AbortError') {
			// The AbortController fired — request was cancelled / timed out.
			console.error('catalog.search timed out');
			throw err;
		}
		// Network failure, JSON parse failure, etc. — surfaced as-is.
		throw err;
	} finally {
		clearTimeout(timeout);
	}
}

await searchRecentScenes();

GeoperaError carries three fields you can branch on:

FieldTypeMeaning
statusnumberThe HTTP status of the failing response (e.g. 403, 422, 429).
operationstringThe operation id that failed (e.g. catalog.search).
problemunknownThe parsed RFC 9457 problem+json body Geopera returns for business and permission errors.

Its message is a stable, human-readable summary of the form Geopera operation '<operation>' failed (HTTP <status>). Only the platform’s own non-2xx responses become a GeoperaError; transport-level failures (DNS, TLS, an aborted signal) propagate as the native errors fetch throws, so distinguish them with instanceof GeoperaError. For the full problem+json schema and the catalogue of error types, see Errors.

Escape hatch: invoke()

The typed namespaces are the recommended surface. Underneath, every call routes through one generic method, invoke(operationId, body, options?), which POSTs body to /v1/op/{operationId} and returns the parsed response. Reach for it when the operation id is dynamic — chosen at runtime, or not known to the generated namespaces:

typescript
const opId = 'catalog.search'; // resolved at runtime
const res = await client.invoke(opId, { collections: ['sentinel-2-l2a'], limit: 10 });

invoke is generic over the operation id string literal, so when you pass a literal it stays fully typed (body and return inferred); pass a string variable and you opt into looser typing. It shares the same auth, error handling, and 2xx resolution as the namespace methods — in fact the namespaces are literally typed shorthands that call straight into invoke. The third options argument is the same { signal } shape the namespace methods accept.

One-shot: callOperation()

For a single call where constructing and holding a client is overkill, the package exports a functional helper, callOperation, that builds a client, runs one operation, and returns the typed result:

typescript
import { callOperation } from '@geopera/sdk';

const org = await callOperation('organizations.create', { name: 'Acme' }, 'gpra_...');

Its signature is callOperation(operationId, body, token, options?). The fourth argument accepts the same client knobs plus a per-call signal:

typescript
const res = await callOperation('catalog.search', { limit: 50 }, process.env.GEOPERA_TOKEN!, {
	baseUrl: 'https://staging-api.geopera.com',
	headers: { 'X-Request-Source': 'my-app' },
	fetch: myFetch,
	signal: ac.signal
});

It is exactly new GeoperaClient({ token, baseUrl, headers, fetch }).invoke(op, body, { signal }) — reach for GeoperaClient directly when you make more than one call so the client (and its config) is reused.

Advanced configuration

The constructor accepts a few optional fields alongside token:

OptionTypeDefaultPurpose
tokenstring— (required)API key (gpra_...) or session token.
baseUrlstringhttps://api.geopera.comTarget a different environment (e.g. staging). Trailing slashes are stripped.
headersRecord<string, string>{}Extra headers sent on every request.
fetchtypeof fetchglobal fetchCustom fetch implementation (undici, a test stub, Node below 18).
typescript
const client = new GeoperaClient({
	token: 'gpra_...',
	baseUrl: 'https://staging-api.geopera.com',
	headers: { 'X-Request-Source': 'my-app' },
	fetch: myFetch
});

The client always sets Authorization, Content-Type: application/json, and Accept: application/json; your headers are merged in afterward, so you can add tracing or routing headers but should not rely on overriding the auth or content-type headers. The resolved baseUrl is exposed read-only as client.baseUrl.

Each call accepts a trailing options argument with an AbortSignal for cancellation and timeouts:

typescript
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 5_000);
try {
	await client.catalog.search({ limit: 50 }, { signal: ac.signal });
} finally {
	clearTimeout(t);
}

The signal is forwarded straight to fetch, so aborting rejects the in-flight call with the runtime’s native AbortError (not a GeoperaError).

Exported types

For building your own typed helpers, the package exports the type machinery behind the client:

typescript
import type { OperationId, OperationInput, OperationOutput } from '@geopera/sdk';

// A reusable, fully-typed wrapper for one operation.
type SearchInput = OperationInput<'catalog.search'>;
type SearchOutput = OperationOutput<'catalog.search'>;

// e.g. a function that accepts any catalog.search body and returns its result.
async function runSearch(body: SearchInput): Promise<SearchOutput> {
	return client.catalog.search(body);
}

OperationId is the union of every operation id the API exposes; OperationInput<K> and OperationOutput<K> give you the request and success models for any id. They are derived from the Geopera OpenAPI document, so they stay in sync with the platform and with the Python SDK.

Gotchas

  • Keep gpra_ keys server-side. API keys grant your account’s scopes; do not embed them in browser builds. Use a user session token client-side instead.
  • Node below 18 has no global fetch. Pass a fetch implementation in the client options, or the constructor throws on construction (not on first call).
  • baseUrl trailing slashes are normalised. A baseUrl of https://x.test/ is stored as https://x.test, so request URLs never double up the slash.
  • An empty response body is returned as undefined. Operations that answer with no JSON resolve to undefined rather than throwing — type the result accordingly if you target such an operation.
  • Only platform non-2xx responses throw GeoperaError. Aborts surface as the native AbortError; network and JSON-parse failures propagate as their own errors. Always gate on err instanceof GeoperaError before reading status / problem.
  • Namespace nodes are not thenables. const p = client.catalog.search is a reference, not a pending request; you must call it (client.catalog.search(body)) to send anything.

Next steps