Basecamp TypeScript SDK

July 27, 2026 · View on GitHub

npm version TypeScript Test

Official TypeScript SDK for the Basecamp API.

Features

  • Full type safety with TypeScript generics
  • 30+ services covering the complete Basecamp API
  • OAuth 2.0 with PKCE support
  • ETag-based HTTP caching
  • Automatic retry with exponential backoff
  • Pagination helpers for large result sets
  • Observability hooks for logging, metrics, and tracing
  • OpenTelemetry integration

Installation

npm install @37signals/basecamp

Requires Node.js 22.12+ and TypeScript 5.0+.

Quick Start

import { createBasecampClient } from "@37signals/basecamp";

const client = createBasecampClient({
  accountId: process.env.BASECAMP_ACCOUNT_ID!,
  accessToken: process.env.BASECAMP_TOKEN!,
});

// List all projects
const projects = await client.projects.list();
for (const project of projects) {
  console.log(`${project.id}: ${project.name}`);
}

Configuration

Client Options

import { createBasecampClient } from "@37signals/basecamp";

const client = createBasecampClient({
  // Required
  accountId: "12345",
  accessToken: "your-token", // or async token provider

  // Optional
  baseUrl: "https://3.basecampapi.com/12345", // default
  userAgent: "my-app/1.0",
  enableCache: true, // ETag caching (default: false)
  enableRetry: true, // Auto retry 429 and 503 (default: true)
  hooks: myHooks, // Observability hooks
});

Token Providers

For simple use cases, pass a static token string:

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "your-access-token",
});

For token refresh scenarios, pass an async function:

const client = createBasecampClient({
  accountId: "12345",
  accessToken: async () => {
    // Fetch or refresh your token
    const token = await myTokenStore.getValidToken();
    return token.accessToken;
  },
});

OAuth 2.0

The SDK includes utilities for implementing OAuth 2.0 with automatic PKCE negotiation. PKCE parameters are included only when the server's discovery metadata advertises code_challenge_methods_supported: ["S256"] (per RFC 8414 and RFC 7636).

Interactive Login (CLI / Desktop)

performInteractiveLogin handles the full flow — discovery, PKCE negotiation, local callback server, browser launch, code exchange, and token storage:

import { performInteractiveLogin } from "@37signals/basecamp";
import open from "open";

const token = await performInteractiveLogin({
  clientId: CLIENT_ID,
  clientSecret: CLIENT_SECRET,
  store: myTokenStore,
  openBrowser: (url) => open(url),
  onStatus: (msg) => console.log(msg),
});

Manual Authorization Flow

For web apps or custom flows, use the lower-level helpers directly:

import {
  discoverLaunchpad,
  buildAuthorizationUrl,
  generatePKCE,
  generateState,
  exchangeCode,
  refreshToken,
  isTokenExpired,
} from "@37signals/basecamp";

// 1. Discover OAuth endpoints
const config = await discoverLaunchpad();

// 2. Generate PKCE (only if the server supports S256) and state
const supportsPKCE = config.codeChallengeMethodsSupported?.includes("S256") ?? false;
const pkce = supportsPKCE ? await generatePKCE() : undefined;
const state = generateState();

// Store pkce?.verifier and state in session for later

// 3. Build authorization URL
const authUrl = buildAuthorizationUrl({
  authorizationEndpoint: config.authorizationEndpoint,
  clientId: CLIENT_ID,
  redirectUri: REDIRECT_URI,
  state,
  pkce,
});
// Redirect user to authUrl.toString()

// 4. Exchange code for tokens (in callback handler)
const token = await exchangeCode({
  tokenEndpoint: config.tokenEndpoint,
  code: callbackParams.code,
  redirectUri: REDIRECT_URI,
  clientId: CLIENT_ID,
  clientSecret: CLIENT_SECRET,
  codeVerifier: pkce?.verifier,
  useLegacyFormat: true, // Required for Basecamp Launchpad
});

// 5. Refresh when expired
if (isTokenExpired(token)) {
  const newToken = await refreshToken({
    tokenEndpoint: config.tokenEndpoint,
    refreshToken: token.refreshToken!,
    useLegacyFormat: true,
  });
}

