Back to Blog

Building a Typed TypeScript SDK for Your SaaS Public API

Build a production-grade TypeScript SDK for your SaaS public API. Covers typed fetch wrapper, error handling, rate limit retry, pagination helpers, webhook signature verification, and npm package publishing.

Viprasol Tech Team
April 7, 2027
13 min read

A well-designed SDK is the difference between "I integrated this in an afternoon" and "I spent three days reading the docs and still can't get it to work." For SaaS products with a public API, your SDK is often the first real engineering interaction a prospect has with your product โ€” it shapes whether they stay.

This guide builds a production-grade TypeScript SDK: typed requests and responses, rich error classes, automatic retry with backoff, cursor pagination, and webhook verification.

SDK Structure

packages/sdk/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ client.ts          # Main client class
โ”‚   โ”œโ”€โ”€ errors.ts          # Error classes
โ”‚   โ”œโ”€โ”€ http.ts            # Fetch wrapper with retry
โ”‚   โ”œโ”€โ”€ pagination.ts      # Cursor pagination iterator
โ”‚   โ”œโ”€โ”€ resources/
โ”‚   โ”‚   โ”œโ”€โ”€ users.ts
โ”‚   โ”‚   โ”œโ”€โ”€ projects.ts
โ”‚   โ”‚   โ””โ”€โ”€ webhooks.ts
โ”‚   โ”œโ”€โ”€ types/
โ”‚   โ”‚   โ”œโ”€โ”€ common.ts      # Shared types
โ”‚   โ”‚   โ”œโ”€โ”€ users.ts
โ”‚   โ”‚   โ””โ”€โ”€ projects.ts
โ”‚   โ””โ”€โ”€ index.ts           # Public exports
โ”œโ”€โ”€ package.json
โ””โ”€โ”€ tsconfig.json

Error Classes

// src/errors.ts

