Official Python SDK for the Basecamp API.
- Full API coverage — 46 generated services covering projects, todos, messages, schedules, campfires, card tables, and more
- OAuth 2.0 authentication — PKCE support, token refresh, resource-first (RFC 9728 + RFC 8414) discovery
- Static token authentication — Simple setup for personal integrations
- Automatic retry with backoff — Exponential backoff with jitter, respects
Retry-Afterheaders - Pagination handling — Automatic Link header-based pagination with
ListResult - Structured errors — Typed exceptions with error codes, hints, and CLI-friendly exit codes
- Observability hooks — Integration points for logging, metrics, and tracing
- Webhook verification — HMAC signature verification, deduplication, glob-based routing
- Async support — Full async/await API via
AsyncClientbacked by httpx - File downloads — Authenticated downloads with redirect following
- Type hints — Full type annotations for IDE support
- Python 3.11 or later
- httpx (installed automatically)
pip install basecamp-sdkOr with uv:
uv add basecamp-sdkimport 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']}")import asyncio
import os
from basecamp import AsyncClient
async def main():
async with AsyncClient(access_token=os.environ["BASECAMP_TOKEN"]) as client:
account = client.for_account(os.environ["BASECAMP_ACCOUNT_ID"])
projects = await account.projects.list()
for project in projects:
print(f"{project['id']}: {project['name']}")
asyncio.run(main())| Variable | Description | Default |
|---|---|---|
BASECAMP_BASE_URL |
API base URL | https://3.basecampapi.com |
BASECAMP_TIMEOUT |
Request timeout (seconds) | 30 |
BASECAMP_MAX_RETRIES |
Total attempts including the initial request | 3 |
from basecamp import Config
# Load from environment variables
config = Config.from_env()
# Or configure programmatically
config = Config(
base_url="https://3.basecampapi.com",
timeout=30.0,
max_retries=3,
base_delay=1.0,
max_jitter=0.1,
max_pages=10_000,
)
client = Client(access_token="...", config=config)Configuration is immutable (frozen dataclass). Create a new Config to change settings.
from basecamp import Client
client = Client(access_token="your-token")from basecamp import Client, OAuthTokenProvider
provider = OAuthTokenProvider(
access_token="...",
client_id="your-client-id",
client_secret="your-client-secret",
refresh_token="...",
expires_at=1234567890.0,
on_refresh=lambda access, refresh, expires_at: save_tokens(access, refresh, expires_at),
)
client = Client(token_provider=provider)The OAuthTokenProvider automatically refreshes expired tokens before each request.
Implement the AuthStrategy protocol for custom authentication:
from basecamp import Client, AuthStrategy
class MyAuth:
def authenticate(self, headers: dict[str, str]) -> None:
headers["Authorization"] = "Bearer " + get_token()
client = Client(auth=MyAuth())The SDK provides helpers for the full OAuth 2.0 authorization code flow with PKCE.
from basecamp.oauth import discover_launchpad
config = discover_launchpad()
# config.authorization_endpoint # Optional[str] — may be None (see below)
# config.token_endpointconfig.authorization_endpoint is now optional (str | None): device-only
authorization servers omit it, so authorization-code consumers MUST assert its
presence before use. token_endpoint stays required.
BC5's Authorization Server metadata lives only at its canonical issuer (the web host), not the API host. Discovery therefore starts from the resource and composes with AS discovery. Two composable operations plus an orchestrator:
from basecamp.oauth import (
discover, # RFC 8414 AS metadata + issuer binding
discover_protected_resource, # RFC 9728 resource metadata (hop 1)
discover_from_resource, # orchestrator: selection + fallback
)
result = discover_from_resource("https://3.basecampapi.com")
if result.kind == "selected":
config = result.config # bound OAuthConfig from the selected issuer
else: # result.kind == "fallback"
config = discover_launchpad() # result.reason explains whySelection. Pass expected_issuer="https://app.basecamp.com" (the
production canonical issuer) for an explicit, authoritative choice (raises
expected_issuer_unavailable if it is not advertised — never a silent
fallback). Without it, the SDK identifies BC5 by exclusion: exactly one
non-Launchpad issuer is selected; two or more raise ambiguous_issuers; zero
falls back to Launchpad.
Pass bare origins — no trailing slash. Binding is code-point exact, and the
failure mode depends on which parameter carries the slash: a trailing-slash
expected_issuer fails the advertised-member lookup and raises a hard
expected_issuer_unavailable, while a trailing-slash resource origin breaks
the hop-1 resource binding and silently soft-falls back to Launchpad.
Stage-sensitive fallback. discover_from_resource returns a
DiscoveryResult that is either selected or a soft fallback whose reason
is one of only two FallbackReason values. Every hard case raises
DiscoverySelectionError (carrying a .reason) — no consumer may convert a
raise into a Launchpad request.
| Stage / failure | Outcome |
|---|---|
Hop-1 fetch/parse fails, or resource mismatch |
soft resource_discovery_failed → Launchpad |
Valid metadata omits BC5 (absent / [] / only-Launchpad) |
soft no_as_advertised → Launchpad |
≥2 non-Launchpad issuers, no expected_issuer |
raise ambiguous_issuers |
expected_issuer not advertised |
raise expected_issuer_unavailable |
| BC5 committed → invalid issuer origin | raise invalid_issuer_origin |
| BC5 committed → AS-metadata fetch fails (5xx / network) | raise as_fetch_failed |
| BC5 committed → issuer binding mismatch | raise issuer_mismatch |
| BC5 committed → missing per-grant endpoint (consumer-asserted) | raise capability_unavailable |
discover_protected_resource preserves authorization_servers absent (None)
vs present-but-empty ([]) distinctly.
SSRF hardening. Both hops require HTTPS (localhost exempt), validate the
origin-root with the transport URL parser before any socket opens, suppress
redirects, bound the timeout, and read the body under a genuine streaming cap
that aborts before an oversized body is buffered. Non-2xx on either hop maps to
api_error.
Timing contract. A bounded OAuth request returns within its timeout plus
at most 1 second of cleanup grace, spent only when the request already missed
its deadline. Budget timeout + 1 when folding these calls into your own
deadlines.
from basecamp.oauth import generate_pkce, generate_state, build_authorization_url
pkce = generate_pkce()
state = generate_state()
url = build_authorization_url(
endpoint=config.authorization_endpoint,
client_id="your-client-id",
redirect_uri="https://yourapp.com/callback",
state=state,
pkce=pkce,
)
# Redirect user to urlfrom basecamp.oauth import exchange_code
token = exchange_code(
token_endpoint=config.token_endpoint,
code="authorization-code-from-callback",
redirect_uri="https://yourapp.com/callback",
client_id="your-client-id",
client_secret="your-client-secret",
code_verifier=pkce.verifier,
)
# token.access_token, token.refresh_token, token.expires_atfrom basecamp.oauth import refresh_token
new_token = refresh_token(
token_endpoint=config.token_endpoint,
refresh_tok=token.refresh_token,
client_id="your-client-id",
client_secret="your-client-secret",
# Echo the stored token's RFC 8707 resource indicator. BC5 multi-account
# refresh tokens (e.g. basecamp-cli device logins) REJECT a refresh
# without it (400 invalid_request); it is sent only when set.
resource=token.resource,
)
# A refresh response MAY omit resource (binding unchanged) AND refresh_token
# (no rotation) — carry BOTH forward when persisting, or the next refresh
# loses its token:
# stored_resource = new_token.resource or token.resource
# stored_refresh = new_token.refresh_token or token.refresh_tokenBasecamp's Launchpad uses a non-standard token format. Pass use_legacy_format=True for compatibility:
token = exchange_code(
token_endpoint=config.token_endpoint,
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
client_secret=client_secret,
code_verifier=pkce.verifier,
use_legacy_format=True,
)For input-constrained clients (CLIs, TVs), the device flow trades a redirect for
a user code the person enters on another device. perform_device_login accepts
an already-selected OAuthConfig (from discovery), guards the device capability,
requests a code, surfaces it through your display hook, then polls for a token.
The device grant lives on BC5's authorization server, not Launchpad — so the config MUST come from resource-first discovery. Launchpad advertises no device authorization endpoint, and the capability guard rejects it before any request.
from basecamp import Client
from basecamp.oauth import discover_from_resource, perform_device_login
result = discover_from_resource("https://3.basecampapi.com")
if result.kind != "selected":
raise RuntimeError(f"device flow requires a discovered AS (got fallback: {result.reason})")
config = result.selected_config() # enforces the selected-result invariant, typed OAuthConfig
def show(auth):
print(f"Visit {auth.verification_uri} and enter code: {auth.user_code}")
token = perform_device_login(config, "basecamp-cli", display=show)
# Use the token to build a client — never print or log its value.
client = Client(access_token=token.access_token)The capability guard requires both config.device_authorization_endpoint and
"urn:ietf:params:oauth:grant-type:device_code" in config.grant_types_supported;
otherwise it raises DeviceFlowError(reason="unavailable") before any request.
An omitted scope lets the server apply its default (read) — prefer pinning
it explicitly with scope="read". The returned token MAY carry an RFC 8707
resource indicator (token.resource) — persist it and echo it on refresh
(see Token Refresh above).
The two building blocks compose directly when you want finer control:
import time
from basecamp.oauth import request_device_authorization, poll_device_token
# `device_authorization_endpoint` is optional on a discovered config (device-only
# servers omit it). `perform_device_login` guards this for you; when composing the
# building blocks yourself, assert the capability before requesting a code.
endpoint = config.device_authorization_endpoint
if endpoint is None:
raise RuntimeError("selected AS advertises no device_authorization_endpoint")
auth = request_device_authorization(endpoint, "basecamp-cli")
# The code's lifetime starts at issuance, not after display: anchor before the
# display hook and poll with the remaining lifetime, so a slow display eats into
# the deadline instead of extending it. (`perform_device_login` does this for you.)
issued_at = time.monotonic()
show(auth)
token = poll_device_token(
config.token_endpoint,
"basecamp-cli",
auth.device_code,
auth.interval,
auth.expires_in - (time.monotonic() - issued_at),
# The EXACT issuance-anchored deadline: a suspension between the remaining
# computation above and poller entry must not extend the code lifetime.
deadline_at=issued_at + auth.expires_in,
)poll_device_token runs the RFC 8628 §3.5 loop: it waits at least interval
seconds, honors slow_down (sustained +5s) and authorization_pending, backs
off exponentially on connection timeouts, and enforces a monotonic expiry
deadline. Pass should_cancel (any () -> bool, e.g. threading.Event.is_set)
for cooperative cancellation. The clock and sleep seams are injectable for
deterministic tests.
A terminal device-flow outcome raises DeviceFlowError carrying one of these
five .reason values, with the parent error category derived from it:
reason |
.code |
Retryable |
|---|---|---|
access_denied |
auth_required |
no |
expired |
auth_required |
no |
transport |
network |
yes |
unavailable |
validation |
no |
cancelled |
usage |
no |
Only these five outcomes are DeviceFlowError. A malformed response from an
otherwise-successful round-trip — an unparseable body, a 2xx missing
access_token, or an unrecognized error code — is a server fault, not a
device-flow outcome, so it raises OAuthError with type api_error instead.
.reason-derived retryability is authoritative: only transport retries, and a
caller cannot override it.
from basecamp.oauth import OAuthToken
token = OAuthToken(access_token="...", expires_in=7200)
token.is_expired() # False
token.is_expired(buffer_seconds=60) # True if expiring within 60sAll services are accessed through an AccountClient, obtained via client.for_account(account_id). The table below covers the common ones; see basecamp/generated/services/ for the full 45-service set.
| Category | Service | Accessor |
|---|---|---|
| Projects | Projects | account.projects |
| Templates | account.templates |
|
| Tools | account.tools |
|
| People | account.people |
|
| To-dos | Todos | account.todos |
| Todolists | account.todolists |
|
| Todosets | account.todosets |
|
| TodolistGroups | account.todolist_groups |
|
| HillCharts | account.hill_charts |
|
| Messages | Messages | account.messages |
| MessageBoards | account.message_boards |
|
| MessageTypes | account.message_types |
|
| Comments | account.comments |
|
| Chat | Campfires | account.campfires |
| Scheduling | Schedules | account.schedules |
| Timeline | account.timeline |
|
| Lineup | account.lineup |
|
| Checkins | account.checkins |
|
| Files | Vaults | account.vaults |
| Documents | account.documents |
|
| Uploads | account.uploads |
|
| Attachments | account.attachments |
|
| Card Tables | CardTables | account.card_tables |
| Cards | account.cards |
|
| CardColumns | account.card_columns |
|
| CardSteps | account.card_steps |
|
| Wormholes | account.wormholes |
|
| Client Portal | ClientApprovals | account.client_approvals |
| ClientCorrespondences | account.client_correspondences |
|
| ClientReplies | account.client_replies |
|
| ClientVisibility | account.client_visibility |
|
| Automation | Webhooks | account.webhooks |
| Subscriptions | account.subscriptions |
|
| Events | account.events |
|
| Automation | account.automation |
|
| Boosts | account.boosts |
|
| Reporting | Search | account.search |
| Reports | account.reports |
|
| Timesheets | account.timesheets |
|
| Recordings | account.recordings |
|
| Forwards | account.forwards |
The authorization service is on the top-level Client:
auth = client.authorization.get()All service methods use keyword-only arguments:
# All parameters after * are keyword-only
todo = account.todos.get(todo_id=123)
project = account.projects.create(name="My Project", description="A new project")
todos = account.todos.list(todolist_id=456, status="active")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.
# Sync
result = account.uploads.download(upload_id=1069479400)
with open("uploaded.bin", "wb") as f:
f.write(result.body)
# Async
result = await account.uploads.download(upload_id=1069479400)For any authenticated download URL (e.g. a download_url you already
have in hand), use AccountClient.download_url /
AsyncAccountClient.download_url:
result = account.download_url(url) # sync
result = await account.download_url(url) # asyncPaginated methods return a ListResult, which is a list subclass with a .meta attribute:
projects = account.projects.list()
# ListResult is a list - iterate directly
for project in projects:
print(project["name"])
# Access pagination metadata
print(projects.meta.total_count) # total items across all pages
print(projects.meta.truncated) # True if max_pages was reached
# Standard list operations work
print(len(projects))
first = projects[0]
sliced = projects[:5]Pagination is automatic. The SDK follows Link headers and collects all pages up to config.max_pages (default: 10,000).
from basecamp import Client, NotFoundError, RateLimitError, AuthError, BasecampError
client = Client(access_token="...")
account = client.for_account("12345")
try:
project = account.projects.get(project_id=999)
except NotFoundError as e:
print(f"Not found: {e}")
print(f"HTTP status: {e.http_status}")
print(f"Request ID: {e.request_id}")
except RateLimitError as e:
print(f"Rate limited, retry after: {e.retry_after}s")
except AuthError as e:
print(f"Authentication failed: {e.hint}")
except BasecampError as e:
print(f"API error [{e.code}]: {e}")All exceptions inherit from BasecampError:
| Exception | ErrorCode value |
HTTP Status | Retryable |
|---|---|---|---|
UsageError |
usage |
- | No |
NotFoundError |
not_found |
404 | No |
AuthError |
auth_required |
401 | No |
ForbiddenError |
forbidden |
403 | No |
RateLimitError |
rate_limit |
429 | Yes |
NetworkError |
network |
- | Yes |
ApiError |
api_error |
5xx, other | Yes for 500/502/503/504; No otherwise |
AmbiguousError |
ambiguous |
- | No |
ValidationError |
validation |
400, 422 | No |
Every BasecampError provides:
code-ErrorCodeenum valuehint- Human-readable suggestionhttp_status- HTTP status code (if applicable)retryable- Whether the error is safe to retry. This is a classification hint for your own code, not a prediction of transport behavior: for Basecamp API operations, what actually gets retried is the operation's declared status set (see Retry Behavior), so a 500 reportsretryable=Truebut is not retriedretry_after- Seconds to wait before retry (for rate limits)request_id- Server request ID (if available)exit_code- CLI-friendly exit code (ExitCodeenum)
The SDK automatically retries failed requests with exponential backoff:
- Which statuses - For Basecamp API operations, only the statuses that operation declares retryable. Every operation declares
429, 503, and the declared set is exhaustive: a status outside it — including 500, 502, and 504 — is surfaced to you on the first attempt rather than retried - GET requests - Retried on the declared statuses above, plus
NetworkError, which carries no HTTP status and so is governed by its error classification instead - Idempotent mutations - Operations marked idempotent in the OpenAPI metadata also retry through the same path
- Non-idempotent mutations - NOT retried to prevent duplicate operations
- 401 responses - Token refresh attempted, then single retry for all methods (regardless of idempotency)
- Backoff - Exponential with jitter (
base_delay * 2^(attempt-1) + random() * max_jitter) - Retry-After - Respected for 429 responses (overrides calculated backoff)
- Max retries -
min(config.max_retries, the operation's declared maximum).config.max_retriesis a total attempt count including the initial request (default: 3 attempts;0means a single request with no retry). An operation declaring a lower maximum wins — the declared value can only lower the cap, never raise it client.authorization.get()- The Launchpad authorization endpoint is not a Basecamp API operation, so it carries no declared policy and neither rule above applies to it. It retries whateverretryablereports — including 500, 502 and 504 — bounded only byconfig.max_retries
from basecamp import Client
from basecamp.hooks import console_hooks
client = Client(access_token="...", hooks=console_hooks())
# Logs all operations and requests to stderrSubclass BasecampHooks and override the methods you need:
from basecamp import Client
from basecamp.hooks import BasecampHooks, OperationInfo, OperationResult, RequestInfo, RequestResult
class MyHooks(BasecampHooks):
def on_operation_start(self, info: OperationInfo):
print(f"-> {info.service}.{info.operation}")
def on_operation_end(self, info: OperationInfo, result: OperationResult):
status = "ok" if result.error is None else "error"
print(f"<- {info.service}.{info.operation} {status} ({result.duration_ms}ms)")
def on_request_start(self, info: RequestInfo):
print(f" {info.method} {info.url} (attempt {info.attempt})")
def on_request_end(self, info: RequestInfo, result: RequestResult):
print(f" {result.status_code} ({result.duration:.3f}s)")
def on_retry(self, info: RequestInfo, attempt: int, error: BaseException, delay: float):
print(f" retry {attempt} in {delay:.1f}s: {error}")
def on_paginate(self, url: str, page: int):
print(f" page {page}: {url}")
client = Client(access_token="...", hooks=MyHooks())from basecamp.hooks import chain_hooks, console_hooks
combined = chain_hooks(console_hooks(), MyHooks())
client = Client(access_token="...", hooks=combined)chain_hooks composes multiple hooks. on_end callbacks fire in reverse order (LIFO).
Hook exceptions are caught and logged to stderr. A failing hook never interrupts SDK operations.
from basecamp.webhooks import WebhookReceiver
receiver = WebhookReceiver(secret="your-webhook-secret")
def handle_todos(event):
print(f"Todo event: {event['kind']}")
def handle_message(event):
print(f"New message: {event['recording']['title']}")
def handle_all(event):
print(f"Event: {event['kind']}")
receiver.on("todo_*", handle_todos)
receiver.on("message_created", handle_message)
receiver.on_any(handle_all)
# In your web framework handler:
result = receiver.handle_request(
raw_body=request.body,
headers=dict(request.headers),
)from basecamp.webhooks import verify_signature, compute_signature
# Verify a webhook signature (returns bool)
if not verify_signature(
request.body,
"your-webhook-secret",
request.headers["X-Basecamp-Signature"],
):
raise ValueError("Invalid webhook signature")
# Compute a signature
sig = compute_signature(request.body, "your-webhook-secret")def log_events(event, next_fn):
print(f"Processing: {event['kind']}")
return next_fn()
receiver.use(log_events)The receiver automatically deduplicates events by event["id"] using an LRU window (default: 1,000 events). Configure with dedup_window_size:
receiver = WebhookReceiver(secret="...", dedup_window_size=5000)Every service method has a sync and async variant. The async client mirrors the sync API:
from basecamp import AsyncClient
async with AsyncClient(access_token="...") as client:
account = client.for_account("12345")
# All service methods are awaitable
projects = await account.projects.list()
todo = await account.todos.get(todo_id=123)
# Downloads are async too
result = await account.download_url("https://...")Use AsyncClient with async with for automatic cleanup, or call await client.close() manually.
Download files from Basecamp with authentication and redirect handling:
# Sync
result = account.download_url("https://3.basecampapi.com/.../download/file.pdf")
print(result.filename) # "file.pdf"
print(result.content_type) # "application/pdf"
print(result.content_length) # 12345
with open(result.filename, "wb") as f:
f.write(result.body)
# Async
result = await account.download_url("https://...")Downloads resolve signed URLs with an authenticated request, then fetch file content via a second unauthenticated request so credentials are never sent to the signed URL.
# Install dependencies (from repo root)
cd python && uv sync && cd ..
# Run all checks (tests, types, lint, format, drift)
make py-check
# Run tests only
make py-test
# Type checking
make py-typecheck
# Regenerate services from OpenAPI spec
make py-generate
# Check for service drift
make py-check-driftMIT