# TypeScript SDK (https://upapi.io/docs/sdk)



`@upapi/sdk` is a small typed wrapper over the gateway. It has **no runtime
dependencies** — it uses the platform's own `fetch` — so it adds essentially
nothing to a bundle or a cold start. Node 20 or newer.

```bash
npm install @upapi/sdk
```

## Getting started [#getting-started]

```ts
import { UpAPI } from '@upapi/sdk';

const upapi = new UpAPI({ apiKey: process.env.UPAPI_API_KEY });

const trending = await upapi.call('github-trending.get', {
  language: 'typescript',
  since: 'weekly',
  limit: 5,
});
```

`call(slug, input)` is the escape hatch: it works for any slug, including
operations added after your installed version.

For anything in the catalog, prefer the generated `operations` surface — it is
keyed by slug and fully typed in both directions, so a wrong field name is a
compile error rather than a `400` at runtime:

```ts
const pokemon = await upapi.operations['pokeapi-pokemon.get']({ name: 'pikachu' });
//    ^? PokeapiPokemonGetOutput
```

<Callout title="Slug keys, not camelCase methods">
  The typed surface lives on `upapi.operations[...]` and is indexed by the real slug —
  `upapi.operations['ip-geolocation.get']({ ... })`. There is no `upapi.ipGeolocationGet()` form.
</Callout>

### Client options [#client-options]

```ts
const upapi = new UpAPI({
  apiKey: process.env.UPAPI_API_KEY,
  baseUrl: 'https://api.upapi.io', // default
  timeoutMs: 60_000, // default
  retry: { retries: 2, maxRetryDelayMs: 60_000 }, // defaults
  idempotency: true, // default — see Idempotency below
  fetch: myInstrumentedFetch, // defaults to global fetch
});
```

`timeoutMs`, `retry`, `idempotency`, `idempotencyKey` and an `AbortSignal` can
also be passed per call as a third argument, on both `call` and the `operations`
methods.

## Reading quota as you go [#reading-quota-as-you-go]

`callWithMeta` returns the metering context alongside the result, so you can
pace yourself without waiting to be throttled:

```ts
const { data, rateLimit, requestId, cached } = await upapi.callWithMeta('github-trending.get', {
  since: 'daily',
});

rateLimit.monthlyRemaining; // weighted units left this UTC month
rateLimit.remaining; // requests left in this minute
cached; // true when the gateway served a cached upstream result
requestId; // quote this in a support request
```

`rateLimit.trial` is present **only** while a trial is live, matching the
gateway's own behaviour with the `X-Trial-Units-*` headers — feature-detect it
rather than reading a zero.

This is returned per call rather than stored on the client on purpose: a shared
"last rate limit" field would be overwritten by whichever concurrent request
happened to finish last, which is exactly when the number matters.

## Errors [#errors]

There are **two** error types, and production code should handle both.

```ts
import { UpAPI, UpAPIError, UpAPITimeoutError } from '@upapi/sdk';

try {
  await upapi.call('github-trending.get', { since: 'daily' });
} catch (err) {
  if (err instanceof UpAPIError) {
    err.code; // 'RATE_LIMITED' — branch on this
    err.status; // 429
    err.retryAfterSeconds; // number | undefined
    err.requestId; // for support
    err.rateLimit; // where you stood when it failed
    err.isRateLimited; // status === 429
  } else if (err instanceof UpAPITimeoutError) {
    // The request exceeded timeoutMs, or your AbortSignal fired.
    // Nothing came back, so there is no code and no status.
  }
  throw err;
}
```

`code` is the same vocabulary documented in [Errors](https://upapi.io/docs/errors), so the
handling you write for the SDK matches the handling you would write for raw
HTTP. `PUBLIC_ERROR_CODES` and `PUBLIC_ERROR_STATUS` are re-exported if you want
to switch exhaustively.

<Callout type="warn" title="`code` can be `UNKNOWN`">
  A failure that never reached the gateway — an edge returning an HTML error page, say — has no
  error envelope to read, so `code` is `'UNKNOWN'` and `status` carries the real HTTP status. Always
  keep a default branch.
</Callout>

## Retries [#retries]

The client retries **429 responses only**, up to `retry.retries` times (default
2\), waiting for the interval the gateway asked for in `Retry-After` and never
sleeping longer than `maxRetryDelayMs`.

That narrowness is deliberate. A 429 is the one failure the server has told you
is worth repeating and exactly when. A `400` will never succeed on retry, and
silently repeating a `502` can double-charge you for an operation that already
did real upstream work. Set `retry: { retries: 0 }` to disable it, or wrap the
call yourself for a broader policy — the SDK will not guess.

Not every 429 is worth waiting for, and the client tells them apart by
[error code](https://upapi.io/docs/errors#429-arrives-with-a-reason): `RATE_LIMITED` and
`SERVICE_BUSY` are retried, `QUOTA_EXCEEDED` is surfaced immediately. A response
with no recognizable code falls back to the older rule — retry unless
`Retry-After` exceeds `maxRetryDelayMs` — so an out-of-date gateway still
behaves sanely.

A retried burst throttle still costs nothing:
[throttled calls consume no units](https://upapi.io/docs/rate-limits).

## Idempotency [#idempotency]

Every operation call carries an auto-generated `Idempotency-Key`, reused across
this client's own retries. A retried call therefore replays the first attempt's
result rather than running the operation — and being billed — a second time. See
[Idempotency](https://upapi.io/docs/api#idempotency) for the server side.

```ts
const result = await upapi.callWithMeta('reddit-post-comment.post', input);
result.replayed; // true when the gateway replayed a stored response
```

Pass your own key when the unit of work belongs to your system rather than to
this process, so a retry from another process or after a restart is still
recognized as the same request:

```ts
await upapi.call('reddit-post-comment.post', input, { idempotencyKey: `comment-${jobId}` });
```

An explicit key is always sent. To stop sending generated ones, set
`idempotency: false` on the client or on a single call.

## Checking usage [#checking-usage]

```ts
const usage = await upapi.usage();

usage.remainingMonth; // weighted units left this month
if ('trial' in usage) {
  usage.trial.unitsRemaining;
}
```

This calls [`/usage`](https://upapi.io/docs/usage-api) and is free to poll — it is not metered
and records no call. As with the header, the `trial` block is absent off-trial.

## Inspecting the catalog [#inspecting-the-catalog]

The operation catalog ships with the package, so you can enumerate what a build
supports without a network call:

```ts
import { OPERATIONS, OPERATION_SLUGS } from '@upapi/sdk';

OPERATION_SLUGS; // readonly slug list

// OPERATIONS is an ARRAY of metadata records, not a map keyed by slug.
const op = OPERATIONS.find((o) => o.slug === 'github-trending.get');
op?.unitWeight; // weighted units per call
op?.category; // 'Developer Tools'
```

`unitWeight` is the number of [weighted units](https://upapi.io/docs/rate-limits) one call
spends, which makes it possible to price a workload before running it. This is
the only place a per-operation weight is published as data you can rely on — the
OpenAPI document does not carry it, and neither does `/api/catalog` when the
engine is healthy.

## Staying in sync [#staying-in-sync]

The typed operation list, the per-op types and the error vocabulary are all
generated from the same registries that seed the live marketplace, and CI fails
if they drift. An SDK release therefore describes exactly the operations that
were live when it was cut: upgrading picks up new ones, and nothing silently
disappears from under you.