export class AcmeError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode?: number,
    public readonly requestId?: string
  ) {
    super(message);
    this.name = "AcmeError";
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

export class AcmeAuthenticationError extends AcmeError {
  constructor(message: string, requestId?: string) {
    super(message, "authentication_error", 401, requestId);
    this.name = "AcmeAuthenticationError";
  }
}

export class AcmePermissionError extends AcmeError {
  constructor(message: string, requestId?: string) {
    super(message, "permission_error", 403, requestId);
    this.name = "AcmePermissionError";
  }
}

export class AcmeNotFoundError extends AcmeError {
  constructor(resource: string, id: string, requestId?: string) {
    super(`${resource} '${id}' not found`, "not_found", 404, requestId);
    this.name = "AcmeNotFoundError";
  }
}

export class AcmeValidationError extends AcmeError {
  constructor(
    message: string,
    public readonly errors: Array<{ field: string; message: string }>,
    requestId?: string
  ) {
    super(message, "validation_error", 422, requestId);
    this.name = "AcmeValidationError";
  }
}

export class AcmeRateLimitError extends AcmeError {
  constructor(
    public readonly retryAfter: number, // seconds
    requestId?: string
  ) {
    super(`Rate limit exceeded. Retry after ${retryAfter}s`, "rate_limit_exceeded", 429, requestId);
    this.name = "AcmeRateLimitError";
  }
}

export class AcmeServerError extends AcmeError {
  constructor(message: string, statusCode: number, requestId?: string) {
    super(message, "server_error", statusCode, requestId);
    this.name = "AcmeServerError";
  }
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

HTTP Wrapper with Retry and Backoff

// src/http.ts
import {
  AcmeAuthenticationError, AcmePermissionError, AcmeNotFoundError,
  AcmeValidationError, AcmeRateLimitError, AcmeServerError, AcmeError,
} from "./errors";

export interface HttpClientOptions {
  baseUrl: string;
  apiKey: string;
  timeout?: number;          // ms, default 30000
  maxRetries?: number;       // default 3
  userAgent?: string;
}

export interface RequestOptions {
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  body?: unknown;
  query?: Record<string, string | number | boolean | undefined>;
  headers?: Record<string, string>;
  retries?: number;          // Override per-request
}

interface ApiErrorResponse {
  error: {
    message: string;
    code: string;
    errors?: Array<{ field: string; message: string }>;
  };
  request_id?: string;
}

export class HttpClient {
  private readonly baseUrl: string;
  private readonly apiKey: string;
  private readonly timeout: number;
  private readonly maxRetries: number;
  private readonly userAgent: string;

  constructor(options: HttpClientOptions) {
    this.baseUrl = options.baseUrl.replace(/\/$/, "");
    this.apiKey = options.apiKey;
    this.timeout = options.timeout ?? 30_000;
    this.maxRetries = options.maxRetries ?? 3;
    this.userAgent = options.userAgent ?? "acme-node-sdk/1.0.0";
  }

  async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
    const url = this.buildUrl(path, options.query);
    const maxAttempts = (options.retries ?? this.maxRetries) + 1;

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), this.timeout);

      try {
        const response = await fetch(url, {
          method: options.method ?? "GET",
          headers: {
            "Authorization": `Bearer ${this.apiKey}`,
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": this.userAgent,
            "X-SDK-Version": "1.0.0",
            ...options.headers,
          },
          body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
          signal: controller.signal,
        });

        clearTimeout(timeoutId);
        return await this.handleResponse<T>(response);
      } catch (err) {
        clearTimeout(timeoutId);

        if (err instanceof AcmeRateLimitError) {
          if (attempt < maxAttempts) {
            await sleep(err.retryAfter * 1000);
            continue;
          }
          throw err;
        }

        if (err instanceof AcmeServerError && attempt < maxAttempts) {
          // Exponential backoff: 1s, 2s, 4s
          await sleep(Math.pow(2, attempt - 1) * 1000);
          continue;
        }

        if (err instanceof Error && err.name === "AbortError") {
          throw new AcmeError("Request timed out", "timeout");
        }

        throw err;
      }
    }

    throw new AcmeError("Max retries exceeded", "max_retries");
  }

  private async handleResponse<T>(response: Response): Promise<T> {
    const requestId = response.headers.get("x-request-id") ?? undefined;

    if (response.ok) {
      if (response.status === 204) return undefined as T;
      return response.json() as Promise<T>;
    }

    let body: ApiErrorResponse | null = null;
    try {
      body = await response.json();
    } catch {
      // Non-JSON error response
    }

    const message = body?.error?.message ?? response.statusText;

    switch (response.status) {
      case 401: throw new AcmeAuthenticationError(message, requestId);
      case 403: throw new AcmePermissionError(message, requestId);
      case 404: throw new AcmeNotFoundError("Resource", "", requestId);
      case 422: throw new AcmeValidationError(message, body?.error?.errors ?? [], requestId);
      case 429: {
        const retryAfter = parseInt(response.headers.get("retry-after") ?? "60", 10);
        throw new AcmeRateLimitError(retryAfter, requestId);
      }
      default:
        throw new AcmeServerError(message, response.status, requestId);
    }
  }

  private buildUrl(
    path: string,
    query?: Record<string, string | number | boolean | undefined>
  ): string {
    const url = new URL(`${this.baseUrl}${path}`);
    if (query) {
      for (const [key, value] of Object.entries(query)) {
        if (value !== undefined) {
          url.searchParams.set(key, String(value));
        }
      }
    }
    return url.toString();
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Pagination Iterator

// src/pagination.ts

export interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    cursor: string | null;
    hasNextPage: boolean;
    total?: number;
  };
}

export class PageIterator<T> implements AsyncIterableIterator<T[]> {
  private cursor: string | null = null;
  private done = false;

  constructor(
    private readonly fetcher: (cursor?: string) => Promise<PaginatedResponse<T>>
  ) {}

  async next(): Promise<IteratorResult<T[]>> {
    if (this.done) return { done: true, value: undefined };

    const response = await this.fetcher(this.cursor ?? undefined);
    this.cursor = response.pagination.cursor;
    this.done = !response.pagination.hasNextPage;

    return { done: false, value: response.data };
  }

  [Symbol.asyncIterator](): AsyncIterableIterator<T[]> {
    return this;
  }

