Basecamp Go SDK

August 22, 2026 · View on GitHub

Go Reference Test Go Report Card

Official Go SDK for the Basecamp API.

Upgrading to v0.14.0? Read MIGRATING.md before you bump the version. The compiler catches the type changes — ListVersions elements became UploadVersion, and UpdateUploadRequest.Description became *string — but not the reroute: every 507 now reports limit_exceeded instead of a retryable api_error, so a case falling through to a default arm sends storage, project and webhook limits wherever that default goes. Coming from v0.12.0 or earlier, read v0.13.0's section too.

Features

  • Full coverage of 30+ Basecamp API services
  • OAuth 2.0 authentication with automatic token refresh
  • Static token authentication for simple integrations
  • ETag-based HTTP caching for efficient API usage (opt-in)
  • Automatic retry with exponential backoff
  • Pagination handling with GetAll()
  • Structured errors with CLI-friendly exit codes
  • Secure credential storage (system keyring with file fallback)

Installation

go get github.com/basecamp/basecamp-sdk/go

Requires Go 1.26 or later.

Getting a token

Every 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:

  1. Choose the grant that matches how your code runs:
Your integrationGrantWho refreshes the token
already holds a token you obtained elsewherestatic tokenbelowyou do
can receive a browser redirect (web app, or a local callback server)authorization code + PKCEbelowAuthManager
has no browser, but a person can approve on another device (CLI, headless server, TV)device flowbelowAuthManager

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.

  1. 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-cli client, 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.

StaticTokenProvider hands back the string you gave it and nothing more — it never refreshes, so 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 AuthManager before you ship.

Finding your account ID

Every API path is scoped to an account — https://3.basecampapi.com/{accountId}/… — so ForAccount 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 defaults to Launchpad, which is right for a Launchpad-issued token; a device-flow token is issued by the discovered BC5 server and its authorization.json lives there, so pass that issuer as GetInfoOptions.Endpoint:

info, err := client.Authorization().GetInfo(context.Background(), &basecamp.GetInfoOptions{
    FilterProduct: "bc3", // Basecamp; the same response also carries "hey" and other products
    // Endpoint defaults to Launchpad, which is right for a Launchpad-issued
    // token. For a device-flow token, set it to the discovered issuer:
    //   Endpoint: issuer + "/authorization.json",
})
if err != nil {
    log.Fatal(err)
}
for _, a := range info.Accounts {
    fmt.Printf("%d: %s\n", a.ID, a.Name)
}

account := client.ForAccount(fmt.Sprint(info.Accounts[0].ID))

info.Expiry() tells you how long the token has left, which is the quickest way to confirm a static token has not lapsed. ok is false when the document states no expiry — both production issuers always state one today, so that branch is robustness for whatever GetInfoOptions.Endpoint points at:

if expiry, ok := info.Expiry(); ok && time.Until(expiry) < 0 {
    log.Fatal("token has lapsed")
}

Quick Start

Using a Static Token

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)

func main() {
    // Configure the client
    cfg := basecamp.DefaultConfig()

    // Use a static token
    token := &basecamp.StaticTokenProvider{
        Token: os.Getenv("BASECAMP_TOKEN"),
    }

    client := basecamp.NewClient(cfg, token)

    // Get account ID from environment (ForAccount validates it's numeric)
    accountID := os.Getenv("BASECAMP_ACCOUNT_ID")
    if accountID == "" {
        log.Fatal("BASECAMP_ACCOUNT_ID environment variable is required")
    }
    account := client.ForAccount(accountID)

    // List all projects
    result, err := account.Projects().List(context.Background(), nil)
    if err != nil {
        log.Fatal(err)
    }

    for _, p := range result.Projects {
        fmt.Printf("%d: %s\n", p.ID, p.Name)
    }
}

Using OAuth 2.0

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"

    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)