Resource-first discovery (BC5)

BC5 serves its Authorization Server metadata only at its canonical issuer (the web host), so discovery starts from the resource (RFC 9728) and composes with AS discovery (RFC 8414):

import { discoverFromResource, DiscoverySelectionError } from "@37signals/basecamp";

const result = await discoverFromResource("https://3.basecampapi.com");
if (result.kind === "selected") {
  // result.config is bound + validated for result.issuer
} else {
  // result.reason is "resource_discovery_failed" | "no_as_advertised"
  // → fall back to Launchpad (discoverLaunchpad())
}

performInteractiveLogin supports this via resourceBaseUrl (mutually exclusive with the legacy baseUrl; supplying both is a usage error):

await performInteractiveLogin({
  clientId: "basecamp-cli",
  store: myTokenStore,
  resourceBaseUrl: "https://3.basecampapi.com",
  expectedIssuer, // optional: authoritative, non-heuristic selection
  openBrowser: (url) => open(url),
});

Selection. With expectedIssuer, the advertised member equal by code-point is selected (else a hard expected_issuer_unavailable). Without it, the SDK uses a documented Basecamp-profile heuristic: exactly one non-Launchpad issuer → selected; ≥2 → hard ambiguous_issuers (never guesses); zero → Launchpad.

Fallback is allowed only before a first-party issuer is committed. Once valid resource metadata advertises it and it is selected, every later failure is fatal — the SDK never silently falls back to Launchpad:

FailureResult
Hop-1 fetch/parse fails, or resource mismatchsoft resource_discovery_failed → Launchpad
Valid metadata omits the first-party issuersoft no_as_advertised → Launchpad
≥2 non-Launchpad issuers (no expectedIssuer)throws ambiguous_issuers
expectedIssuer not advertisedthrows expected_issuer_unavailable
Committed issuer origin invalidthrows invalid_issuer_origin
Committed AS metadata fetch failsthrows as_fetch_failed
Committed issuer binding mismatchthrows issuer_mismatch

Hard cases throw DiscoverySelectionError (with a .reason); soft cases return a { kind: "fallback", reason }.

Breaking-ish: OAuthConfig.authorizationEndpoint is now optional — device-only servers omit it. Authorization-code consumers must assert it before use (performInteractiveLogin does this and errors if it is missing). Both discovery hops are SSRF-hardened (HTTPS-only origins, suppressed redirects, bounded body reads).

Services

The SDK provides typed services for the complete Basecamp API:

Projects & Organization

ServiceMethods
projectslist, get, create, update, trash
templateslist, get, createProject
toolslist, get, update
peoplelist, get, me, listPingable

To-dos

ServiceMethods
todoslist, get, create, update, trash, complete, uncomplete, reposition
todolistslist, get, create, update, trash, reposition
todosetsget
todolistGroupslist, get, create, reposition

Messages & Communication

ServiceMethods
messageslist, get, create, update, pin, unpin
messageBoardsget
messageTypeslist, get, create, update, delete
commentslist, get, create, update
campfireslist, get, listLines, getLine, createLine, updateLine, deleteLine

Card Tables (Kanban)

ServiceMethods
cardTablesget, listColumns
cardslist, get, create, update, move
cardColumnsget, create, update, move
cardStepslist, get, create, update, complete, uncomplete
wormholescreate, update, delete

Scheduling

ServiceMethods
schedulesget, listEntries, getEntry, createEntry, updateEntry, trashEntry
lineupcreate, update, delete
checkinsget, listQuestions, getQuestion, listAnswers, getAnswer

Files & Documents

ServiceMethods
vaultslist, get, create, update
documentslist, get, create, update, trash
uploadslist, get, create, update, trash
attachmentscreateUploadUrl, create

Integrations & Events

ServiceMethods
webhookslist, get, create, update, delete
subscriptionsget, subscribe, unsubscribe, update
eventslist, listForRecording
recordingsarchive, unarchive, trash

Search & Reports

ServiceMethods
searchsearch
reportsprogress, upcoming, assigned, overdue, personProgress
timesheetsforRecording, forProject, report
timelineget

