Official TypeScript/Node.js SDK for the AWSYS.CO URL Shortener API. Zero
runtime dependencies — built on the native fetch/AbortController available in Node 18+.
Table of contents: Requirements · Installation · Quick Start · Authentication & Configuration · Error Handling · Retry & Rate Limiting · Pagination · API Reference · Examples · Security · Contributing · Supported Node Versions · License & Changelog
- Node.js 18 or higher (uses built-in
fetch/AbortController) — see Supported Node Versions - An AWSYS.CO account on the Pro plan or higher
- An API key (generated in your dashboard settings)
npm install @awsysco/sdkimport { AwsysClient } from "@awsysco/sdk";
const client = new AwsysClient({
apiKey: "awsys_your_key_here",
});
// Create a short link
const link = await client.links.create({ url: "https://example.com" });
console.log(link.shortUrl); // https://awsys.co/abc123See examples/ for fuller, runnable scripts.
API keys are sent via Authorization: Bearer <key> automatically. Generate your key in the
AWSYS.CO dashboard under Settings → API Keys. API key access requires a Pro plan or higher —
free tier accounts cannot use the API.
const client = new AwsysClient({
apiKey: "awsys_...", // Falls back to process.env.AWSYS_API_KEY if omitted.
// Missing both → AwsysConfigurationError, thrown before any request.
baseUrl: "https://awsys.co", // Falls back to process.env.AWSYS_BASE_URL, then this default.
// Must be http(s) — any other scheme → AwsysConfigurationError.
timeoutMs: 30000, // Per-request timeout (AbortController-based). Default 30000.
maxRetries: 3, // Max retry attempts. 0 disables retries entirely.
});A User-Agent: awsysco-ts-sdk/<version> (node/<runtime>) header is sent on every request, with
<version> matching this package's published version.
timeoutMs can also be overridden per call:
await client.links.get("abc123", { timeoutMs: 5000 });Redaction: the client and every thrown error implement toJSON() and
util.inspect.custom so the raw API key never appears in JSON.stringify(client),
console.log(client), or an error's own serialized form — see Security.
All API errors throw a typed error class, all extending the base AwsysError:
| Error Class | HTTP Status | When thrown |
|---|---|---|
AwsysConfigurationError |
– | Missing API key, or an invalid baseUrl scheme |
AwsysValidationError |
400 | Invalid request payload |
AwsysAuthError |
401 | Missing, invalid, revoked, or free-tier API key |
AwsysForbiddenError |
403 | Insufficient tier, feature flag off, or a deprecated Firebase-only endpoint (e.g. customDomains.activate()) |
AwsysNotFoundError |
404 | Resource does not exist |
AwsysConflictError |
409 | Conflict (e.g. custom slug already taken) |
AwsysRateLimitError |
429 | Rate limit exceeded (per-route or per-quota) |
AwsysServerError |
5xx | Server-side error |
AwsysNetworkError |
– (transport) | Connection reset/refused, DNS failure, etc. |
AwsysTimeoutError |
– (extends AwsysNetworkError) |
Request aborted after timeoutMs |
Every error has message, code (machine-readable, from the API where available), status
(HTTP status, when there is one), and raw (the parsed or raw response body). The SDK tolerates
every response-body shape the platform actually sends — {error:true,code,message},
{error:"<string>",code}, {error:true,code} with no message, {success:false,message,code},
non-JSON bodies (including HTML error pages), and missing bodies — so a body-shape surprise never
throws an unrelated JS error in place of a typed one.
AwsysRateLimitError additionally has retryAfter (seconds, if provided by the server), code
(e.g. HOURLY_LIMIT_EXCEEDED, MONTHLY_LIMIT_EXCEEDED, DAILY_LIMIT_EXCEEDED), and resetsAt
(when the platform provides it).
import {
AwsysError,
AwsysNotFoundError,
AwsysRateLimitError,
AwsysTimeoutError,
} from "@awsysco/sdk";
try {
const link = await client.links.get("abc123");
} catch (err) {
if (err instanceof AwsysNotFoundError) {
console.log("Link not found");
} else if (err instanceof AwsysRateLimitError) {
console.log(`Rate limited. Retry in ${err.retryAfter}s (code: ${err.code})`);
} else if (err instanceof AwsysTimeoutError) {
console.log("Request timed out");
} else if (err instanceof AwsysError) {
console.log(`API error [${err.status}] ${err.code}: ${err.message}`);
}
}The SDK retries automatically:
429is retried for every HTTP method — except quota-class 429s (HOURLY_LIMIT_EXCEEDED/DAILY_LIMIT_EXCEEDED/MONTHLY_LIMIT_EXCEEDED), which raiseAwsysRateLimitErrorimmediately since waiting a few seconds cannot help.502/503/504and transport-level network errors are retried only for idempotent methods (GET,PUT,DELETE) — never forPOST/PATCH, since the platform has no idempotency keys and a retried write could create a duplicate.- Backoff uses the
Retry-Afterheader when present (seconds or an HTTP-date), otherwise exponential backoff (1s × 2^attempt, capped at 30s) with full jitter. - Up to 3 retries (4 attempts total) by default.
const client = new AwsysClient({
apiKey: "awsys_...",
maxRetries: 5, // retry up to 5 times; 0 disables retries entirely
});Rate limits depend on your plan (no X-RateLimit-* response headers are sent — check
AwsysRateLimitError's code/resetsAt instead):
| Plan | Hourly limit | Monthly limit |
|---|---|---|
| Pro | 50 calls | 1,000 calls |
| Builder | 500 calls | 10,000 calls |
| Developer | 1,000 calls | 50,000 calls |
| Agency | 5,000 calls | 250,000 calls |
| Enterprise | 20,000 calls | 1,000,000 calls |
client.links.list(opts?) returns { data, limit, offset, hasMore } (total is present on the
type for compatibility but is always undefined as of 1.4.0 — see CHANGELOG).
limit is clamped to 100 client-side.
For iterating every link without manually paging, use listAll():
for await (const link of client.links.listAll({ limit: 100 })) {
console.log(link.shortCode);
}listAll() stops when hasMore is false or a page returns fewer than limit items
(guarding against a response that omits hasMore). No other resource's list() paginates —
folders, webhooks, saved views, domains, affiliate programs, and import jobs are unpaginated (or
limit-only) by platform design.
See Authentication & Configuration.
Create a shortened link.
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
string |
Yes | The destination URL |
customSlug |
string |
No | Custom slug (Pro+ required) |
expiresAt |
string |
No | ISO 8601 expiry date-time |
maxClicks |
number |
No | Max clicks before link expires |
Returns: CreatedLink
const link = await client.links.create({
url: "https://example.com/my-page",
maxClicks: 100,
expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(),
});List links, or iterate every link automatically — see Pagination.
const { data, hasMore } = await client.links.list({ limit: 20, offset: 0 });Get a single link by its short code (or namespaced prefix/slug path).
const link = await client.links.get("abc123");Update a link's expiry or click limit.
const updated = await client.links.update("abc123", { maxClicks: 500 });Delete a link permanently.
await client.links.delete("abc123");Get click statistics for a link. Returns: LinkStats.
const stats = await client.analytics.getStats("abc123");
console.log(stats.totalClicks);
console.log(stats.clicks); // Array of click events with timestamp, country, deviceGet rich aggregated analytics for a link over a time window
(GET /api/v1/links/:shortPath/stats/aggregate).
const agg = await client.analytics.getAggregateStats("abc123", { period: "30d" });
console.log(agg.totalClicks, agg.uniqueVisitors);Get the authenticated user's most recent clicks across all links
(GET /api/user/clicks/recent).
const recent = await client.analytics.getRecentClicks({ limit: 20, since: "2026-09-01T00:00:00.000Z" });This route is behind a per-account feature flag ("Live Globe"). If it's off for your account, it throws
AwsysForbiddenErrorwithcode: "FEATURE_DISABLED".
Returns the URL for a QR code image — built locally, no network request.
const qrUrl = client.qr.getUrl("abc123", { size: 256, color: "000000" });
// Use as: <img src={qrUrl} />const settings = await client.qr.getSettings("abc123");
await client.qr.updateSettings("abc123", { color: "#ff0000" });const folders = await client.folders.list();
const folder = await client.folders.create({ name: "Q4 Campaign", color: "ef4444" });
await client.folders.assignLink("abc123", folder.id);
await client.folders.removeLink("abc123");
await client.folders.delete(folder.id);await client.tags.add("abc123", ["launch", "social"]); // also accepts a single string
await client.tags.remove("abc123", "social");const { views } = await client.savedViews.list();
const view = await client.savedViews.create({ name: "Active launch links", filters: { status: "active", tag: "launch" } });
await client.savedViews.update(view.id, { name: "Renamed view" });
await client.savedViews.delete(view.id);No dedicated list route exists on the platform — list() derives templates from /api/v1/me.
const templates = await client.utmTemplates.list();
const template = await client.utmTemplates.create({
name: "Newsletter CTA",
utmSource: "newsletter",
utmMedium: "email",
utmCampaign: "weekly-digest",
});
await client.utmTemplates.delete(template.id);const { eventTypes } = await client.webhooks.listEventTypes();
const { webhooks } = await client.webhooks.list();
const webhook = await client.webhooks.create({ url: "https://you.example.com/hook", events: ["link.created"] });
await client.webhooks.update(webhook.id, { enabled: false });
await client.webhooks.test(webhook.id, "link.created");
await client.webhooks.delete(webhook.id);Legacy webhook docs on the platform can lack
enabled/secret— everyWebhookfield exceptid/url/eventsis optional; treat an absentenabledas unknown, notfalse.
const { domains } = await client.customDomains.list();
await client.customDomains.add("links.yourdomain.com");
await client.customDomains.verify("links.yourdomain.com");
await client.customDomains.update("links.yourdomain.com", { defaultRedirect: "https://example.com/" });
await client.customDomains.remove("links.yourdomain.com");
const check = await client.customDomains.check("links.yourdomain.com");
client.customDomains.activate()is deprecated — it requires Firebase-session auth and is not reachable with an API key. It now throwsAwsysForbiddenErrorinstead of making a network call; activate domains from the dashboard. Removed entirely in the next major version.
const ns = await client.namespace.get();
const availability = await client.namespace.check("mynamespace");
await client.namespace.claim("mynamespace");
await client.namespace.release();const program = await client.affiliate.createProgram({
name: "My Program",
commissionType: "cpc",
cpcRate: 0.5,
});
const programs = await client.affiliate.listPrograms();
await client.affiliate.updateProgram(program.id, { name: "Renamed" });
const stats = await client.affiliate.getProgramStats(program.id, "30d");
const partners = await client.affiliate.listPartners(program.id);
await client.affiliate.updatePartnerStatus(program.id, partners[0].id, "approved");
const discovered = await client.affiliate.discover(20);
await client.affiliate.join(discovered[0].id, "REFCODE");
const partnerships = await client.affiliate.listPartnerships();
await client.affiliate.leaveProgram(partnerships[0].id);
const limits = await client.affiliate.getLimits();await client.agentlink.subscribe("you@example.com"); // public endpoint
const linkStats = await client.agentlink.getLinkStats("abc123", 30);
const accountStats = await client.agentlink.getAccountStats(30);Requires Builder tier or higher.
const result = await client.bulk.create({
urls: [
{ url: "https://example.com/page-1" },
{ url: "https://example.com/page-2", maxClicks: 50 },
],
});const me = await client.me.get();
console.log(me.subscriptionTier); // "pro", "builder", "enterprise", etc.Distinct from client.me.get() (static plan info) — the user's editable profile.
const profile = await client.profile.get();
await client.profile.update({ displayName: "New Name" });Live consumption stats — distinct from client.me.get()'s static plan limits.
const usage = await client.usage.get();
console.log(usage.linksCreatedToday, usage.apiCallsThisMonth, usage.overage);const session = await client.web2app.consumeSession("tok_abc123"); // single-use, 24h TTLMigrate links from an external provider (e.g. Bitly). Requires a paid tier.
const job = await client.imports.start({ provider: "bitly", accessToken: "...", scanOnly: true });
const status = await client.imports.getStatus(job.id);
const jobs = await client.imports.list({ limit: 10 });
await client.imports.cancel(job.id);
const finished = await client.imports.waitForCompletion(job.id, { pollIntervalMs: 3000, timeoutMs: 300000 });
const csv = await client.imports.getRedirectMapCsv(job.id);
const json = await client.imports.getRedirectMapJson(job.id);const linksCsv = await client.dataExport.exportLinks();
const statsCsv = await client.dataExport.exportLinkStats("abc123");const scan = await client.trustScore.scan("abc123"); // public endpoint
console.log(scan.trustScore, scan.trustStatus, scan.threats);Runnable scripts in examples/:
basic.ts— minimal quick-start (links, analytics, namespace, webhooks)advanced.ts— folders, QR,listAll(), webhooks, custom domains, affiliate, saved views, UTM templates, trust score, namespace, profile, full error handlingintegration.ts— a manual smoke-test script exercising the main happy paths against a live API (not wired intonpm test)
- The API key is never logged and never appears in
JSON.stringify/util.inspectoutput of the client or of any thrown error — seeSECURITY-REVIEW.mdfor what's covered by tests and known gaps. - TLS verification is never disabled — there is no SDK option to turn it off (plain
fetch). baseUrlmust behttp://orhttps://; anything else throwsAwsysConfigurationErrorbefore any request is made.- Zero runtime dependencies.
Full findings: SECURITY-REVIEW.md.
- Clone the repository
- Install dependencies:
npm install - Copy
.env.exampleto.env.testand add your staging API key:cp .env.example .env.test
- Run tests:
npm test - Build:
npm run build
npm run build # Compile with tsup (CJS + ESM + .d.ts)
npm test # Unit + contract-fixture tests (offline) + integration tests (skipped without AWSYS_API_KEY)
npm run test:watch # Watch mode
npm run typecheck # Type-check without emitting
npm run lint # ESLint (typescript-eslint, type-checked)CI (.github/workflows/ci.yml) runs lint, typecheck, tests, build, and a pack+install smoke test
on every push/PR across Node 18/20/22. .github/workflows/contract-drift.yml checks this SDK
against the platform's sdk-contract.json on a schedule and whenever the platform repo dispatches
a contract-changed event.
Two layers: contract-fixture tests (tests/contracts/, offline, mocked fetch, driven by a
vendored copy of the platform's sdk-contract.json — every capability/error/behavior scenario is
asserted, method-by-method) and integration tests (the rest of tests/, gated behind
AWSYS_API_KEY — skip cleanly when it's unset, run against real staging when it's present). Never
commit .env.test.
Node 18, 20, and 22 (CI matrix). Node 18 reached end-of-life in April 2025 and is kept for 1.4.0 compatibility only — expect the next major version to raise the minimum.
MIT — see LICENSE for details. Release history: CHANGELOG.md.
© 2026 Alpha Wave Systems