func main() {
    cfg := basecamp.DefaultConfig()

    // AuthManager handles token storage and refresh
    authMgr := basecamp.NewAuthManager(cfg, http.DefaultClient)
    client := basecamp.NewClient(cfg, authMgr)

    // Discover available accounts (account-agnostic operation)
    info, err := client.Authorization().GetInfo(context.Background(), nil)
    if err != nil {
        log.Fatal(err)
    }

    // Create an account-scoped client
    account := client.ForAccount(fmt.Sprint(info.Accounts[0].ID))

    // List active projects
    result, err := account.Projects().List(context.Background(), &basecamp.ProjectListOptions{
        Status: basecamp.ProjectStatusActive,
    })
    if err != nil {
        log.Fatal(err)
    }

    for _, p := range result.Projects {
        fmt.Printf("%s (%d)\n", p.Name, p.ID)
    }
}

OAuth discovery (resource-first)

BC5's authorization-server (AS) metadata lives only at its canonical issuer (the web host), so discovery starts from the resource (RFC 9728) and composes with AS metadata discovery (RFC 8414). The oauth package exposes three composable operations on *oauth.Discoverer:

import (
    "errors"
    "net/http"

    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/oauth"
)

d := oauth.NewDiscoverer(http.DefaultClient)

// RFC 8414 — authorization-server metadata, with issuer binding.
cfg, err := d.Discover(ctx, "https://launchpad.37signals.com")

// RFC 9728 — protected-resource metadata (hop 1).
meta, err := d.DiscoverProtectedResource(ctx, "https://3.basecampapi.com")

// Orchestrator — resource-first selection + stage-sensitive fallback.
result, err := d.DiscoverFromResource(ctx, "https://3.basecampapi.com")
if err != nil {
    // Hard failure — never fall back to Launchpad. Match with errors.Is:
    switch {
    case errors.Is(err, oauth.ErrAmbiguousIssuers):          // ≥2 non-Launchpad issuers, no expected issuer
    case errors.Is(err, oauth.ErrExpectedIssuerUnavailable): // expected issuer not advertised
    case errors.Is(err, oauth.ErrInvalidIssuerOrigin):       // advertised issuer refused: bad origin root, or blocked address
    case errors.Is(err, oauth.ErrASFetchFailed):             // committed issuer's AS metadata unavailable
    case errors.Is(err, oauth.ErrIssuerMismatch):            // committed issuer's metadata fails issuer binding
    }
    return err
}
if result.IsFallback() {
    // Soft fallback: result.FallbackReason is resource_discovery_failed or
    // no_as_advertised. Fall back to Launchpad (oauth.LaunchpadBaseURL).
} else {
    use(result.Config, result.Issuer) // selected BC5 authorization server
}

Pass oauth.WithExpectedIssuer(issuer) to select an issuer authoritatively instead of by the exclude-Launchpad heuristic; oauth.WithTimeout and oauth.WithMaxBodyBytes tune each fetch.

Notes:

  • Config.AuthorizationEndpoint is now *string (optional): device-only servers omit it, so authorization-code consumers must assert its presence before use. Config also carries DeviceAuthorizationEndpoint *string and GrantTypesSupported []string.

  • Every fetch is SSRF-hardened: origins are validated with net/url before any socket opens, HTTPS is required (localhost exempt), redirects are suppressed, timeouts are bounded, and bodies are read under a bounded cap. Non-2xx on either hop surfaces as an api_error.

  • The advertised-issuer hop additionally enforces an address policy. That hop is the one whose destination comes from a parsed response body, so URL syntax is not enough: net/url has no notion of what a host resolves to. DiscoverFromResource judges the literal address at connection time via oauth.DefaultIssuerPolicy(), so a syntactically valid issuer pointing at private, loopback, link-local, or other special-use space is refused before a socket opens — including legacy spellings like https://2130706433/, and including a name that resolves to blocked space only at dial time. (The https here is load-bearing: on the http spelling of that same host the origin-root profile refuses first, so it would demonstrate the pre-existing scheme gate rather than the address policy.) The refusal surfaces as ErrInvalidIssuerOrigin; it also matches errors.Is(err, surfguard.ErrBlocked) and wraps a *surfguard.Violation if you need to tell the two causes apart. Hop 1 and Discover are unaffected — their destinations are operator-configured.

    A deployment whose issuer legitimately sits off the public internet re-admits exactly the space it needs rather than switching the policy off. Mind surfguard's precedence when you do: Allow re-admits space the default deny tables refuse but not space the IANASpecialUse tables refuse, and those cover all of RFC 1918.

    // On-premises issuer in private space — build without IANASpecialUse.
    oauth.NewDiscoverer(c, oauth.WithIssuerPolicy(
        surfguard.Policy{}.AllowAllPorts().Allow(netip.MustParsePrefix("10.4.0.0/16"))))
    
    // Local development — AllowLoopback does pierce those tables.
    oauth.NewDiscoverer(c, oauth.WithIssuerPolicy(
        oauth.DefaultIssuerPolicy().AllowLoopback()))
    

    oauth.WithIssuerHTTPClient carries the hop on your own client, which a consumer egressing through a proxy needs since surfguard's transport sets Proxy: nil by construction. oauth.WithoutIssuerPolicy restores the pre-policy behavior outright.

  • The same policy governs where the credentials go next. The selected Config carries the token_endpoint and device_authorization_endpoint the issuer's metadata named, and nothing constrains those to the issuer's origin — so a public issuer that passes the policy could still steer the client_id, device_code, authorization code, client secret, or refresh token into private space. PerformDeviceLogin, RequestDeviceAuthorization, PollDeviceToken, and an Exchanger therefore judge the endpoint's address at dial time by default, on the same shared oauth.DefaultIssuerPolicy() client. A refusal is a non-retryable *basecamp.Error (api_error) that matches errors.Is(err, surfguard.ErrBlocked); in the poll loop it ends the flow on the first attempt rather than backing off. See the device-flow section below for the overrides.

