Basecamp SDK

August 12, 2026 · View on GitHub

Official Basecamp API clients, runtimes, and software development kits for Go, Ruby, TypeScript, Swift, Kotlin, and Python.

OpenAPI 3.1 spec included.

Upgrading? Read MIGRATING.md before you bump the version. v0.14.0 breaks all six SDKs: ListUploadVersions now returns version events instead of uploads — the four typed SDKs catch that at compile time, Ruby and Python just start seeing different keys — and every 507 now reports the new limit_exceeded code instead of a retryable api_error, which silently reroutes any error handling that branches on the old code. Coming from v0.12.0 or earlier? v0.13.0's section still applies, and 61 of its breaks survive a clean build.

Languages

LanguagePathStatusPackage
Gogo/Activegithub.com/basecamp/basecamp-sdk/go
Rubyruby/Activebasecamp-sdk
TypeScripttypescript/Active@37signals/basecamp
Swiftswift/ActiveBasecamp (SPM)
Kotlinkotlin/Activecom.basecamp:basecamp-sdk (GitHub Packages)
Pythonpython/Activebasecamp-sdk (PyPI)
FeatureGoTypeScriptRubySwiftKotlinPython
OAuth 2.0 Authentication
Static Token Authentication
ETag HTTP Caching (opt-in)via Faraday†
Automatic Retry with Backoff
Pagination Handling
Observability Hooks
Structured Errors
Webhook Verification

† Ruby SDK uses Faraday - add caching via faraday-http-cache

Note: HTTP caching is disabled by default. Enable explicitly via configuration:

  • Go: cfg.CacheEnabled = true, or BASECAMP_CACHE_ENABLED=true plus a cfg.LoadConfigFromEnv() call
  • TypeScript: enableCache: true in client options
  • Swift: BasecampConfig(enableCache: true)
  • Kotlin: enableCache = true in builder DSL

All SDKs are generated from a single Smithy specification, ensuring consistent behavior and API coverage across languages.

Getting a token

Every Basecamp API request carries an OAuth 2.0 access token. There is no API key and no personal access token, so even a throwaway script starts here:

  1. Choose the grant that matches how your code runs:
Your integrationGrantWho refreshes the token
already holds a token you obtained elsewherestatic tokenyou do
can receive a browser redirect (web app, or a local callback server)authorization code + PKCEa refreshing token provider (built in for Go, Ruby, Python; wire it yourself in TypeScript and Kotlin)
has no browser, but a person can approve on another device (CLI, headless server, TV)device flow (RFC 8628)Go's AuthManager; in Ruby and Python the standalone refresh helper, not their built-in token providers (see below); wire it yourself in TypeScript and Kotlin

The one-line rule: a redirect URI you control → authorization code; no browser but someone to approve → device flow; a token already in hand → static token. An unattended daemon or CI job fits none of the three on its own — the device flow needs a person to enter the user code at the verification URI — so provision a token out of band and hand it to the process as a static or refresh token.

  1. Get the client credentials that grant needs:
  • Authorization code + PKCE — register your own integration at https://launchpad.37signals.com/integrations. You get a client ID, a client secret, and whatever redirect URI you nominated.
  • Device flow — nothing to register. It runs as the pre-registered public basecamp-cli client, which sends no secret, against the device endpoint that discovery returns. Launchpad advertises no device endpoint, so a client you register there is not the one this flow uses.
  • Static token — nothing to register; you already hold the token.

A device-flow token needs a matching refresh path. BC5 device logins mint multi-account refresh tokens carrying an RFC 8707 resource indicator, and a refresh that does not echo it is rejected with 400 invalid_request. Go's AuthManager refreshes against the stored token endpoint and echoes resource, so it handles this. Ruby's OauthTokenProvider and Python's OAuthTokenProvider do not — both are pinned to Launchpad's legacy token URL, send no resource, and expect a client secret the public client does not have. In those two, refresh a device token with Basecamp::Oauth.refresh_token / basecamp.oauth.exchange.refresh_token, passing the stored resource.

