TypeScript SDK
@geopera/sdk is the official, dependency-free, fully typed TypeScript client for the Geopera platform. You call operations through resource namespaces — client.catalog.search(body) — and the SDK infers the request body and return type of every call at compile time, so a wrong field or a wrong shape is a build error rather than a runtime surprise.
Every Geopera capability is an operation reachable at POST /v1/op/{operation_id}. The namespace surface mirrors those operation ids one-to-one: client.catalog.search(...) is exactly the catalog.search operation, and client.orders.archive.place(...) is orders.archive.place. The same operation surface backs the REST API, the CLI, and the Python SDK — there are no hand-written types to drift.
Install
The package is published to npm as @geopera/sdk.
npm install @geopera/sdk
# or
pnpm add @geopera/sdk
# or
yarn add @geopera/sdkIt ships dual ESM + CommonJS builds with bundled .d.ts types, is marked sideEffects: false (tree-shakeable), targets node >= 18, and has zero runtime dependencies — it is a thin wrapper around fetch. The current major is 2.x; package versions follow semver independently of the /v1 API version. See Versioning for how the two relate.
Runtimes
The SDK runs anywhere a global fetch exists:
- Node.js 18+ — global
fetchis built in. - Deno and Bun — global
fetchis built in. - Edge runtimes — Vercel Edge Functions, Cloudflare Workers, and other V8-isolate environments.
- The browser — usable directly, but only with a short-lived session token, never an API key (see Authentication).
On Node versions older than 18 there is no global fetch. Pass your own — for example undici — via the fetch constructor option. If no fetch is available and none is supplied, the constructor throws immediately so the misconfiguration surfaces at startup rather than at first call. See Custom fetch.
Both ESM (import) and CommonJS (require) entry points are provided, so the snippets below work whether your project uses "type": "module" or CommonJS:
// ESM
import { GeoperaClient } from '@geopera/sdk';// CommonJS
const { GeoperaClient } = require('@geopera/sdk');Quickstart
Construct a client with a token, then call the operation as a method on its resource namespace. Each operation maps to client.<resource>.<action>(body), and the body and return type are inferred from that method:
import { GeoperaClient } from '@geopera/sdk';
const client = new GeoperaClient({ token: 'gpra_...' });
const res = await client.catalog.search({
collections: ['sentinel-2-l2a'],
limit: 10
});
// ^? typed as the catalog.search output model
console.log(res);The namespace mirrors the operation id, so deeply nested operations read naturally:
const order = await client.orders.archive.place({
// body fields are inferred from orders.archive.place's input model
});The method, its body type, and the awaited result type are all linked: pick a different action and your editor immediately knows the new body and return shapes. Passing a field the operation does not accept is a compile error.
Every operation is a POST — there are no GET-style methods. Operations that take no input still require a body argument; pass an empty object:
const me = await client.auth.whoami({});For a fuller walkthrough — installing, wiring an env-var token, and a first end-to-end call — see the Quickstart.
Escape hatch: invoke
Every namespaced method is sugar over a single dynamic call. When you need to call an operation whose id is only known at runtime — for example driving a list of operation ids from config — use invoke(operationId, body) directly. It has the same behaviour, auth, headers, and error handling as the namespaced methods:
const res = await client.invoke('catalog.search', {
collections: ['sentinel-2-l2a'],
limit: 10
});invoke is still fully typed: the operation id is constrained to the OperationId union, and the body and return type are inferred from the literal id you pass. The only thing you give up versus the namespaced form is editor discoverability of the namespace tree.
Prefer the namespaced form (client.catalog.search(...)) for everyday code. Reach for invoke only when the operation id is dynamic.
Authentication
The token you pass is sent as Authorization: Bearer <token> on every request. The token is the credential — there is no exchange, refresh, or grant step. Geopera accepts either of two kinds of token and runs the call as that principal, with its scopes, audit trail, and provenance:
// Headless / server-to-server: a minted API key (prefix gpra_).
const client = new GeoperaClient({ token: 'gpra_...' });
// A signed-in user's session token (obtained by signing in to Geopera).
const client = new GeoperaClient({ token: userSessionToken });An API key (prefix gpra_) is a long-lived credential you mint for headless and server-to-server use. A session token is the short-lived credential a browser sign-in yields; it is used the same way — as the bearer token — and carries the signed-in user’s scopes.
Never ship an API key to the browser or to client-side code. In browser and edge contexts, forward a short-lived user session token instead — typically minted by your own backend and handed to the page. See TypeScript SDK authentication for patterns, Authentication for how tokens are issued, and Scopes for what a principal is allowed to call.
The client
Constructor options
new GeoperaClient(options) accepts a single GeoperaClientOptions object:
| Option | Type | Default | Description |
|---|---|---|---|
token | string | — (required) | API key (gpra_...) or a user session token. |
baseUrl | string | https://api.geopera.com | API base URL. Trailing slashes are stripped. |
headers | Record<string, string> | {} | Extra headers sent on every request. |
fetch | typeof fetch | globalThis.fetch | Custom fetch implementation. |
token is required: omitting it throws GeoperaClient: \token` is required. If no globalfetchis available and none is supplied, the constructor throwsGeoperaClient: no global `fetch` available` so the misconfiguration surfaces at construction time.
The client is cheap to construct and holds no connections, but it is idiomatic to build one per token and reuse it across requests. To act as a different principal, construct a second client with that token.
Namespaced methods
Each operation is exposed as client.<resource>.<action>(body, options?), returning a Promise of the operation’s output model:
const search = await client.catalog.search(
{ collections: ['sentinel-2-l2a'], limit: 10 },
{ signal: ac.signal } // optional per-call options
);Under the hood the namespace surface is a Proxy: accessing a property descends one path segment (client.orders → client.orders.archive) and calling a node invokes the operation at the accumulated path. Operation ids are collision-free as a tree, so a node is never both a group and a callable. The practical consequence is that the whole operation surface is available with full types even though there is no generated method body to maintain.
Every method POSTs body (JSON-encoded) to /v1/op/{operation_id} with Authorization, Content-Type: application/json, and Accept: application/json headers, then parses and returns the JSON response. On a non-2xx response it throws a GeoperaError.
invoke(op, body, options?)
The dynamic escape hatch every namespaced method is built on:
async invoke<K extends OperationId>(
op: K,
body: OperationInput<K>,
options?: { signal?: AbortSignal },
): Promise<OperationOutput<K>>invoke POSTs body to /v1/op/{op} and behaves identically to the namespaced methods. Use it when the operation id is computed at runtime.
callOperation(op, body, token, options?)
A functional one-shot equivalent for when you do not want to keep a client around. It constructs a client internally and invokes once:
import { callOperation } from '@geopera/sdk';
const org = await callOperation('organizations.create', { name: 'Acme' }, 'gpra_...');Its options object accepts baseUrl, headers, fetch, and signal. For repeated calls, construct a GeoperaClient once and reuse it rather than calling callOperation in a loop — each call builds a fresh client.
Exported members
The package exports a small, stable surface. The full reference lives at Client reference:
import {
GeoperaClient, // the client class
GeoperaError, // thrown on non-2xx responses
callOperation, // one-shot functional helper
DEFAULT_BASE_URL // "https://api.geopera.com"
} from '@geopera/sdk';
import type {
GeoperaClientOptions,
OperationId, // union of every valid operation id
OperationInput, // request body type for an operation
OperationOutput // response type for an operation
} from '@geopera/sdk';Advanced configuration
Custom base URL and headers
Point at a staging environment and attach headers to every request:
const client = new GeoperaClient({
token: 'gpra_...',
baseUrl: 'https://staging-api.geopera.com',
headers: { 'X-Request-Source': 'my-app' }
});Per-client headers are merged into every request.
Note — The SDK always sets
Authorization,Content-Type, andAcceptafter spreading your headers, so those three cannot be overridden viaheaders.
Custom fetch
Inject any fetch-compatible implementation — handy for tests, for undici on old Node, or to add instrumentation such as logging, tracing, or retries:
import { GeoperaClient } from '@geopera/sdk';
import { fetch as undiciFetch } from 'undici';
const client = new GeoperaClient({
token: 'gpra_...',
fetch: undiciFetch as unknown as typeof fetch
});A wrapping fetch is the supported place to add cross-cutting behaviour like retry-on-429 with backoff, since the SDK does not retry on your behalf:
const retrying: typeof fetch = async (input, init) => {
for (let attempt = 0; ; attempt++) {
const res = await fetch(input, init);
if (res.status !== 429 || attempt >= 3) return res;
const retryAfter = Number(res.headers.get('Retry-After') ?? 1);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
};
const client = new GeoperaClient({ token: 'gpra_...', fetch: retrying });See Rate limits for the 429 and Retry-After semantics.
Cancellation and timeouts
Both namespaced methods and invoke forward an AbortSignal, so you can cancel in-flight requests or impose a timeout. An aborted request rejects with the runtime’s AbortError (a DOMException), not a GeoperaError — so distinguish the two when handling:
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 10_000);
try {
const res = await client.catalog.search(
{ collections: ['sentinel-2-l2a'], limit: 50 },
{ signal: ac.signal }
);
console.log(res);
} finally {
clearTimeout(timer);
}On runtimes that support it, AbortSignal.timeout(ms) is a concise alternative: { signal: AbortSignal.timeout(10_000) }.
Idempotency
For operations that create or mutate state, send an idempotency key via the per-client (or per-call) headers. Reusing the same key safely returns the original result instead of duplicating the action:
const client = new GeoperaClient({
token: 'gpra_...',
headers: { 'Idempotency-Key': crypto.randomUUID() }
});A per-client key is reused across every request, which is rarely what you want for distinct writes; prefer a fresh key per logical operation by setting it in per-call headers. See Idempotency for full semantics, and Advanced usage for more configuration patterns.
Error handling
Any non-2xx response throws a GeoperaError. It carries the HTTP status, the failing operation id, and the parsed RFC 9457 problem+json problem body Geopera returns for business and permission errors:
import { GeoperaClient, GeoperaError } from '@geopera/sdk';
const client = new GeoperaClient({ token: 'gpra_...' });
try {
await client.orders.tasking.estimate(body);
} catch (err) {
if (err instanceof GeoperaError) {
console.error(err.status, err.operation, err.problem);
// e.g. branch on err.status === 402 (payment) or 409 (conflict)
} else {
throw err;
}
}GeoperaError extends Error, with name === "GeoperaError" and a message of the form Geopera operation '<op>' failed (HTTP <status>). Its fields are:
| Field | Type | Description |
|---|---|---|
status | number | The HTTP status code of the failing response. |
operation | string | The operation id that failed. |
problem | unknown | The parsed problem+json body (RFC 9457). |
The problem field is typed unknown — narrow it before reading detail, type, or status:
function detailOf(err: GeoperaError): string | undefined {
const p = err.problem;
return p && typeof p === 'object' && 'detail' in p
? String((p as { detail: unknown }).detail)
: undefined;
}Three distinct failure classes can surface, so handle them separately:
GeoperaError— the API returned a non-2xx response. Inspectstatusandproblem.AbortError(aDOMException) — the request was cancelled via anAbortSignal.- Network /
TypeError—fetchcould not complete (DNS, TLS, offline). The request never reached the API.
For the full problem schema and status-code catalogue see Errors and the SDK-specific Error handling page.
Type-safety
Three type helpers are exported so you can build your own typed wrappers without re-deriving anything:
import type { OperationId, OperationInput, OperationOutput } from '@geopera/sdk';
// A union of every valid operation id.
type Op = OperationId;
// The request body type for a specific operation.
type SearchBody = OperationInput<'catalog.search'>;
// The response type for a specific operation.
type SearchResult = OperationOutput<'catalog.search'>;Because the surface is keyed by operation id, you can write generic helpers that stay fully typed across every operation. For example, a wrapper that adds logging while preserving each operation’s exact body and return type:
async function logged<K extends OperationId>(
client: GeoperaClient,
op: K,
body: OperationInput<K>
): Promise<OperationOutput<K>> {
console.time(op);
try {
return await client.invoke(op, body);
} finally {
console.timeEnd(op);
}
}These types cover the same operation surface as the REST API and the Python SDK. There are no hand-written types for you to maintain.
Worked example: search, then estimate
Search the catalog, inspect the results, and request a tasking estimate — every body and result is statically typed end to end, and the three failure classes are handled distinctly:
import { GeoperaClient, GeoperaError } from '@geopera/sdk';
const client = new GeoperaClient({ token: process.env.GEOPERA_TOKEN! });
async function main() {
const search = await client.catalog.search({
collections: ['sentinel-2-l2a'],
bbox: [151, -34, 152, -33],
limit: 5
});
console.log('search result:', search);
const estimate = await client.orders.tasking.estimate({
// body fields are inferred from orders.tasking.estimate's input model
});
console.log('estimate:', estimate);
}
main().catch((err) => {
if (err instanceof GeoperaError) {
console.error(`Operation ${err.operation} failed (${err.status})`, err.problem);
process.exit(1);
}
// Network errors, aborts, and programmer errors bubble up unchanged.
throw err;
});