@linkskipper/sdk
v0.2.1
Published
Official TypeScript client for the Link Skipper link-resolving API.
Maintainers
Readme
@linkskipper/sdk
Official TypeScript/JavaScript client for the Link Skipper link-resolving API. It turns the asynchronous resolve flow into a single await.
- Zero runtime dependencies (native
fetch, Node 18+ or any modern browser). - ESM + CommonJS + bundled type declarations.
- Typed errors for every API problem code, plus built-in retry with exponential backoff.
Install
npm install @linkskipper/sdkQuick start — resolveAndWait
resolveAndWait submits the URL, then transparently polls the job until it finishes, returning the destination. A cache hit returns instantly; a cache miss is polled for you.
import { LinkSkipper, JobFailedError, TimeoutError } from "@linkskipper/sdk";
const client = new LinkSkipper({ apiKey: process.env.LINKSKIPPER_API_KEY! });
try {
const link = await client.resolveAndWait("https://exe.io/AbCdEf", {
maxWaitMs: 60_000,
pollIntervalMs: 2_000,
});
console.log(link.targetUrl);
console.log(`${link.provider} (${link.tier}) · ${link.creditsCharged} credit(s) · cached=${link.cached}`);
} catch (error) {
if (error instanceof JobFailedError) {
console.error("Could not resolve:", error.reason);
} else if (error instanceof TimeoutError) {
console.error("Still pending after the deadline:", error.jobId);
} else {
throw error;
}
}Configuration
const client = new LinkSkipper({
apiKey: "sk_live_...",
baseUrl: "https://linkskipper.app",
timeoutMs: 30_000,
pollIntervalMs: 2_000,
maxWaitMs: 120_000,
retry: {
maxAttempts: 3,
initialDelayMs: 500,
maxDelayMs: 8_000,
backoffFactor: 2,
},
});| Option | Default | Description |
| --- | --- | --- |
| apiKey | (required) | Sent as Authorization: Bearer <apiKey>. |
| baseUrl | https://linkskipper.app | API origin. |
| timeoutMs | 30000 | Per-request timeout. |
| pollIntervalMs | 2000 | Delay between job polls in resolveAndWait. |
| maxWaitMs | 120000 | Total budget for resolveAndWait before it throws TimeoutError. |
| retry | 3 attempts | Backoff for network errors, 5xx, and 429. |
| fetch | globalThis.fetch | Inject a custom fetch (e.g. for older runtimes). |
API
resolve(url, { idempotencyKey? })
One-shot, non-blocking. On a cache hit it returns status: "done" with the targetUrl inline; otherwise it returns status: "queued" with a jobId and pollUrl.
const result = await client.resolve("https://cuty.io/xyz", { idempotencyKey: "order-42" });
if (result.status === "done") {
console.log(result.targetUrl);
} else {
console.log("queued at position", result.queuePosition, "->", result.pollUrl);
}getJob(jobId)
Fetch a single job's current state.
const job = await client.getJob(result.jobId!);
console.log(job.status);resolveAndWait(url, opts)
Resolve, then poll until the job is done, failed, or invalid. Returns the resolved link on success, throws JobFailedError on failed/invalid, and TimeoutError once maxWaitMs elapses.
account() / providers()
const account = await client.account();
console.log(account.balance, account.subscriptionUntil);
const providers = await client.providers();
console.log(providers.map((p) => `${p.label} (${p.tier})`));Errors
Every non-2xx application/problem+json response is mapped to a typed exception. All extend ApiError, which exposes status, code, detail, title, type, balance, and retryAfter.
| Code | Class | HTTP |
| --- | --- | --- |
| invalid_request | InvalidRequestError | 400 |
| invalid_key | InvalidKeyError | 401 |
| out_of_credits | OutOfCreditsError | 402 |
| forbidden_scope | ForbiddenScopeError | 403 |
| not_found | NotFoundError | 404 |
| link_removed | LinkRemovedError | 410 |
| unsupported_link | UnsupportedLinkError | 422 |
| rate_limited | RateLimitedError | 429 |
| quota_exceeded | QuotaExceededError | 429 |
| resolve_failed | ResolveFailedError | 502 |
| provider_down | ProviderDownError | 503 |
Non-HTTP outcomes use NetworkError (transport failure after retries), TimeoutError (poll deadline), and JobFailedError (terminal failed/invalid job).
import { RateLimitedError, OutOfCreditsError } from "@linkskipper/sdk";
try {
await client.resolveAndWait("https://exe.io/AbCdEf");
} catch (error) {
if (error instanceof RateLimitedError) {
console.log("retry after", error.retryAfter, "seconds");
} else if (error instanceof OutOfCreditsError) {
console.log("balance:", error.balance);
}
}Webhooks
Pass webhook_url on a resolve and Link Skipper POSTs the result to your endpoint, signed with X-LinkSkipper-Signature: t=<unixSeconds>,v1=<hmacSha256>. Verify every delivery against your key's webhook secret with verifyWebhook, which checks the HMAC in constant time, enforces a freshness window (default 300s), and returns the typed WebhookEvent. Pass the raw request body string — not a re-serialized object.
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") {
console.log(`Job ${event.job_id} -> ${event.target_url}`);
}
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) {
res.sendStatus(400);
} else {
throw error;
}
}
});Retries
Network errors, 5xx, and 429 are retried with bounded exponential backoff. A Retry-After header is always honored. Other 4xx responses are never retried.
License
MIT © Link Skipper