  // Collect all pages into a flat array
  async collect(): Promise<T[]> {
    const results: T[] = [];
    for await (const page of this) {
      results.push(...page);
    }
    return results;
  }
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

Resource: Users

// src/resources/users.ts
import type { HttpClient } from "../http";
import type { PaginatedResponse } from "../pagination";
import { PageIterator } from "../pagination";

export interface User {
  id: string;
  email: string;
  name: string;
  avatarUrl: string | null;
  role: "owner" | "admin" | "member";
  createdAt: string;
  updatedAt: string;
}

export interface CreateUserParams {
  email: string;
  name: string;
  role?: "admin" | "member";
  sendInvite?: boolean;
}

export interface UpdateUserParams {
  name?: string;
  role?: "admin" | "member";
}

export interface ListUsersParams {
  limit?: number;
  cursor?: string;
  search?: string;
  role?: "owner" | "admin" | "member";
}

export class UsersResource {
  constructor(
    private readonly http: HttpClient,
    private readonly workspaceId: string
  ) {}

  async get(userId: string): Promise<User> {
    return this.http.request<User>(
      `/v2/workspaces/${this.workspaceId}/users/${userId}`
    );
  }

  async create(params: CreateUserParams): Promise<User> {
    return this.http.request<User>(
      `/v2/workspaces/${this.workspaceId}/users`,
      { method: "POST", body: params }
    );
  }

  async update(userId: string, params: UpdateUserParams): Promise<User> {
    return this.http.request<User>(
      `/v2/workspaces/${this.workspaceId}/users/${userId}`,
      { method: "PATCH", body: params }
    );
  }

  async delete(userId: string): Promise<void> {
    return this.http.request<void>(
      `/v2/workspaces/${this.workspaceId}/users/${userId}`,
      { method: "DELETE" }
    );
  }

  async list(params?: ListUsersParams): Promise<PaginatedResponse<User>> {
    return this.http.request<PaginatedResponse<User>>(
      `/v2/workspaces/${this.workspaceId}/users`,
      { query: params as any }
    );
  }

  // Async iterator โ€” paginate all users automatically
  iterate(params?: Omit<ListUsersParams, "cursor">): PageIterator<User> {
    return new PageIterator<User>((cursor) =>
      this.list({ ...params, cursor })
    );
  }
}

Webhook Signature Verification

// src/resources/webhooks.ts
import { AcmeError } from "../errors";

export class WebhooksResource {
  constructor(private readonly webhookSecret: string) {}

  /**
   * Verify a webhook payload signature.
   * Call this in your webhook handler before processing.
   */
  async verifySignature(
    payload: string | Buffer,
    signature: string,
    timestamp?: string
  ): Promise<void> {
    // Reject stale webhooks (>5 minutes old)
    if (timestamp) {
      const age = Math.abs(Date.now() - parseInt(timestamp, 10) * 1000);
      if (age > 5 * 60 * 1000) {
        throw new AcmeError(
          "Webhook timestamp too old (possible replay attack)",
          "webhook_timestamp_expired"
        );
      }
    }

    const signedPayload = timestamp
      ? `${timestamp}.${payload.toString()}`
      : payload.toString();

    const encoder = new TextEncoder();
    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(this.webhookSecret),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["sign"]
    );

    const expected = await crypto.subtle.sign(
      "HMAC",
      key,
      encoder.encode(signedPayload)
    );

    const expectedHex = Array.from(new Uint8Array(expected))
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");

    // Timing-safe comparison
    if (!timingSafeEqual(expectedHex, signature.replace("sha256=", ""))) {
      throw new AcmeError("Invalid webhook signature", "webhook_signature_invalid");
    }
  }
}

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return result === 0;
}

Main Client

// src/client.ts
import { HttpClient, type HttpClientOptions } from "./http";
import { UsersResource } from "./resources/users";
import { ProjectsResource } from "./resources/projects";
import { WebhooksResource } from "./resources/webhooks";

export interface AcmeClientOptions {
  apiKey: string;
  workspaceId: string;
  webhookSecret?: string;
  baseUrl?: string;
  timeout?: number;
  maxRetries?: number;
}

export class Acme {
  private readonly http: HttpClient;

  readonly users: UsersResource;
  readonly projects: ProjectsResource;
  readonly webhooks: WebhooksResource;

