<!-- Source: https://docs.geopera.com/api-reference/sdks/typescript/advanced · Markdown for LLMs -->

# 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](/api-reference/sdks/typescript) 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](/api-reference/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.                                                |

```typescript
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](/api-reference/authentication) for how to obtain one and [Scopes](/api-reference/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](#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.

```typescript
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`](/api-reference/errors), 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`:

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

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

```typescript
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`](/api-reference/errors):

```typescript
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.parse`s 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.

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

1. Construct a short-lived client whose `headers` include the per-request value, then make the call.
2. Use the functional `callOperation` helper, which takes a `headers` map 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`](/api-reference/idempotency) so a retry after a network blip does not double-submit. The key is just a header, so set it via `callOperation`:

```typescript
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](/api-reference/idempotency) for the full contract and replay semantics.

If you prefer to keep using namespace methods, the equivalent is a short-lived client:

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

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

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

```typescript
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`](/api-reference/errors) — `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](/api-reference/pagination) for the field conventions and response envelope.

```typescript
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](/api-reference/rate-limits) when paging large result sets.

To stream results to a consumer instead of buffering everything, yield each page from an async generator:

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

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

```typescript
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](/api-reference/errors) 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:

```typescript
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](/api-reference/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.

```typescript
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](/api-reference/errors) and [Rate limits](/api-reference/rate-limits) for the full status taxonomy.