A static token is the shortest path to a first successful call, and it is the one option the SDK will never refresh for you — once it expires, every request fails with 401 until you supply a new one. The Quick Start snippets below all use static tokens for brevity; move to one of the other two grants before you ship. OAuth is available in every SDK except Swift, and the device flow in Go, Ruby, TypeScript, Kotlin, and Python — see the per-language docs linked under Documentation.

Finding your account ID

Every API path is scoped to an account — https://3.basecampapi.com/{accountId}/… — so you need that number before your first call, and the Quick Start snippets below assume you already have it. One token can reach several accounts, so ask the token which:

LanguageCall
Goclient.Authorization().GetInfo(ctx, nil)
Rubyclient.authorization.get
TypeScriptawait client.authorization.getInfo()
Pythonclient.authorization.get()

The response lists every account the token can reach; take accounts[].id for an entry whose product is "bc3" (that is Basecamp — the same response also carries "hey" and other 37signals products). The same response carries the token's expiry — expires_at on the wire, expiresAt on the TypeScript type — which is the quickest way to confirm a static token has not lapsed.

The document is account-independent, so call it on the top-level client, before ForAccount/for_account. TypeScript is the exception — createBasecampClient requires an accountId up front, so pass a placeholder for the bootstrap call and rebuild the client once you know the real one.

It lives on the authorization server that issued your token, not on the Basecamp API — which matters once you leave Launchpad behind. A Launchpad-issued token (authorization code, or a static token from there) reads it at Launchpad. A device-flow token is issued by the discovered BC5 server, and its document lives there too. Ruby follows the token: Http#get_authorization_document runs resource-first discovery and fetches from the selected issuer. The other three hardcode Launchpad, so a device-flow token needs the issuer supplied — Go takes GetInfoOptions.Endpoint and TypeScript an endpoint option, while Python's authorization.get() accepts no override at all, so fetch the document yourself as in the curl below.

Swift and Kotlin ship no authorization service. Fetch it once with any HTTP client:

# Launchpad-issued token. For a device-flow token, replace the host with the
# issuer discovery selected — that is where its authorization.json lives.
curl -s https://launchpad.37signals.com/authorization.json \
  -H "Authorization: Bearer $BASECAMP_TOKEN" \
  -H "User-Agent: my-app/1.0 (you@example.com)"

A User-Agent identifying your app is required on every Basecamp request, including this one.

Quick Start

Go

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)

func main() {
    cfg := basecamp.DefaultConfig()
    token := &basecamp.StaticTokenProvider{Token: os.Getenv("BASECAMP_TOKEN")}
    client := basecamp.NewClient(cfg, token)

    account := client.ForAccount(os.Getenv("BASECAMP_ACCOUNT_ID"))
    result, err := account.Projects().List(context.Background(), nil)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }

    for _, p := range result.Projects {
        fmt.Printf("%d: %s\n", p.ID, p.Name)
    }
}

Ruby

require "basecamp"

client = Basecamp.client(access_token: ENV["BASECAMP_TOKEN"])
account = client.for_account(ENV["BASECAMP_ACCOUNT_ID"])

account.projects.list.each do |project|
  puts "#{project['id']}: #{project['name']}"
end

TypeScript

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

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

const projects = await client.projects.list();
projects.forEach(p => console.log(`${p.id}: ${p.name}`));

Swift

import Basecamp

let client = BasecampClient(
    accessToken: ProcessInfo.processInfo.environment["BASECAMP_TOKEN"]!,
    userAgent: "my-app/1.0 (you@example.com)"
)

let account = client.forAccount(ProcessInfo.processInfo.environment["BASECAMP_ACCOUNT_ID"]!)
let projects = try await account.projects.list()
for project in projects {
    print("\(project.id): \(project.name)")
}

Kotlin

Service accessors are extension properties, so account.projects needs the com.basecamp.sdk.generated import as well as the client's. Every service method is suspend, so the calls need a coroutine.

import com.basecamp.sdk.BasecampClient
import com.basecamp.sdk.generated.projects