OAuth device authorization grant (RFC 8628)

For CLIs and other input-constrained clients, the oauth package implements the RFC 8628 device authorization grant. PerformDeviceLogin accepts an already-selected *oauth.Config (from discovery), guards the device capability, shows the user a code, and polls the token endpoint until they approve. Each configured endpoint is required to use HTTPS (localhost exempt) and redirects are suppressed.

import (
    "errors"
    "fmt"
    "net/http"

    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/oauth"
)

// The device grant lives on BC5's authorization server, not Launchpad, so the
// config must be the selected first-party result of resource-first discovery. A
// Launchpad fallback advertises no device endpoint, and PerformDeviceLogin would
// return DeviceFlowError{Reason: DeviceFlowUnavailable}.
d := oauth.NewDiscoverer(http.DefaultClient)
result, err := d.DiscoverFromResource(ctx, "https://3.basecampapi.com")
if err != nil {
    return err // hard failure
}
if result.IsFallback() {
    // Launchpad fallback: no device endpoint. Surface it explicitly (or switch
    // to the authorization-code flow) — never return nil here, or the caller
    // treats the fallback as a successful device login with no token.
    return fmt.Errorf("device flow unavailable: fell back to Launchpad (%s)", result.FallbackReason)
}

token, err := oauth.PerformDeviceLogin(ctx, result.Config, "basecamp-cli",
    func(auth oauth.DeviceAuthorization) {
        fmt.Printf("Visit %s and enter code: %s\n", auth.VerificationURI, auth.UserCode)
    },
    // Optional: oauth.WithDeviceScope("read"), oauth.WithDeviceHTTPClient(hc).
)
if err != nil {
    var dfe *oauth.DeviceFlowError
    if errors.As(err, &dfe) {
        switch dfe.Reason {
        case oauth.DeviceFlowAccessDenied: // user declined  → auth_required
        case oauth.DeviceFlowExpired:      // code expired    → auth_required
        case oauth.DeviceFlowTransport:    // network failure → network (retryable)
        case oauth.DeviceFlowUnavailable:  // AS lacks device flow → validation
        case oauth.DeviceFlowCancelled:    // ctx cancelled   → wraps ctx.Err()
        }
    }
    return err
}
use(token) // *oauth.Token

The capability guard requires BOTH cfg.DeviceAuthorizationEndpoint != nil AND cfg.GrantTypesSupported advertising oauth.DeviceCodeGrantType; otherwise it returns DeviceFlowError{Reason: DeviceFlowUnavailable} before any request.

DeviceFlowError derives its parent error category from Reason (call .Code() for the basecamp taxonomy code, .Retryable() for retryability). A cancelled flow wraps the context error, so errors.Is(err, context.Canceled) matches.

