> ## Documentation Index
> Fetch the complete documentation index at: https://linkskipper.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# JavaScript SDK

> @linkskipper/sdk — a typed client for Node and the browser with retries, polling, and webhook verification.

The official JavaScript / TypeScript SDK wraps the REST API with a typed client, automatic
retries, a resolve-and-wait helper, typed error classes, and webhook verification.

<CardGroup cols={2}>
  <Card title="npm" icon="npm" href="https://www.npmjs.com/package/@linkskipper/sdk">
    `@linkskipper/sdk` · v0.2.1
  </Card>

  <Card title="GitHub" icon="github" href="https://github.com/linkskipper/sdk-js">
    Source, issues, and changelog.
  </Card>
</CardGroup>

## Install

```bash theme={null}
npm install @linkskipper/sdk
```

Requires Node 18+ (for global `fetch`), a modern browser, or a custom `fetch` passed in.
Ships ESM with TypeScript types.

<Warning>
  Use the SDK from a **server**, not the browser, so your API key stays secret. See
  [Authentication](/docs/authentication).
</Warning>

## Initialize

```ts theme={null}
import { LinkSkipper } from "@linkskipper/sdk";

const client = new LinkSkipper({
  apiKey: process.env.LINKSKIPPER_API_KEY!,
});
```

### Client options

<ParamField path="apiKey" type="string" required>
  Your `sk_live_…` key. Throws `LinkSkipperError` if missing.
</ParamField>

<ParamField path="baseUrl" type="string" default="https://api.linkskipper.app">
  Override the API base URL.
</ParamField>

<ParamField path="timeoutMs" type="number" default="30000">
  Per-request timeout in milliseconds.
</ParamField>

<ParamField path="pollIntervalMs" type="number" default="2000">
  Default poll interval for `resolveAndWait`.
</ParamField>

<ParamField path="maxWaitMs" type="number" default="120000">
  Default overall deadline for `resolveAndWait`.
</ParamField>

<ParamField path="retry" type="Partial<RetryConfig>">
  Retry tuning: `{ maxAttempts: 3, initialDelayMs: 500, maxDelayMs: 8000, backoffFactor: 2 }`.
  Retries `429` and `5xx`, honoring `Retry-After`, with exponential backoff and jitter.
</ParamField>

<ParamField path="fetch" type="typeof fetch">
  Custom fetch implementation (for older Node or testing).
</ParamField>

<ParamField path="userAgent" type="string">
  Override the default `User-Agent`.
</ParamField>

## Methods

### resolve

`resolve(url, options?) => Promise<ResolveResult>`

Submits a URL to `POST /v1/resolve`. Returns immediately — a cached link comes back with
`status: "done"`, a new one with `status: "queued"` and a `jobId`.

<ParamField path="url" type="string" required>
  The shortener URL to resolve.
</ParamField>

<ParamField path="options.idempotencyKey" type="string">
  Sent as the `Idempotency-Key` header to de-duplicate retries.
</ParamField>

```ts theme={null}
const result = await client.resolve("https://ouo.io/abc123", {
  idempotencyKey: "order-42",
});

if (result.status === "done") {
  console.log(result.targetUrl, result.cached);
} else {
  console.log("queued:", result.jobId);
}
```

The `ResolveResult` exposes `jobId`, `status`, `url`, `targetUrl`, `provider`, `tier`,
`creditsCharged`, `cached`, `balance`, `queuePosition`, and `pollUrl` (camelCase mappings of
the [resolve response](/docs/resolve)).

### resolveAndWait

`resolveAndWait(url, options?) => Promise<ResolvedLink>`

The high-level helper: submits the resolve, then polls the job until it is terminal and
returns the resolved link. A cached hit returns without polling.

<ParamField path="url" type="string" required>
  The shortener URL to resolve.
</ParamField>

<ParamField path="options.idempotencyKey" type="string">
  De-duplicate retries.
</ParamField>

<ParamField path="options.pollIntervalMs" type="number" default="2000">
  How often to poll the job.
</ParamField>

<ParamField path="options.maxWaitMs" type="number" default="120000">
  Overall deadline. Exceeding it throws `TimeoutError`.
</ParamField>

<ParamField path="options.signal" type="AbortSignal">
  Cancel the wait loop.
</ParamField>

```ts theme={null}
import { LinkSkipper, JobFailedError, TimeoutError } from "@linkskipper/sdk";

const client = new LinkSkipper({ apiKey: process.env.LINKSKIPPER_API_KEY! });

try {
  const link = await client.resolveAndWait("https://ouo.io/abc123", {
    maxWaitMs: 60_000,
    pollIntervalMs: 2_000,
  });

  console.log(link.targetUrl);
  console.log(
    `${link.provider} (${link.tier}) · ${link.creditsCharged} credits · cached=${link.cached}`,
  );
} catch (error) {
  if (error instanceof JobFailedError) {
    console.error("Resolve failed:", error.reason);
  } else if (error instanceof TimeoutError) {
    console.error("Timed out, still pending:", error.jobId);
  } else {
    throw error;
  }
}
```