suspend fun main() {
    val client = BasecampClient {
        accessToken(System.getenv("BASECAMP_TOKEN"))
        userAgent = "my-app/1.0 (you@example.com)"
    }

    val account = client.forAccount(System.getenv("BASECAMP_ACCOUNT_ID"))
    account.projects.list().forEach { println("${it.id}: ${it.name}") }

    client.close()
}

Python

import os
from basecamp import Client

client = Client(access_token=os.environ["BASECAMP_TOKEN"])
account = client.for_account(os.environ["BASECAMP_ACCOUNT_ID"])

projects = account.projects.list()
for project in projects:
    print(f"{project['id']}: {project['name']}")

Features

All SDKs provide:

  • Full API coverage - 35+ services covering projects, todos, messages, schedules, campfires, card tables, and more
  • OAuth 2.0 authentication - Token refresh, PKCE support (Go, TypeScript, Ruby, Kotlin, Python), and static token options
  • Automatic retry - Exponential backoff with jitter, respects Retry-After headers
  • Pagination - Link header–based pagination support (high-level handling may vary by SDK; see language docs)
  • ETag caching - Opt-in HTTP caching for efficient API usage (Go, TypeScript, Ruby†, Swift, Kotlin); off by default everywhere
  • Structured errors - Typed errors with helpful hints, CLI-friendly exit codes, and per-field validation detail you can bind straight to a form
  • Observability hooks - Integration points for logging, metrics, and tracing

API Coverage

CategoryServices
ProjectsProjects, Templates, Tools, People
To-dosTodos, Todolists, Todosets, TodolistGroups
MessagesMessages, MessageBoards, MessageTypes, Comments
ChatCampfires (lines, chatbots)
SchedulingSchedules, Timeline, Lineup, Checkins
FilesVaults, Documents, Uploads, Attachments
Card TablesCardTables, Cards, CardColumns, CardSteps, Wormholes
Client PortalClientApprovals, ClientCorrespondences, ClientReplies
AutomationWebhooks, Subscriptions, Events
ReportingSearch, Reports, Timesheets, Recordings

Specification

The spec/ directory contains the API specification in Smithy IDL format. This specification drives:

  • OpenAPI generation for client codegen
  • Type definitions across all SDKs
  • Consistent behavior modeling (pagination, retries, idempotency)

See the spec README for details on the model structure.

Documentation

Environment Variables

There is no environment variable every SDK honours. The Quick Start snippets above call getenv themselves — that is the caller reading its own environment, not an SDK convention. What the SDKs read on their own, and only when you ask them to (the XDG directory variables aside: Go reads XDG_CACHE_HOME in DefaultConfig and XDG_CONFIG_HOME for its config directory, and Ruby reads XDG_CONFIG_HOME in Config.global_config_dir):

VariableRead byOnly when
BASECAMP_BASE_URLGo, Ruby, Pythoncfg.LoadConfigFromEnv() / Config.from_env
BASECAMP_TIMEOUTRuby, PythonConfig.from_env
BASECAMP_MAX_RETRIESRuby, PythonConfig.from_env
BASECAMP_CACHE_ENABLED, BASECAMP_CACHE_DIRGocfg.LoadConfigFromEnv()
BASECAMP_PROJECT_ID, BASECAMP_TODOLIST_IDGocfg.LoadConfigFromEnv()
BASECAMP_TOKENGoyou authenticate through AuthManager, which prefers it over the stored OAuth credentials
BASECAMP_NO_KEYRINGGoyou construct a CredentialStore via NewCredentialStore — which NewAuthManager does for you, but NewAuthManagerWithStore does not

TypeScript, Swift, and Kotlin read no environment variables at all — configure them entirely through their options objects.

BASECAMP_ACCOUNT_ID is not an SDK variable. No SDK reads it; it is a convention shared by this repository's examples, make conformance-*-live, and the nightly canary. Pass the account ID explicitly to ForAccount / for_account / forAccount / accountId.

See individual SDK documentation for language-specific options.

License

MIT