Client Portal

ServiceMethods
clientApprovalslist, get
clientCorrespondenceslist, get
clientReplieslist, get
clientVisibilityget, update

Email

ServiceMethods
forwardslist, get, createReply

Downloading Files

Fetch an upload's file content in one call. The SDK fetches the upload metadata, then follows the authenticated-hop + 302 flow against the signed storage URL.

const result = await client.uploads.download(1069479400);
// result.body is a ReadableStream<Uint8Array>
const bytes = new Uint8Array(await new Response(result.body).arrayBuffer());
// result.contentType, result.contentLength, result.filename are also available

For any authenticated download URL (e.g. a download_url you already have in hand), use client.downloadURL:

const result = await client.downloadURL(url);

Pagination

List methods return a single page of results by default. Use the pagination helpers with low-level API calls to fetch all pages:

import { fetchAllPages, paginateAll } from "@37signals/basecamp";

// First, fetch the initial page using the low-level client
const initialResponse = await client.GET("/projects.json");

// Option 1: fetchAllPages - returns all results as an array
const allProjects = await fetchAllPages(
  initialResponse.response,
  (response) => response.json()
);

// Option 2: paginateAll - async generator for streaming large result sets
for await (const page of paginateAll(
  initialResponse.response,
  (response) => response.json()
)) {
  for (const project of page) {
    console.log(project.name);
  }
}

Paginated endpoints include an X-Total-Count HTTP header when available. You can access this header via the response.headers field on low-level client.GET/client.POST calls.

Low-Level API Access

For endpoints not covered by services or advanced use cases, use the raw typed client:

// Direct API calls with full type inference
const { data, error, response } = await client.GET("/projects.json");

if (error) {
  console.error("Failed:", error);
} else {
  console.log(data.map((p) => p.name));
}

// With path parameters
const { data: project } = await client.GET("/projects/{projectId}", {
  params: { path: { projectId: 12345 } },
});

// POST with body
const { data: newProject } = await client.POST("/projects.json", {
  body: { name: "My Project", description: "A new project" },
});

Error Handling

The SDK provides structured errors with codes, hints, and exit codes for CLI applications:

import { BasecampError, isBasecampError, isErrorCode } from "@37signals/basecamp";

try {
  await client.todos.get(todoId);
} catch (err) {
  if (isBasecampError(err)) {
    console.error(`Error [${err.code}]: ${err.message}`);

    if (err.hint) {
      console.error(`Hint: ${err.hint}`);
    }

    if (err.retryable && err.retryAfter) {
      console.log(`Retry after ${err.retryAfter} seconds`);
    }

    // Use exit codes for CLI applications
    process.exit(err.exitCode);
  }
  throw err;
}

Error Codes

CodeHTTP StatusExit CodeDescription
auth_required4013Authentication required
forbidden4034Access denied
not_found4042Resource not found
rate_limit4295Rate limit exceeded (retryable)
network-6Network error (retryable)
api_error5xx7Server error
ambiguous-8Multiple matches found
validation400, 4229Invalid request data
usage-1Configuration or argument error

Retry Behavior

The SDK automatically retries requests on transient failures:

  • Retryable errors: 429 (rate limit) and 503 (service unavailable)
  • Backoff: Exponential with jitter
  • Rate limits: Respects Retry-After header
  • Max retries: 3 attempts by default

Disable retry for specific use cases:

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "token",
  enableRetry: false,
});

Caching

The SDK uses ETag-based HTTP caching to reduce API calls and respect Basecamp's rate limits:

// First request fetches from API
const projects = await client.projects.list();

// Second request returns cached data if unchanged (304 Not Modified)
const projects2 = await client.projects.list();

Disable caching if needed:

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "token",
  enableCache: false,
});

Observability

Console Logging

For debugging or verbose CLI modes:

import { createBasecampClient, consoleHooks } from "@37signals/basecamp";

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "token",
  hooks: consoleHooks({
    logOperations: true,
    logRequests: true, // More verbose
    logRetries: true,
    minDurationMs: 100, // Only log slow requests
  }),
});

