Official Python SDK for the Basecamp API.
Upgrading to v0.13.0? Read MIGRATING.md before you bump the version — Python carries eight breaks with no signal at all, and no compile step to catch anything else.
- 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-sdkEvery 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:
- Choose the grant that matches how your code runs:
| Your integration | Grant | Who refreshes the token |
|---|---|---|
| already holds a token you obtained elsewhere | static token — Static Token |
you do |
| can receive a browser redirect (web app, or a local callback server) | authorization code + PKCE — PKCE and Authorization URL |
OAuthTokenProvider |
| has no browser, but a person can approve on another device (CLI, headless server, TV) | device flow — Device Authorization Grant |
basecamp.oauth.exchange.refresh_token, echoing the token's resource — not OAuthTokenProvider |
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.
- 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-cliclient, 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.
Client(access_token=...) wraps the string in a StaticTokenProvider, which never refreshes — once the token expires every call fails with 401 until you supply a new one. Use it to get a first successful call, then move to a refreshing path before you ship — OAuthTokenProvider for an authorization-code token from Launchpad, or, for a device-flow token, basecamp.oauth.exchange.refresh_token echoing the stored resource. Do not hand a device-flow token to OAuthTokenProvider: it refreshes only against Launchpad and sends no resource, so it fails at the first expiry.
Every API path is scoped to an account — https://3.basecampapi.com/{accountId}/… — so for_account needs that number before your first call. One token can reach several accounts, so ask the token which. authorization hangs off the top-level Client because the endpoint lives on the authorization server rather than the Basecamp API, and so takes no account context. It is pinned to Launchpad and authorization.get() accepts no override, which is right for a Launchpad-issued token; a device-flow token is issued by the discovered BC5 server, so fetch /authorization.json from that issuer yourself:
import os
from basecamp import Client
client = Client(access_token=os.environ["BASECAMP_TOKEN"])
info = client.authorization.get()
# "bc3" is Basecamp; the same response also carries "hey" and other products,
# and they are not ordered — filter before you pick, or you may scope the
# client to a HEY account.
basecamp_accounts = [a for a in info["accounts"] if a["product"] == "bc3"]
for a in basecamp_accounts:
print(f"{a['id']}: {a['name']}")
account = client.for_account(basecamp_accounts[0]["id"])The response is a plain dict of parsed JSON. info["expires_at"] tells you how long the token has left, which is the quickest way to confirm a static token has not lapsed. On AsyncClient, the same call is await client.authorization.get().
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']}")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())Nothing here is read automatically. These apply only when you call Config.from_env(); a plain Config() reads no environment at all. They are also the only three environment variables the SDK reads anywhere — from_env does not consult base_delay, max_jitter, or max_pages, so those keep their defaults unless you pass them.
| 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 |
BASECAMP_TOKEN and BASECAMP_ACCOUNT_ID appear in the examples above only because the caller reads them and passes the values in; the SDK never looks them up. Pass the token to Client(access_token=...) and the account ID to for_account.
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 items beyond those returned were available
# 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).
Every paginated method also accepts a max_items keyword to cap how many items are collected. Collection stops as soon as the cap is met, without fetching further pages. Zero or negative values disable the cap, as in the other SDKs:
recent = account.projects.list(max_items=50)
print(recent.meta.truncated) # True only if more items were availablemeta.truncated is True only when items beyond those returned were available — items were dropped by max_items, or the last-fetched page still advertised a next page when collection stopped (at max_items or the max_pages safety cap). When it is False, the result is definitely complete.
A positive page selects exactly that page: one request, that page's items,
no link-following.
page_3 = account.projects.list(page=3)
print(page_3.meta.truncated) # True when a further page existedOmit page (or pass 0) to auto-paginate the whole collection. max_items
still trims a pinned page.
All six SDKs share these semantics — one request, that page only, no link-following. See SPEC section 8.
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)
Basecamp rejects invalid writes with a body keyed by field. The SDK folds those
messages into the message and ValidationError keeps the raw map in
field_errors, so you can drive a form without re-parsing the message:
from basecamp import ValidationError
try:
account.calendars.update_calendar(calendar_id=calendar_id, calendar={"color": "chartreuse"})
except ValidationError as e:
print(e) # "color: is not a valid color"
for field, messages in (e.field_errors or {}).items():
for message in messages:
print(f" {field} {message}")field_errors is None for every other error shape, and its messages are the
raw ones — the message is capped at 500 bytes, the map is not.
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