The two lower-level steps are exported for callers that drive the flow directly:

auth, err := oauth.RequestDeviceAuthorization(ctx, deviceAuthEndpoint, "basecamp-cli")
if err != nil {
    return err
}
token, err := oauth.PollDeviceToken(ctx, tokenEndpoint, "basecamp-cli",
    auth.DeviceCode, auth.Interval, auth.ExpiresIn)

PollDeviceToken runs the §3.5 loop: it waits at least interval seconds between polls, enforces a monotonic expiry deadline, sustains slow_down bumps (+5s), backs off exponentially on connection timeouts, and honors context cancellation. The clock (oauth.WithDeviceClock) and inter-poll wait (oauth.WithDeviceSleep) are injectable for deterministic tests; scope is omitted from the authorization request unless set with oauth.WithDeviceScope, so the server applies its default (read) — prefer pinning it explicitly.

Both endpoints are address-policed by default, and so is the token endpoint an oauth.Exchanger posts to: the flow cannot tell a discovered endpoint from a hand-configured one, so every request on the default client is judged by oauth.DefaultIssuerPolicy() at dial time. The precedence is the same on every surface of the package — a client you hand in is yours, enforcement included; otherwise your policy; otherwise the default:

// Local development against an AS on loopback — AllowLoopback pierces the
// IANASpecialUse tables; plain Allow does not (see the discovery notes above).
devPolicy := oauth.WithDevicePolicy(oauth.DefaultIssuerPolicy().AllowLoopback())
token, err := oauth.PerformDeviceLogin(ctx, cfg, "basecamp-cli", display, devPolicy)
ex := oauth.NewExchanger(nil, oauth.WithExchangerPolicy(oauth.DefaultIssuerPolicy().AllowLoopback()))

// Your own client carries the requests instead, policy and all. To keep the
// policy on a custom transport, build the transport from it:
hc := &http.Client{Transport: oauth.DefaultIssuerPolicy().RoundTripper()}
oauth.PerformDeviceLogin(ctx, cfg, "basecamp-cli", display, oauth.WithDeviceHTTPClient(hc))
oauth.NewExchanger(hc)

// http.DefaultClient restores the pre-policy behavior outright.
oauth.NewExchanger(http.DefaultClient)

WithDevicePolicy builds its transport once, when the option is constructed, so build it once and reuse it; an Exchanger given WithExchangerPolicy owns its transport the same way. Neither has a Close.

Persisting device-login credentials (RFC 8707 resource echo)

BC5 device logins as basecamp-cli mint multi-account refresh tokens: the token response carries an RFC 8707 resource indicator (urn:bc:account:<id>, on token.Resource), and every refresh of a multi-account token MUST send it back or the server rejects the refresh (400 invalid_request). AuthManager echoes the stored resource (and submits the stored client_id — BC5 public clients send no secret) automatically, and preserves the stored value when a refresh response omits it.

The oauth helpers return an *oauth.Token; they never write the root package's Credentials. After a device login (or code exchange) the caller bridges the two — saving the client id it used and the token's resource so later refreshes can echo them:

import (
    "net/http"

    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)

// sdkCfg is the SDK *basecamp.Config (the device-login example above binds
// its discovery result to result.Config, an *oauth.Config — a different type).
sdkCfg := &basecamp.Config{BaseURL: "https://3.basecampapi.com"}
authMgr := basecamp.NewAuthManager(sdkCfg, http.DefaultClient)
creds := &basecamp.Credentials{
    AccessToken:   token.AccessToken,
    RefreshToken:  token.RefreshToken,
    Scope:         token.Scope,
    TokenEndpoint: result.Config.TokenEndpoint,
    ClientID:      "basecamp-cli",       // the id the login used
    Resource:      token.Resource,       // the account binding to echo on refresh
}
// A token response may omit expires_in; a zero ExpiresAt means no known
// expiry (never force-refreshed) — storing zero-time.Unix()'s negative value
// would instead mark the fresh token expired.
if !token.ExpiresAt.IsZero() {
    creds.ExpiresAt = token.ExpiresAt.Unix()
}
err = authMgr.Store().Save(basecamp.NormalizeBaseURL(sdkCfg.BaseURL), creds)

