Basecamp Ruby SDK
Official Ruby SDK for the Basecamp API.
Requirements
- Ruby 3.2+
- Faraday HTTP client
Installation
Add to your Gemfile:
gem "basecamp-sdk"
Or install directly:
gem install basecamp-sdk
Quick Start
require "basecamp"
# Create client with access token
client = Basecamp.client(access_token: ENV["BASECAMP_TOKEN"])
# Scope to an account
account = client.for_account(ENV["BASECAMP_ACCOUNT_ID"])
# List projects
account.projects.list.each do |project|
puts "#{project['id']}: #{project['name']}"
end
# Get a specific project
project = account.projects.get(project_id: 12345)
# Create a todo
todo = account.todos.create(
todolist_id: 67890,
content: "Review PR",
due_on: "2024-12-31"
)
Configuration
Basic Configuration
config = Basecamp::Config.new(
base_url: "https://3.basecampapi.com", # Default
timeout: 30, # Request timeout in seconds
max_retries: 3, # Total request attempts for GET requests (including the initial request)
base_delay: 1.0, # Base delay for exponential backoff
max_pages: 10_000 # Max pages for pagination
)
token_provider = Basecamp::StaticTokenProvider.new(ENV["BASECAMP_TOKEN"])
client = Basecamp::Client.new(config: config, token_provider: token_provider)
Configuration Options
| Option | Default | Description |
|---|---|---|
base_url |
https://3.basecampapi.com |
Basecamp API base URL |
timeout |
30 |
HTTP request timeout (seconds) |
max_retries |
3 |
Total request attempts for GET requests (including the initial request) |
base_delay |
1.0 |
Base delay for exponential backoff (seconds) |
max_jitter |
0.1 |
Maximum random jitter added to delays |
max_pages |
10_000 |
Maximum pages to fetch during pagination |
OAuth Authentication
Token Providers
The SDK supports multiple authentication patterns:
# Static token (simplest)
token_provider = Basecamp::StaticTokenProvider.new("your-access-token")
# OAuth with refresh
token_provider = Basecamp::OauthTokenProvider.new(
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: Time.now + 3600,
client_id: ENV["BASECAMP_CLIENT_ID"],
client_secret: ENV["BASECAMP_CLIENT_SECRET"]
)
OAuth Flow Helpers
# 1. Discover OAuth configuration
config = Basecamp::Oauth.discover_launchpad
# 2. Build authorization URL (redirect user here)
auth_url = "#{config.}?" + URI.encode_www_form(
type: "web_server",
client_id: ENV["BASECAMP_CLIENT_ID"],
redirect_uri: "https://myapp.com/callback"
)
# 3. Exchange code for tokens (in callback handler)
token = Basecamp::Oauth.exchange_code(
token_endpoint: config.token_endpoint,
code: params[:code],
redirect_uri: "https://myapp.com/callback",
client_id: ENV["BASECAMP_CLIENT_ID"],
client_secret: ENV["BASECAMP_CLIENT_SECRET"],
use_legacy_format: true # Required for Launchpad
)
# 4. Use the token
client = Basecamp.client(access_token: token.access_token)
# 5. Refresh when needed
if token.expired?
token = Basecamp::Oauth.refresh_token(
token_endpoint: config.token_endpoint,
refresh_token: token.refresh_token,
use_legacy_format: true
)
end
Device Authorization Grant (RFC 8628)
For input-constrained clients (CLIs, TVs) that can't host a redirect URI, the
device flow trades a redirect for a user-entered code. Basecamp pre-registers the
public basecamp-cli client (token_endpoint_auth_method: none, no secret); an
omitted scope defaults to read — prefer pinning it explicitly with
scope: "read". Pass bare origins everywhere (no trailing slash): binding is
code-point exact — a trailing-slash expected_issuer raises a hard
expected_issuer_unavailable, while a trailing-slash resource origin breaks
the hop-1 binding and silently soft-falls back to Launchpad.
# The device grant runs against an ALREADY-SELECTED config whose issuer advertises
# a device_authorization_endpoint. Resolve one with resource-first discovery —
# NOT discover_launchpad: Launchpad advertises no device endpoint, so the
# capability guard would reject it.
result = Basecamp::Oauth.discover_from_resource("https://3.basecampapi.com")
raise "device login is not available for this resource" unless result.selected?
config = result.config
# One call runs the whole grant against the SELECTED config: it guards
# capability, requests a device/user code, shows it via the display hook, then
# polls the token endpoint until the user approves.
token = Basecamp::Oauth.perform_device_login(
config: config,
client_id: "basecamp-cli",
display: ->(auth) do
puts "Visit #{auth.verification_uri} and enter code: #{auth.user_code}"
puts "Or open: #{auth.verification_uri_complete}" if auth.verification_uri_complete
end
)
client = Basecamp.client(access_token: token.access_token)
# BC5 device logins as basecamp-cli mint MULTI-ACCOUNT refresh tokens: the
# token carries an RFC 8707 resource indicator (token.resource,
# "urn:bc:account:<id>"), and refreshing without echoing it is rejected
# (400 invalid_request). Persist token.resource alongside the tokens and
# echo it on refresh:
# A device-token response MAY omit refresh_token — guard it: without one,
# refreshing is impossible and the user must re-run the device login.
if token.expired? && token.refresh_token
fresh = Basecamp::Oauth.refresh_token(
token_endpoint: config.token_endpoint,
refresh_token: token.refresh_token,
client_id: "basecamp-cli", # public client — no secret
resource: token.resource
)
# A refresh response MAY omit resource (the binding is unchanged) — persist
# `fresh.resource || token.resource` so the next refresh still echoes it.
end
The capability guard requires BOTH config.device_authorization_endpoint AND the
exact grant-type URN urn:ietf:params:oauth:grant-type:device_code in
config.grant_types_supported — servers advertise the full URN, so checking for
a bare device_code entry would wrongly conclude the flow is unavailable;
otherwise it raises
Basecamp::Oauth::DeviceFlowError with reason: :unavailable before any request
is issued. The two lower-level steps are also exposed directly:
auth = Basecamp::Oauth.(
device_authorization_endpoint: config.,
client_id: "basecamp-cli"
)
token = Basecamp::Oauth.poll_device_token(
token_endpoint: config.token_endpoint,
client_id: "basecamp-cli",
device_code: auth.device_code,
interval: auth.interval,
expires_in: auth.expires_in
)
The polling loop waits at least interval seconds between polls against a
monotonic deadline, sustains a slow_down bump (+5s for every later poll),
and backs off exponentially on connection timeouts. A terminal outcome raises
DeviceFlowError carrying a reason whose parent type is derived from it:
reason |
type |
Meaning |
|---|---|---|
:access_denied |
auth |
The user declined the request |
:expired |
auth |
The code expired before approval |
:transport |
network (retryable) |
A network failure ended the flow |
:unavailable |
validation |
The config can't do device flow |
:cancelled |
usage |
The caller cancelled the flow |
poll_device_token and perform_device_login accept injectable clock,
sleeper, and cancelled callables — pass a cancelled probe (e.g. one that
checks a signal set by SIGINT) to abort a pending login cleanly.
Resource-First Discovery (RFC 9728 + RFC 8414)
BC5's Authorization Server (AS) metadata lives only at the canonical issuer (the web host), so discovery starts from the resource (the API host) rather than probing the API host for AS metadata. Three composable operations are provided:
# RFC 8414 — AS metadata for a known issuer, bound to the requested issuer by
# code-point. token_endpoint is required; authorization_endpoint is OPTIONAL
# (device-only servers omit it) — authorization-code consumers must assert it.
config = Basecamp::Oauth.discover("https://launchpad.37signals.com")
# RFC 9728 — protected-resource metadata for a resource origin. resource is
# bound by code-point; authorization_servers preserves absent (nil) vs [].
resource = Basecamp::Oauth.discover_protected_resource("https://3.basecampapi.com")
# Orchestrator — resource-first selection + stage-sensitive fallback.
result = Basecamp::Oauth.discover_from_resource(
"https://3.basecampapi.com",
expected_issuer: nil # optional: authoritative issuer selection
)
if result.selected?
config = result.config # bound AS config for the selected issuer
else
# Only two SOFT reasons ever yield a fallback (→ Launchpad):
# "resource_discovery_failed" | "no_as_advertised"
result.reason
end
discover_from_resource returns a DiscoveryResult that is either selected
or a soft fallback. Every hard failure — an ambiguous advertised set, an
unavailable expected_issuer, an invalid advertised issuer origin, an AS-metadata
fetch failure, or an issuer-binding mismatch after a BC5 issuer was selected —
raises Basecamp::Oauth::DiscoverySelectionError (carrying a reason). A hard
failure is never silently converted into a Launchpad request.
All discovery fetches are SSRF-hardened: origins are validated against the
origin-root profile (HTTPS-only, localhost exempt) with Ruby's URI parser before
any socket opens, redirects are not followed, timeouts are bounded, non-2xx maps
to api_error, and the response body is read under a genuine streaming cap that
aborts before an oversized body is buffered.
Services
The SDK provides 46 account-scoped services. The table below covers the common ones; see lib/basecamp/generated/services/ for the authoritative, complete set:
| Service | Description |
|---|---|
projects |
Project management |
todos |
Todo items |
todolists |
Todo lists |
todosets |
Todo set containers |
todolist_groups |
Todolist grouping/folders |
people |
People/users |
comments |
Comments on recordings |
messages |
Message posts |
message_boards |
Message boards |
message_types |
Message categories |
campfires |
Chat rooms |
schedules |
Calendar schedules |
documents |
Documents |
vaults |
File folders |
uploads |
File uploads |
attachments |
Binary attachments |
recordings |
Generic recordings |
webhooks |
Webhook subscriptions |
subscriptions |
Notification subscriptions |
templates |
Project templates |
events |
Activity events |
checkins |
Automatic check-ins |
forwards |
Email forwards |
cards |
Card table cards |
card_tables |
Card tables (kanban) |
card_columns |
Card table columns |
card_steps |
Card workflow steps |
wormholes |
Card table wormholes (cross-project moves) |
lineup |
Card lineup view |
tools |
Project dock tools |
search |
Full-text search |
reports |
Activity reports |
timeline |
Activity timeline |
timesheet |
Time tracking reports |
client_approvals |
Client approval workflows |
client_correspondences |
Client communications |
client_replies |
Client replies |
authorization |
Auth info |
Pagination
All list methods return lazy Enumerators that automatically handle pagination:
# Automatically fetches all pages
account.projects.list.each do |project|
puts project["name"]
end
# Take only what you need
first_10 = account.todos.list(todolist_id: 456).take(10)
# Convert to array (fetches all pages)
all_projects = account.projects.list.to_a
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.
result = account.uploads.download(upload_id: 1069479400)
File.binwrite("uploaded.bin", result.body)
# result.content_type, result.content_length, result.filename are also available
For any authenticated download URL (e.g. a download_url you already
have in hand), use AccountClient#download_url:
result = account.download_url(url)
Retry Behavior
Only plain GET requests retry — automatically, with exponential backoff. Mutation operations (POST, PUT, DELETE) do not retry to prevent data duplication, and the raw upload and download paths skip the retry loop entirely (the upload path is strictly one request; the download hop keeps only the one-shot 401 replay below).
- Which errors: Retry keys off the error's
retryable?classification, not a declared status list — 429 (rate limit), 500, 502, 503, 504, and any other 5xx all retry, as doesNetworkError(connection failures, including DNS and connect-phase timeouts). Read timeouts are the exception: Faraday surfaces them as a status-lessApiErrorwithretryable? == false, so a GET that times out mid-response fails on the first attempt. 400, 401, 403, 404, and 422 never retry. max_retries: Total request attempts for GET requests, including the initial request — the default3means one initial attempt plus two retries.max_retries: 0sends zero requests and raisesBasecamp::ApiError("Request failed after 0 attempts").- Backoff: Exponential with jitter —
base_delay * 2^(attempt - 1) + rand * max_jitter— uncapped, bounded in practice by the attempt budget. - Rate limits: A 429's
Retry-Afterheader overrides the calculated backoff. Only 429 carries it: 5xx and network errors always use the exponential backoff. - 401 responses: With a refresh-capable token provider, the SDK refreshes the token and replays the request once — for all methods, including mutations — outside the
max_retriesbudget. A second 401 is surfaced. The raw upload path has no 401 replay. - Per-operation metadata: The retry policy operations declare (
retry_onstatuses, per-operationmax) is inert in Ruby — every API GET issued through the client, including the Launchpad authorization fetch, rides the same classification-based loop bounded byconfig.max_retriesalone. (The download flow's redirect hop and OAuth discovery use their own single-attempt transports.) retryable?: Unlike SDKs where the error classification is only a hint for your own code, in Ruby an error'sretryable?(andretry_after) is exactly what the transport acts on for GET requests.
Error Handling
begin
account.projects.get(project_id: 99999)
rescue Basecamp::NotFoundError => e
puts "Project not found: #{e.}"
rescue Basecamp::RateLimitError => e
puts "Rate limited, retry after: #{e.retry_after} seconds"
rescue Basecamp::AuthError => e
puts "Authentication failed: #{e.}"
rescue Basecamp::ApiError => e
puts "API error (#{e.http_status}): #{e.}"
end
Error Types
| Error | Description |
|---|---|
ApiError |
Base error class for all API errors |
AuthError |
Authentication failures (401) |
ForbiddenError |
Access denied (403) |
NotFoundError |
Resource not found (404) |
ValidationError |
Invalid request data (400, 422) |
RateLimitError |
Rate limit exceeded (429) |
NetworkError |
Connection failures |
Observability Hooks
Monitor SDK behavior with hooks:
class MyHooks
include Basecamp::Hooks
def on_request_start(info)
puts "Starting #{info.method} #{info.url}"
end
def on_request_end(info, result)
puts "Completed in #{result.duration}s with status #{result.status_code}"
end
def on_retry(info, attempt, error, delay)
puts "Retrying attempt #{attempt} after #{delay}s"
end
def on_paginate(url, page)
puts "Fetching page #{page}"
end
end
client = Basecamp::Client.new(
config: config,
token_provider: token_provider,
hooks: MyHooks.new
)
Environment Variables
| Variable | Description |
|---|---|
BASECAMP_TOKEN |
OAuth access token |
BASECAMP_ACCOUNT_ID |
Account ID |
BASECAMP_BASE_URL |
API base URL (default: https://3.basecampapi.com) |
BASECAMP_TIMEOUT |
Request timeout in seconds (default: 30) |
BASECAMP_MAX_RETRIES |
Total request attempts for GET requests, including the initial request (default: 3) |
Development
# Install dependencies
bundle install
# Run tests
bundle exec rake test
# Run linter
bundle exec rubocop
# Generate types from OpenAPI
ruby scripts/generate-types.rb
License
MIT