The `ResolvedLink` has a guaranteed `targetUrl` plus `jobId`, `provider`, `tier`,
`creditsCharged`, `balance`, and `cached`.

### getJob

`getJob(jobId, signal?) => Promise<Job>`

Reads `GET /v1/jobs/{jobId}` once. Use it to poll manually or check a job from a webhook.

```ts theme={null}
const job = await client.getJob("9b1d7c0e-2f3a-4b5c-8d6e-1a2b3c4d5e6f");
if (job.status === "done") {
  console.log(job.targetUrl);
}
```

### account

`account() => Promise<Account>`

Reads `GET /v1/account`: `telegramId`, `balance`, `subscriptionUntil`, and `providers`.

```ts theme={null}
const account = await client.account();
console.log(`${account.balance} credits left`);
```

### providers

`providers() => Promise<ProviderEntry[]>`

Reads `GET /v1/providers`. Each entry has `provider`, `label`, `hosts`, `tier`, and
`latency`.

```ts theme={null}
const providers = await client.providers();
for (const p of providers) {
  console.log(`${p.label}: ${p.hosts.join(", ")}`);
}
```

## Errors

Every failed API call rejects with an `ApiError` subclass keyed to the response
[error code](/docs/errors). `JobFailedError`, `TimeoutError`, `NetworkError`, and
`WebhookVerificationError` cover the non-HTTP cases. All extend `LinkSkipperError`.

| Class                      | Triggered by                                              |
| -------------------------- | --------------------------------------------------------- |
| `InvalidRequestError`      | `invalid_request` (400)                                   |
| `InvalidKeyError`          | `invalid_key` (401)                                       |
| `OutOfCreditsError`        | `out_of_credits` (402) — `.balance`                       |
| `ForbiddenScopeError`      | `forbidden_scope` (403)                                   |
| `NotFoundError`            | `not_found` (404)                                         |
| `LinkRemovedError`         | `link_removed` (410)                                      |
| `UnsupportedLinkError`     | `unsupported_link` (422)                                  |
| `RateLimitedError`         | `rate_limited` (429) — `.retryAfter`                      |
| `QuotaExceededError`       | `quota_exceeded` (429) — `.retryAfter`                    |
| `ResolveFailedError`       | `resolve_failed` (502)                                    |
| `ProviderDownError`        | `provider_down` (503)                                     |
| `JobFailedError`           | A polled job ended `failed`/`invalid` — `.reason`, `.job` |
| `TimeoutError`             | `resolveAndWait` deadline passed — `.jobId`, `.waitedMs`  |
| `NetworkError`             | Transport failure after retries — `.cause`                |
| `WebhookVerificationError` | `verifyWebhook` rejected a payload                        |

Every `ApiError` carries `status`, `code`, `title`, `detail`, `type`, `balance`,
`retryAfter`, and the raw `problem` object.

```ts theme={null}
import {
  ApiError,
  OutOfCreditsError,
  RateLimitedError,
} from "@linkskipper/sdk";

try {
  await client.resolveAndWait("https://ouo.io/abc123");
} catch (error) {
  if (error instanceof OutOfCreditsError) {
    console.error("Top up — balance:", error.balance);
  } else if (error instanceof RateLimitedError) {
    console.error("Back off for", error.retryAfter, "seconds");
  } else if (error instanceof ApiError) {
    console.error(error.code, error.status, error.detail);
  } else {
    throw error;
  }
}
```

## Webhook verification

`verifyWebhook(payload, signatureHeader, secret, toleranceSeconds?)` checks the
`X-LinkSkipper-Signature` header, the timestamp tolerance (default 300s), and the HMAC, then
returns the typed `WebhookEvent`. It throws `WebhookVerificationError` on any failure.

```ts theme={null}
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@linkskipper/sdk";

app.post(
  "/webhooks/linkskipper",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = verifyWebhook(
        req.body.toString("utf8"),
        req.header("X-LinkSkipper-Signature"),
        process.env.LINKSKIPPER_WEBHOOK_SECRET,
      );
      if (event.event === "resolve.done") {
        saveTarget(event.job_id, event.target_url);
      }
      res.sendStatus(204);
    } catch (error) {
      if (error instanceof WebhookVerificationError) {
        res.sendStatus(400);
      } else {
        throw error;
      }
    }
  },
);
```

<Warning>
  Pass the **raw** request body (e.g. via `express.raw`), not a re-serialized object, or the
  signature won't match. See [Webhooks](/docs/webhooks).
</Warning>

## Exported types

The package exports `Account`, `AccountProvider`, `Job`, `JobStatus`, `ProviderEntry`,
`ProviderTier`, `ResolveResult`, `ResolveStatus`, `ResolvedLink`, `ProblemDetails`,
`WebhookEvent`/`WebhookPayload`, `WebhookEventName`, `ClientOptions`, `ResolveOptions`,
`ResolveAndWaitOptions`, `RetryConfig`, `ErrorCode`, and the `ERROR_CODES` constant.
