Basecamp TypeScript SDK
July 27, 2026 · View on GitHub
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:
| Failure | Result |
|---|---|
Hop-1 fetch/parse fails, or resource mismatch | soft resource_discovery_failed → Launchpad |
| Valid metadata omits the first-party issuer | soft no_as_advertised → Launchpad |
≥2 non-Launchpad issuers (no expectedIssuer) | throws ambiguous_issuers |
expectedIssuer not advertised | throws expected_issuer_unavailable |
| Committed issuer origin invalid | throws invalid_issuer_origin |
| Committed AS metadata fetch fails | throws as_fetch_failed |
| Committed issuer binding mismatch | throws issuer_mismatch |
Hard cases throw DiscoverySelectionError (with a .reason); soft cases return a
{ kind: "fallback", reason }.
Breaking-ish:
OAuthConfig.authorizationEndpointis now optional — device-only servers omit it. Authorization-code consumers must assert it before use (performInteractiveLogindoes 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
| Service | Methods |
|---|---|
projects | list, get, create, update, trash |
templates | list, get, createProject |
tools | list, get, update |
people | list, get, me, listPingable |
To-dos
| Service | Methods |
|---|---|
todos | list, get, create, update, trash, complete, uncomplete, reposition |
todolists | list, get, create, update, trash, reposition |
todosets | get |
todolistGroups | list, get, create, reposition |
Messages & Communication
| Service | Methods |
|---|---|
messages | list, get, create, update, pin, unpin |
messageBoards | get |
messageTypes | list, get, create, update, delete |
comments | list, get, create, update |
campfires | list, get, listLines, getLine, createLine, updateLine, deleteLine |
Card Tables (Kanban)
| Service | Methods |
|---|---|
cardTables | get, listColumns |
cards | list, get, create, update, move |
cardColumns | get, create, update, move |
cardSteps | list, get, create, update, complete, uncomplete |
wormholes | create, update, delete |
Scheduling
| Service | Methods |
|---|---|
schedules | get, listEntries, getEntry, createEntry, updateEntry, trashEntry |
lineup | create, update, delete |
checkins | get, listQuestions, getQuestion, listAnswers, getAnswer |
Files & Documents
| Service | Methods |
|---|---|
vaults | list, get, create, update |
documents | list, get, create, update, trash |
uploads | list, get, create, update, trash |
attachments | createUploadUrl, create |
Integrations & Events
| Service | Methods |
|---|---|
webhooks | list, get, create, update, delete |
subscriptions | get, subscribe, unsubscribe, update |
events | list, listForRecording |
recordings | archive, unarchive, trash |
Search & Reports
| Service | Methods |
|---|---|
search | search |
reports | progress, upcoming, assigned, overdue, personProgress |
timesheets | forRecording, forProject, report |
timeline | get |
Client Portal
| Service | Methods |
|---|---|
clientApprovals | list, get |
clientCorrespondences | list, get |
clientReplies | list, get |
clientVisibility | get, update |
| Service | Methods |
|---|---|
forwards | list, 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
| Code | HTTP Status | Exit Code | Description |
|---|---|---|---|
auth_required | 401 | 3 | Authentication required |
forbidden | 403 | 4 | Access denied |
not_found | 404 | 2 | Resource not found |
rate_limit | 429 | 5 | Rate limit exceeded (retryable) |
network | - | 6 | Network error (retryable) |
api_error | 5xx | 7 | Server error |
ambiguous | - | 8 | Multiple matches found |
validation | 400, 422 | 9 | Invalid request data |
usage | - | 1 | Configuration 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-Afterheader - 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 durationsbasecamp.operations.total- Counter of operationsbasecamp.errors.total- Counter of errorsbasecamp.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