Official TypeScript SDK for the Basecamp API.
- 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
npm install @37signals/basecampRequires Node.js 18+ and TypeScript 5.0+.
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}`);
}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
});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;
},
});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).
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),
});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,
});
}The SDK provides typed services for the complete Basecamp API:
| Service | Methods |
|---|---|
projects |
list, get, create, update, trash |
templates |
list, get, createProject |
tools |
list, get, update |
people |
list, get, me, listPingable |
| Service | Methods |
|---|---|
todos |
list, get, create, update, trash, complete, uncomplete, reposition |
todolists |
list, get, create, update, trash |
todosets |
get |
todolistGroups |
list, get, create, reposition |
| 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, deleteLine |
| Service | Methods |
|---|---|
cardTables |
get, listColumns |
cards |
list, get, create, update, move |
cardColumns |
get, create, update, move |
cardSteps |
list, get, create, update, complete, uncomplete |
| Service | Methods |
|---|---|
schedules |
get, listEntries, getEntry, createEntry, updateEntry, trashEntry |
lineup |
create, update, delete |
checkins |
get, listQuestions, getQuestion, listAnswers, getAnswer |
| Service | Methods |
|---|---|
vaults |
list, get, create, update |
documents |
list, get, create, update, trash |
uploads |
list, get, create, update, trash |
attachments |
createUploadUrl, create |
| Service | Methods |
|---|---|
webhooks |
list, get, create, update, delete |
subscriptions |
get, subscribe, unsubscribe, update |
events |
list, listForRecording |
recordings |
archive, unarchive, trash |
| Service | Methods |
|---|---|
search |
search |
reports |
progress, upcoming, assigned, overdue, personProgress |
timesheets |
forRecording, forProject, report |
timeline |
get |
| Service | Methods |
|---|---|
clientApprovals |
list, get |
clientCorrespondences |
list, get |
clientReplies |
list, get |
clientVisibility |
get, update |
| Service | Methods |
|---|---|
forwards |
list, get, createReply |
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 availableFor any authenticated download URL (e.g. a download_url you already have
in hand), use client.downloadURL:
const result = await client.downloadURL(url);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.
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" },
});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;
}| 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 |
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,
});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,
});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)
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,
});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
import { chainHooks, consoleHooks, otelHooks } from "@37signals/basecamp";
const client = createBasecampClient({
accountId: "12345",
accessToken: "token",
hooks: chainHooks(
consoleHooks(),
otelHooks({ tracer, meter }),
myCustomHooks,
),
});// 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 });// 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);// 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);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);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);
}# 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 lintMIT