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(
project_id: 12345,
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, # Max retry attempts for GET requests
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 |
Maximum retry attempts for GET requests |
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
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 45 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
GET requests automatically retry on transient failures with exponential backoff:
- Retryable errors: 429 (rate limit), 502, 503, 504 (gateway errors)
- Backoff: Exponential with jitter (1s, 2s, 4s...)
- Rate limits: Respects
Retry-Afterheader
Mutation operations (POST, PUT, DELETE) do not retry to prevent data duplication.
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) |
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