Pass the discovered base origin without a trailing slash everywhere a base URL or issuer is expected: binding is code-point exact — a trailing-slash WithExpectedIssuer value fails the advertised-member lookup as a hard ErrExpectedIssuerUnavailable, while a trailing-slash resource origin breaks the hop-1 binding and silently soft-falls back to Launchpad.

Configuration

Environment Variables

Nothing here is read automatically. The five Config variables apply only when you call cfg.LoadConfigFromEnv(). BASECAMP_TOKEN is read by AuthManager.AccessToken and AuthManager.IsAuthenticated; BASECAMP_NO_KEYRING is read one level down, by NewCredentialStore. (Separately, DefaultConfig consults XDG_CACHE_HOME to site the cache directory, and globalConfigDir consults XDG_CONFIG_HOME to site the config directory.)

VariableRead byDescription
BASECAMP_BASE_URLcfg.LoadConfigFromEnv()API base URL (default: https://3.basecampapi.com)
BASECAMP_PROJECT_IDcfg.LoadConfigFromEnv()Default project ID
BASECAMP_TODOLIST_IDcfg.LoadConfigFromEnv()Default todolist ID
BASECAMP_CACHE_DIRcfg.LoadConfigFromEnv()Cache directory path (default: ~/.cache/basecamp)
BASECAMP_CACHE_ENABLEDcfg.LoadConfigFromEnv()Enable HTTP caching (default: false)
BASECAMP_TOKENAuthManagerAccess token. Consulted first, ahead of any stored OAuth credentials, so setting it short-circuits the OAuth flow — handy for scripts and CI
BASECAMP_NO_KEYRINGNewCredentialStoreStore credentials in a file instead of the system keyring. Read when the store is constructed, so NewAuthManager picks it up but NewAuthManagerWithStore does not — pass a store you built yourself and this variable has already been applied, or not, by that call

StaticTokenProvider does not read BASECAMP_TOKEN; it uses whatever string you put in its Token field. The Quick Start above reads the variable itself, at the call site.

Note: account ID is specified via client.ForAccount(accountID) rather than configuration. BASECAMP_ACCOUNT_ID is a convention used by this repository's examples and tooling — no SDK reads it.

Programmatic Configuration

cfg := basecamp.DefaultConfig()
cfg.ProjectID = "67890"           // Optional default project
cfg.CacheEnabled = true           // Enable ETag caching
cfg.CacheDir = "/custom/cache"    // Custom cache location

// Or load from environment
cfg.LoadConfigFromEnv()

// Or load from JSON file
cfg, err := basecamp.LoadConfig("/path/to/config.json")

Optional Fields

Optional fields are pointers, so that "not addressed" stays distinguishable from a value. Nil omits the field; a non-nil pointer sends the value verbatim, including the zero value. basecamp.Ptr builds one for any type:

entry, err := account.Schedules().UpdateEntry(ctx, entryID, &basecamp.UpdateScheduleEntryRequest{
    Summary:        basecamp.Ptr("Kickoff, moved"),
    AllDay:         basecamp.Ptr(false),     // an explicit false, not "unset"
    ParticipantIDs: basecamp.Ptr([]int64{}), // an explicit empty list: remove everyone
    // Description stays nil, so the entry's description is left alone.
})

Reading one is the half that fails quietly: Go auto-dereferences a value-receiver method call, so hc.UpdatedAt.IsZero() compiles against a *time.Time and panics at run time on a chart that has never moved. Nil-check it, or let basecamp.Deref return the zero value for you:

hc, err := account.HillCharts().Get(ctx, todosetID)
if updated := basecamp.Deref(hc.UpdatedAt); !updated.IsZero() {
    fmt.Println("last moved", updated)
}

Collapsing absence to the zero value is only safe where the caller cannot tell the two apart. Where the difference carries meaning — a string the server really sent as empty versus a field it omitted — compare against nil instead.

API Coverage

Projects & Organization

ServiceMethods
Projects()List, Get, Create, Update, Trash, Archive, Unarchive
Templates()List, Get, CreateProject
Tools()Get, Create, Update, Delete, Enable, Disable, Reposition (dock tools)
People()List, Get, ListPingable, Me, ListProjectPeople

To-dos

ServiceMethods
Todos()List, Get, Create, Update, Edit, Replace, Complete, Uncomplete, Reposition
Todosets()Get
Todolists()List, Get, Create, Update, Edit, Replace, Trash, Reposition
TodolistGroups()List, Get, Create, Replace, Reposition

PUT on these resources is a full replace: BC3 rebuilds the record from the params it receives, so a field you omit is cleared. Update (overlay the fields you set) and Edit (read-modify-write closure) are merge-safe composites over that route — they GET first and resend the whole representation. Replace is the raw verbatim PUT. TodolistGroups() offers only Replace; use Todolists().Update/Edit for merge-safe group writes. That is not a workaround — BC3 has no group model, so TodolistGroup is a Go alias for Todolist (#544) and both surfaces address one polymorphic route through one projection. Tell the variants apart structurally: GroupsURL on a list, GroupPositionURL on a group. Never on Type, which reads "Todolist" for both.

Messages & Communication

ServiceMethods
Messages()List, Get, Create, Update, Trash
MessageBoards()Get
MessageTypes()List, Get, Create, Update, Destroy
Comments()List, Get, Create, Update, Trash
Campfires()List, Get, ListLines, GetLine, CreateLine, UpdateLine, DeleteLine, Chatbot CRUD
Forwards()List, Get

Scheduling

ServiceMethods
Schedules()Get, ListEntries, GetEntry, CreateEntry, UpdateEntry, TrashEntry, GetEntryOccurrence, UpdateSettings
Lineup()List, Get, Create, Update, Delete
Checkins()Get, List, ListQuestions, GetQuestion, ListAnswers, GetAnswer, UpdateAnswer

Files & Documents

ServiceMethods
Vaults()Get, List, Create, Update
Attachments()CreateUploadURL, Create

Card Tables (Kanban)

ServiceMethods
CardTables()Get, ListColumns, GetColumn
Cards()List, Get, Create, Update, Move
CardColumns()List, Get, Create, Update, Watch, Unwatch
CardSteps()List, Get
Wormholes()Create, Update, Delete
ServiceMethods
Timeline()Progress, ProjectTimeline, PersonProgress
Reports()AssignablePeople, AssignedTodos, OverdueTodos, UpcomingSchedule
Timesheet()MyEntries, ProjectEntries
Search()Search
Events()List, ListForRecording

Integrations

ServiceMethods
Webhooks()List, Get, Create, Update, Delete
Subscriptions()List, Subscribe, Unsubscribe, Update
Recordings()Archive, Unarchive, Trash

Client Portal

ServiceMethods
ClientApprovals()Get, ListResponses, GetResponse
ClientCorrespondences()List, Get, Create, Update, Trash

Working with Todos

ctx := context.Background()

// List todos in a todolist
todos, err := account.Todos().List(ctx, todolistID, nil)

// Create a todo
todo, err := account.Todos().Create(ctx, todolistID, &basecamp.CreateTodoRequest{
    Content:     "Review pull request",
    Description: "Check the new authentication flow",
    DueOn:       "2026-02-01",
    AssigneeIDs: []int64{12345},
})

// Complete a todo
err = account.Todos().Complete(ctx, todoID)

// Reposition a todo
err = account.Todos().Reposition(ctx, todoID, 1, nil) // Move to first position

// Move a todo to a different todolist
targetListID := int64(12345)
err = account.Todos().Reposition(ctx, todoID, 1, &targetListID)

Working with Messages

ctx := context.Background()

// Get the message board (boardID from project dock/tools)
var boardID int64 = 12345
board, err := account.MessageBoards().Get(ctx, boardID)

// List messages
messages, err := account.Messages().List(ctx, board.ID, nil)

// Create a message
msg, err := account.Messages().Create(ctx, board.ID, &basecamp.CreateMessageRequest{
    Subject: "Weekly Update",
    Content: "<p>Here's what we accomplished this week...</p>",
})

Working with Campfire

ctx := context.Background()

// List all campfires
campfires, err := account.Campfires().List(ctx, nil)

// Send a message
line, err := account.Campfires().CreateLine(ctx, campfireID, "Hello, team!")

// List recent messages
lines, err := account.Campfires().ListLines(ctx, campfireID, nil)

Working with Webhooks

ctx := context.Background()
var bucketID int64 = 12345 // project/bucket ID

// Create a webhook
webhook, err := account.Webhooks().Create(ctx, bucketID, &basecamp.CreateWebhookRequest{
    PayloadURL: "https://example.com/webhook",
    Types:      []string{"Todo", "Comment"},
})

// List webhooks
webhooks, err := account.Webhooks().List(ctx, bucketID, nil)

// Delete a webhook
err = account.Webhooks().Delete(ctx, webhookID)

Pagination

List methods auto-paginate: they follow the API's Link: rel="next" headers and collect every item across all pages, bounded by Limit when you set one.

Page opts out of that. A positive Page fetches exactly that page and disables auto-pagination:

// Page 3 only — one request, no link-following.
result, err := account.Projects().List(ctx, &basecamp.ProjectListOptions{Page: 3})

A positive Limit still trims that page, and dropping items from it counts as truncation. The per-operation default limits (100 todos, and so on) do not apply to a pinned page — asking for page 3 asks for page 3, not its first 100 items.

Meta.Truncated still answers "was there more": on a pinned page it is true when a rel="next" Link went deliberately unfollowed, or when Limit discarded items.

A handful of endpoints are not paginated server-side (Webhooks().List(), MessageTypes().List(), and the other cases each options struct calls out); they return the whole list, and Page there only short-circuits auto-pagination.

All six SDKs share these semantics — one request, that page only, no link-following. See SPEC section 8.

Error Handling

The SDK provides structured errors with codes for programmatic handling:

projects, err := account.Projects().List(ctx, nil)
if err != nil {
    if apiErr, ok := err.(*basecamp.Error); ok {
        switch apiErr.Code {
        case basecamp.CodeNotFound:
            // Handle not found
        case basecamp.CodeAuth:
            // Handle authentication error
        case basecamp.CodeRateLimit:
            // Handle rate limiting (SDK retries automatically)
        case basecamp.CodeForbidden:
            // Handle permission error
        default:
            // Handle other errors
        }

        // Errors include helpful hints
        fmt.Printf("Error: %s\nHint: %s\n", apiErr.Message, apiErr.Hint)

        // Use exit codes for CLI applications
        os.Exit(apiErr.ExitCode())
    }
}

Error Codes

CodeMeaningExit Code
usageInvalid arguments or configuration1
not_foundResource not found2
auth_requiredAuthentication required3
forbiddenAccess denied4
rate_limitRate limited (retryable)5
networkNetwork error (retryable)6
api_errorServer error7
ambiguousMultiple matches found8
validationValidation error (400, 422)9
limit_exceededAccount limit reached (507) — never retryable10

Validation Errors

Basecamp rejects invalid writes with a body keyed by field. The SDK folds those messages into Message and keeps the raw map in FieldErrors, so you can drive a form without re-parsing the message:

_, err := account.Calendars().Update(ctx, calendarID, "chartreuse")
if apiErr, ok := err.(*basecamp.Error); ok && apiErr.Code == basecamp.CodeValidation {
    // Message: "color: is not a valid color"
    fmt.Println(apiErr.Message)

    for field, messages := range apiErr.FieldErrors {
        for _, message := range messages {
            fmt.Printf("  %s %s\n", field, message)
        }
    }
}

FieldErrors is nil for every other error shape, and its messages are the raw ones — Message is capped at 500 bytes, the map is not.

Caching

The SDK supports ETag-based caching for GET responses. Caching is disabled by default to avoid writing private data to disk unexpectedly.

To enable caching:

cfg := basecamp.DefaultConfig()
cfg.CacheEnabled = true

// Or via environment variable:
// BASECAMP_CACHE_ENABLED=true

When enabled, the SDK caches GET responses using ETags:

// First request fetches from API
projects, _ := account.Projects().List(ctx, nil)

// Second request uses cached data if unchanged (304 Not Modified)
projects, _ = account.Projects().List(ctx, nil)

Custom HTTP Client

httpClient := &http.Client{
    Timeout: 60 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns: 50,
    },
}

client := basecamp.NewClient(cfg, token, basecamp.WithHTTPClient(httpClient))

Observability

The SDK provides a hooks interface for observability at two levels:

  • Operation-level: Semantic SDK operations like Todos.Complete, Projects.List
  • Request-level: HTTP requests including retries, caching, and timing

Debug Logging with SlogHooks

For debugging or verbose CLI modes, use SlogHooks to log all SDK activity:

import (
    "log/slog"
    "os"
    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)

// Create a debug logger
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

// Enable observability hooks
hooks := basecamp.NewSlogHooks(logger)
client := basecamp.NewClient(cfg, token, basecamp.WithHooks(hooks))

Output:

level=DEBUG msg="basecamp operation start" service=Todos operation=Complete resource_type=todo is_mutation=true
level=DEBUG msg="basecamp request start" method=POST url=https://3.basecampapi.com/123/todos/789/completion.json attempt=1
level=DEBUG msg="basecamp request complete" method=POST url=... duration=145ms status=204 from_cache=false
level=DEBUG msg="basecamp operation complete" service=Todos operation=Complete duration=147ms

OpenTelemetry Integration

For distributed tracing and metrics with OTel:

import (
    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
    basecampotel "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/otel"
)

// Uses global TracerProvider/MeterProvider by default
hooks := basecampotel.NewHooks()

// Or with custom providers
hooks = basecampotel.NewHooks(
    basecampotel.WithTracerProvider(tp),
    basecampotel.WithMeterProvider(mp),
)

client := basecamp.NewClient(cfg, token, basecamp.WithHooks(hooks))

Creates spans like:

  • Todos.Complete (operation span)
    • basecamp.request (HTTP span, child of operation)

Prometheus Metrics

For Prometheus-style metrics:

import (
    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
    basecampprom "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/prometheus"
    "github.com/prometheus/client_golang/prometheus"
)

hooks := basecampprom.NewHooks(prometheus.DefaultRegisterer)
client := basecamp.NewClient(cfg, token, basecamp.WithHooks(hooks))

Exposes metrics:

MetricTypeLabels
basecamp_operation_duration_secondsHistogramoperation
basecamp_operations_totalCounteroperation, status
basecamp_http_requests_totalCounterhttp_method, status_code
basecamp_retries_totalCounterhttp_method
basecamp_cache_operations_totalCounterresult
basecamp_errors_totalCounterhttp_method, type

Combining Multiple Backends

Use NewChainHooks to send telemetry to multiple backends:

import (
    "github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
    basecampotel "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/otel"
    basecampprom "github.com/basecamp/basecamp-sdk/go/pkg/basecamp/prometheus"
    "github.com/prometheus/client_golang/prometheus"
)

otelHooks := basecampotel.NewHooks()
promHooks := basecampprom.NewHooks(prometheus.DefaultRegisterer)

client := basecamp.NewClient(cfg, token,
    basecamp.WithHooks(basecamp.NewChainHooks(otelHooks, promHooks)),
)

Custom Hooks

Implement the Hooks interface for custom behavior. Embed NoopHooks to only override what you need:

type AlertingHooks struct {
    basecamp.NoopHooks
}

func (h *AlertingHooks) OnRetry(ctx context.Context, info basecamp.RequestInfo, attempt int, err error) {
    if attempt >= 3 {
        alertOncall(fmt.Sprintf("Basecamp API struggling: %s %s attempt %d", info.Method, info.URL, attempt))
    }
}

hooks := &AlertingHooks{}
client := basecamp.NewClient(cfg, token, basecamp.WithHooks(hooks))

Zero Overhead When Disabled

By default, the SDK uses NoopHooks which compiles to nothing—no overhead when observability isn't needed.

Logging

Enable HTTP-level debug logging with a custom slog logger:

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

client := basecamp.NewClient(cfg, token, basecamp.WithLogger(logger))

For semantic operation logging (recommended), use SlogHooks instead—see Observability above.

License

MIT