TypeScript SDK
A zero-dependency typed client for every operation in the catalog.
@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/sdkGetting 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' });
// ^? PokeapiPokemonGetOutputSlug 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 requestrateLimit.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 responsePass 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.