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.
npm install @linkskipper/sdkresolveAndWait 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;
}
}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). |
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);
}Fetch a single job's current state.
const job = await client.getJob(result.jobId!);
console.log(job.status);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.
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})`));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);
}
}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;
}
}
});Network errors, 5xx, and 429 are retried with bounded exponential backoff. A Retry-After header is always honored. Other 4xx responses are never retried.
MIT © Link Skipper