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
 = client.(ENV["BASECAMP_ACCOUNT_ID"])

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

# Get a specific project
project = .projects.get(project_id: 12345)

# Create a todo
todo = .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.authorization_endpoint}?" + 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.(
  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.request_device_authorization(
  device_authorization_endpoint: config.device_authorization_endpoint,
  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
.projects.list.each do |project|
  puts project["name"]
end

# Take only what you need
first_10 = .todos.list(todolist_id: 456).take(10)

# Convert to array (fetches all pages)
all_projects = .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 = .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 = .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 does NetworkError (connection failures, including DNS and connect-phase timeouts). Read timeouts are the exception: Faraday surfaces them as a status-less ApiError with retryable? == 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 default 3 means one initial attempt plus two retries. max_retries: 0 sends zero requests and raises Basecamp::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-After header 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_retries budget. A second 401 is surfaced. The raw upload path has no 401 replay.
  • Per-operation metadata: The retry policy operations declare (retry_on statuses, per-operation max) is inert in Ruby — every API GET issued through the client, including the Launchpad authorization fetch, rides the same classification-based loop bounded by config.max_retries alone. (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's retryable? (and retry_after) is exactly what the transport acts on for GET requests.

Error Handling

begin
  .projects.get(project_id: 99999)
rescue Basecamp::NotFoundError => e
  puts "Project not found: #{e.message}"
rescue Basecamp::RateLimitError => e
  puts "Rate limited, retry after: #{e.retry_after} seconds"
rescue Basecamp::AuthError => e
  puts "Authentication failed: #{e.message}"
rescue Basecamp::ApiError => e
  puts "API error (#{e.http_status}): #{e.message}"
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