upAPI Docs

TypeScript SDK

A zero-dependency typed client for every operation in the catalog.

View as Markdown

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

npm install @upapi/sdk

Getting started

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:

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

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.

Client options

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

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

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

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

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

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

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

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 for the server side.

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:

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

const usage = await upapi.usage();

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

This calls /usage 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

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

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

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.