  constructor(options: AcmeClientOptions) {
    if (!options.apiKey) throw new Error("apiKey is required");
    if (!options.workspaceId) throw new Error("workspaceId is required");

    this.http = new HttpClient({
      baseUrl: options.baseUrl ?? "https://api.acme.com",
      apiKey: options.apiKey,
      timeout: options.timeout,
      maxRetries: options.maxRetries,
      userAgent: `acme-node-sdk/1.0.0 node/${process.version}`,
    });

    this.users = new UsersResource(this.http, options.workspaceId);
    this.projects = new ProjectsResource(this.http, options.workspaceId);
    this.webhooks = new WebhooksResource(options.webhookSecret ?? "");
  }
}

Public Exports

// src/index.ts
export { Acme } from "./client";
export type { AcmeClientOptions } from "./client";

// Error classes โ€” users need to catch specific types
export {
  AcmeError,
  AcmeAuthenticationError,
  AcmePermissionError,
  AcmeNotFoundError,
  AcmeValidationError,
  AcmeRateLimitError,
  AcmeServerError,
} from "./errors";

// Pagination
export type { PaginatedResponse } from "./pagination";
export { PageIterator } from "./pagination";

// Resource types
export type { User, CreateUserParams, UpdateUserParams } from "./resources/users";

SDK Usage Example

import { Acme, AcmeNotFoundError, AcmeRateLimitError } from "@acme/sdk";

const acme = new Acme({
  apiKey: process.env.ACME_API_KEY!,
  workspaceId: process.env.ACME_WORKSPACE_ID!,
  webhookSecret: process.env.ACME_WEBHOOK_SECRET,
});

// Single resource
const user = await acme.users.get("usr_abc123");
console.log(user.name); // Typed: string โœ…

// Create with validation
try {
  const newUser = await acme.users.create({
    email: "alice@example.com",
    name: "Alice",
    sendInvite: true,
  });
} catch (err) {
  if (err instanceof AcmeNotFoundError) {
    console.error("Not found:", err.message);
  } else if (err instanceof AcmeRateLimitError) {
    console.error(`Rate limited โ€” retry after ${err.retryAfter}s`);
  }
}

// Paginate all users (async iterator)
for await (const page of acme.users.iterate({ limit: 100 })) {
  for (const user of page) {
    console.log(user.email);
  }
}

// Collect all into flat array
const allUsers = await acme.users.iterate().collect();

// Webhook verification (Next.js)
export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("x-acme-signature") ?? "";
  const ts = req.headers.get("x-acme-timestamp") ?? undefined;

  await acme.webhooks.verifySignature(body, sig, ts);
  // Signature valid โ€” process event
}

Package Configuration

// package.json
{
  "name": "@acme/sdk",
  "version": "1.0.0",
  "description": "Official TypeScript SDK for Acme API",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --clean",
    "test": "vitest run",
    "lint": "tsc --noEmit"
  },
  "files": ["dist", "README.md"],
  "engines": { "node": ">=18" },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.4.0",
    "vitest": "^1.0.0"
  }
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic SDK (typed client + errors)1 dev3โ€“5 days$800โ€“1,500
Full SDK (retry + pagination + webhooks)1 dev1โ€“2 weeks$2,500โ€“5,000
+ Tests + CI + npm publishing pipeline1 dev+3โ€“5 days+$800โ€“1,500
Multi-language SDKs (Python, Go)2 devs3โ€“4 weeks$8,000โ€“16,000

See Also


Working With Viprasol

Your SDK is your API's first impression on every developer who integrates with your product. A well-built SDK with rich error types, automatic retry, and pagination helpers dramatically reduces integration time โ€” and support tickets. Our team has built TypeScript SDKs for SaaS APIs that handle enterprise scale and publish cleanly to npm with ESM/CJS dual exports.

What we deliver:

  • Typed HTTP client with fetch, timeout, and retry/backoff
  • Rich error class hierarchy (auth, permission, not found, validation, rate limit, server)
  • Cursor pagination async iterator with .collect() helper
  • Webhook signature verification with timing-safe comparison
  • tsup build configuration for ESM/CJS dual output with .d.ts types

Talk to our team about building your API SDK โ†’

Or explore our SaaS development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.