Output:

[Basecamp] Projects.List
[Basecamp] -> GET https://3.basecampapi.com/12345/projects.json
[Basecamp] <- GET https://3.basecampapi.com/12345/projects.json 200 (145ms)
[Basecamp] Projects.List completed (147ms)

Custom Hooks

Implement the BasecampHooks interface for custom observability:

import type { BasecampHooks } from "@37signals/basecamp";

const metricsHooks: BasecampHooks = {
  onOperationStart(info) {
    metrics.startTimer(`${info.service}.${info.operation}`);
  },

  onOperationEnd(info, result) {
    metrics.recordDuration(`${info.service}.${info.operation}`, result.durationMs);
    if (result.error) {
      metrics.incrementError(`${info.service}.${info.operation}`);
    }
  },

  onRetry(info, attempt, error, delayMs) {
    logger.warn(`Retrying ${info.method} ${info.url} (attempt ${attempt})`);
  },
};

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "token",
  hooks: metricsHooks,
});

OpenTelemetry Integration

For distributed tracing and metrics:

import { createBasecampClient, otelHooks } from "@37signals/basecamp";
import { trace, metrics } from "@opentelemetry/api";

const tracer = trace.getTracer("my-app");
const meter = metrics.getMeter("my-app");

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "token",
  hooks: otelHooks({
    tracer,
    meter,
    recordRequestSpans: true, // Include HTTP-level spans
  }),
});

Creates spans and metrics:

  • basecamp.operation.duration - Histogram of operation durations
  • basecamp.operations.total - Counter of operations
  • basecamp.errors.total - Counter of errors
  • basecamp.retries.total - Counter of retry attempts

Combining Multiple Hooks

import { chainHooks, consoleHooks, otelHooks } from "@37signals/basecamp";

const client = createBasecampClient({
  accountId: "12345",
  accessToken: "token",
  hooks: chainHooks(
    consoleHooks(),
    otelHooks({ tracer, meter }),
    myCustomHooks,
  ),
});

Examples

Working with Todos

// List todos in a todolist
const todos = await client.todos.list(todolistId);

// Create a todo with assignees
const todo = await client.todos.create(todolistId, {
  content: "Review pull request",
  description: "<p>Check the new auth flow</p>",
  dueOn: "2026-02-01",
  assigneeIds: [12345, 67890],
});

// Complete a todo
await client.todos.complete(todo.id);

// Reposition a todo to the top
await client.todos.reposition(todo.id, { position: 1 });

Working with Messages

// Get a message board
const board = await client.messageBoards.get(boardId);

// List messages
const messages = await client.messages.list(board.id);

// Create a message
const msg = await client.messages.create(board.id, {
  subject: "Weekly Update",
  content: "<p>Here's what we accomplished...</p>",
});

// Pin a message
await client.messages.pin(msg.id);

Working with Campfire

// List campfires
const campfires = await client.campfires.list();

// Send a message
await client.campfires.createLine(campfireId, {
  content: "Hello, team!",
});

// List recent messages
const lines = await client.campfires.listLines(campfireId);

Working with Webhooks

const bucketId = 12345; // project/bucket ID

// Create a webhook
const webhook = await client.webhooks.create(bucketId, {
  payloadUrl: "https://example.com/webhook",
  types: ["Todo", "Comment"],
});

// List webhooks
const webhooks = await client.webhooks.list(bucketId);

// Delete a webhook
await client.webhooks.delete(webhook.id);

TypeScript Types

All types are exported for use in your code:

import type {
  Project,
  Todo,
  Message,
  Person,
  CreateTodoRequest,
  BasecampError,
  ErrorCode,
} from "@37signals/basecamp";

function processTodo(todo: Todo): void {
  console.log(todo.content);
}

function createTodo(data: CreateTodoRequest): Promise<Todo> {
  return client.todos.create(todolistId, data);
}

Development

# Install dependencies
npm install

# Generate types from OpenAPI spec
npm run generate

# Build
npm run build

# Run tests
npm test

# Type check
npm run typecheck

# Lint
npm run lint

License

MIT