Basecamp integrates with Bolt.new using its REST API v4 through OAuth 2.0 authorization. Register a developer app at integrate.37signals.com, implement the OAuth flow in Next.js API routes (requires a deployed callback URL), then fetch projects, to-do lists, and messages from your Bolt app. Email activity integration (the top search query) uses Basecamp's Campfire messaging and to-do creation endpoints. Webhooks for new activity require deployment.
Building a Basecamp Integration in Bolt.new
Basecamp has been a fixture of project management software since 2004, with 37signals (the company behind Basecamp) consistently choosing a 'less is more' philosophy: flat per-company pricing ($99/month for unlimited users), focused features, and a deliberate anti-complexity stance. For teams using Basecamp as their project hub, building integrations with Bolt.new enables custom project overview dashboards, automated to-do creation from other tools, and workflow bridges between Basecamp and other services.
Basecamp's API (version 4 for Basecamp 4) uses standard REST patterns with OAuth 2.0 authorization through 37signals' shared account system. This is the same OAuth flow used across all 37signals products — Hey, Basecamp, and Highrise — so an authorized app can potentially access multiple 37signals products with the same credentials. The API is well-structured and documented, with one noteworthy quirk: every API request must include a User-Agent header with your application name and a contact email. 37signals uses this to throttle abusive callers by application rather than IP address. Requests without a User-Agent header will fail.
The 'email basecamp 3 integration' query that brings many users to this page reflects a common workflow: creating Basecamp to-dos or messages from emails. While Basecamp 4 does not have native email-to-task conversion (a feature that existed in older versions), the API makes it straightforward to build a webhook-triggered flow: receive an inbound email via a service like SendGrid Inbound Parse, parse the email content, and create a Basecamp to-do or message via the API. This guide covers the core Basecamp API integration and addresses this email workflow pattern.
Integration method
Basecamp uses OAuth 2.0 through 37signals' authorization server (launchpad.37signals.com). Register your app at integrate.37signals.com, implement the OAuth code flow through Next.js API routes, and use the resulting access token to call Basecamp 4's API for projects, to-dos, messages, and schedules. The OAuth callback URL requires a deployed app URL since Bolt's WebContainer preview URL is not stable. Once authorized, all subsequent API calls work through the server-side API routes in the WebContainer.
Prerequisites
- A Basecamp 4 account — sign up at basecamp.com (pricing: $15/user/month or $299/year flat for unlimited users on Business plan)
- A 37signals developer application registered at integrate.37signals.com — this provides your Client ID and Client Secret for OAuth
- A deployed Bolt.new app URL on Netlify or Bolt Cloud for the OAuth callback redirect — Basecamp OAuth cannot complete in Bolt's WebContainer preview since it requires a stable callback URL
- A Next.js project in Bolt.new for server-side API routes — Basecamp OAuth tokens and API calls must remain server-side to keep credentials secure
- Basic familiarity with Basecamp's data model: Accounts (your Basecamp subscription), Projects (Basecamps), and inside each project: To-do lists, Message Boards, Campfire (chat), Schedule, and Docs
Step-by-step guide
Register a 37signals developer application
Register a 37signals developer application
All Basecamp OAuth apps are registered through 37signals' shared developer platform at integrate.37signals.com. This single registration works across all 37signals products — you use the same OAuth credentials whether you are integrating Basecamp, Hey, or Highrise. Go to integrate.37signals.com and sign in with your 37signals account (the same credentials you use for Basecamp). Click 'Register one now!' or navigate to the application registration form. Fill in: application name (the name your users will see when authorizing), your company name, website URL, OAuth callback URL (this must be your deployed app URL, e.g., https://yourapp.netlify.app/api/auth/basecamp/callback), and a description of your integration. After registering, you receive a Client ID (called 'key' in some 37signals documentation) and a Client Secret. Copy both immediately and store them as BASECAMP_CLIENT_ID and BASECAMP_CLIENT_SECRET in your Bolt project's .env file. The OAuth redirect URI is the most important field to get right. Basecamp performs an exact string match between the redirect_uri in your authorization request and what you registered. The URI must be HTTPS (except for localhost during development), and must include the full path. You can add multiple redirect URIs by registering multiple applications or by creating a single URI that accepts different environments as query parameters. Basecamp's API requires your Basecamp account ID in all API URLs. After completing OAuth authorization, call GET https://launchpad.37signals.com/authorization.json with the bearer token — this returns the user's Basecamp account IDs (an array, since users can belong to multiple Basecamp accounts). Store the account ID you need (typically the first one, or let the user select from multiple) for subsequent API calls. The API URL pattern is https://3.basecampapi.com/{accountId}/{resource}.json. Note on User-Agent requirement: 37signals explicitly requires all API requests to include a User-Agent header formatted as 'YourAppName (contact@youremail.com)'. Requests without this header will return 403 errors. This is documented on their API GitHub: 37signals/bc3-api. Create a header constant in your API utility and include it in every single request.
Set up Basecamp OAuth configuration in this Next.js app. Add BASECAMP_CLIENT_ID, BASECAMP_CLIENT_SECRET, BASECAMP_REDIRECT_URI, and BASECAMP_APP_USER_AGENT (format: 'AppName (email@example.com)') to .env with placeholder values. Create a lib/basecamp/config.ts file that exports: BASECAMP_AUTH_URL ('https://launchpad.37signals.com/authorization/new'), BASECAMP_TOKEN_URL ('https://launchpad.37signals.com/authorization/token'), BASECAMP_API_BASE ('https://3.basecampapi.com'), a buildAuthUrl(state: string) function that constructs the 37signals authorization URL with client_id, redirect_uri, response_type=code, type=web_server, and state params. Export a getBasecampHeaders(token: string) function that returns Authorization: Bearer and User-Agent headers. Add TypeScript interfaces for BasecampAccount and BasecampToken.
Paste this in Bolt.new chat
1// lib/basecamp/config.ts2export const BASECAMP_AUTH_URL = 'https://launchpad.37signals.com/authorization/new';3export const BASECAMP_TOKEN_URL = 'https://launchpad.37signals.com/authorization/token';4export const BASECAMP_API_BASE = 'https://3.basecampapi.com';56// CRITICAL: 37signals REQUIRES a descriptive User-Agent on every request7// Format: 'App Name (contact@youremail.com)'8// Without this header, requests return 403 Forbidden9const USER_AGENT = process.env.BASECAMP_APP_USER_AGENT ?? 'MyApp (contact@example.com)';1011export interface BasecampTokenResponse {12 access_token: string;13 refresh_token?: string;14 expires_in: number;15 token_type: string;16}1718export interface BasecampAccount {19 id: number;20 name: string;21 product: string;22 href: string;23 app_href: string;24}2526export interface BasecampAuthorizationResponse {27 expires_at: string;28 identity: {29 id: number;30 first_name: string;31 last_name: string;32 email_address: string;33 };34 accounts: BasecampAccount[];35}3637export function buildAuthUrl(state: string): string {38 const params = new URLSearchParams({39 client_id: process.env.BASECAMP_CLIENT_ID ?? '',40 redirect_uri: process.env.BASECAMP_REDIRECT_URI ?? '',41 response_type: 'code',42 type: 'web_server',43 state,44 });45 return `${BASECAMP_AUTH_URL}?${params.toString()}`;46}4748export function getBasecampHeaders(token: string): Record<string, string> {49 return {50 Authorization: `Bearer ${token}`,51 'User-Agent': USER_AGENT,52 'Content-Type': 'application/json',53 };54}Pro tip: 37signals' User-Agent policy is strictly enforced. The BASECAMP_APP_USER_AGENT value should follow the exact format 'App Name (contact@youremail.com)' with your real contact email. This is used for abuse prevention and to contact you if your integration causes issues. Use your real email — this is not public-facing.
Expected result: The Basecamp configuration module is ready with OAuth URL builders, header generators, and TypeScript interfaces. The .env file has placeholder values for Client ID, Client Secret, Redirect URI, and User-Agent. The getBasecampHeaders function automatically adds the required User-Agent to all API calls.
Implement the 37signals OAuth 2.0 authorization flow
Implement the 37signals OAuth 2.0 authorization flow
Basecamp's OAuth flow uses 37signals' authorization server at launchpad.37signals.com. The flow follows the standard authorization code grant: redirect user to launchpad, user logs in and grants access, launchpad redirects back with a code, you exchange the code for tokens. The key difference from many OAuth implementations is that Basecamp provides permanent access tokens (they do not expire unless explicitly revoked), so token refresh is not typically required. The authorization route, GET /api/auth/basecamp, generates a random state parameter, stores it in a cookie for CSRF verification, and redirects the user to the 37signals authorization URL built with your Client ID and Redirect URI. Include type=web_server in the authorization URL params — 37signals requires this parameter. The callback route, GET /api/auth/basecamp/callback, handles the return redirect from 37signals. It extracts the authorization code from query params, validates the state cookie, and exchanges the code for an access token by POSTing to https://launchpad.37signals.com/authorization/token. The token exchange requires sending client_id, client_secret, redirect_uri, code, and type=web_server as a form-encoded body. After getting the access token, call GET https://launchpad.37signals.com/authorization.json to retrieve the user's account information, including their Basecamp account IDs. This is an essential step — you need the Basecamp account ID (a number like 1234567) to construct any API URLs. Store both the access token and the account ID securely (in HTTP-only cookies or a Supabase database). For multi-account users (people with access to multiple Basecamp accounts), present a selection step after OAuth completes where the user picks which Basecamp account to connect. Store their selection. For single-account users, automatically proceed with their only account. Remember: the OAuth callback URL must match your deployed app URL exactly. This step requires deployment before testing end-to-end. For development, you can hardcode a test access token (generate one via the personal access tokens feature in your Basecamp account) and skip OAuth for initial development.
Implement 37signals OAuth for Basecamp. Create two Next.js API routes: (1) app/api/auth/basecamp/route.ts: generate a random state, save to 'bc_state' cookie, and redirect to buildAuthUrl(state) from lib/basecamp/config.ts. (2) app/api/auth/basecamp/callback/route.ts: validate state cookie, extract code param, POST to BASECAMP_TOKEN_URL with client_id, client_secret, redirect_uri, code, and type=web_server form-encoded, then GET https://launchpad.37signals.com/authorization.json with the access token to get the Basecamp account ID, store access token and account ID in HTTP-only cookies, and redirect to /dashboard. Handle errors appropriately. Use the getBasecampHeaders function for all requests.
Paste this in Bolt.new chat
1// app/api/auth/basecamp/callback/route.ts2import { NextRequest, NextResponse } from 'next/server';3import {4 BASECAMP_TOKEN_URL,5 getBasecampHeaders,6 type BasecampTokenResponse,7 type BasecampAuthorizationResponse,8} from '@/lib/basecamp/config';910export async function GET(request: NextRequest) {11 const { searchParams } = new URL(request.url);12 const code = searchParams.get('code');13 const state = searchParams.get('state');14 const stateCookie = request.cookies.get('bc_state')?.value;1516 // CSRF validation17 if (!state || state !== stateCookie) {18 return NextResponse.redirect(new URL('/auth/error?reason=invalid_state', request.url));19 }20 if (!code) {21 return NextResponse.redirect(new URL('/auth/error?reason=no_code', request.url));22 }2324 try {25 // Exchange authorization code for access token26 const tokenResponse = await fetch(BASECAMP_TOKEN_URL, {27 method: 'POST',28 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },29 body: new URLSearchParams({30 type: 'web_server',31 client_id: process.env.BASECAMP_CLIENT_ID ?? '',32 redirect_uri: process.env.BASECAMP_REDIRECT_URI ?? '',33 client_secret: process.env.BASECAMP_CLIENT_SECRET ?? '',34 code,35 }),36 });3738 if (!tokenResponse.ok) {39 console.error('Token exchange failed:', await tokenResponse.text());40 return NextResponse.redirect(new URL('/auth/error?reason=token_exchange', request.url));41 }4243 const tokens: BasecampTokenResponse = await tokenResponse.json();4445 // Get user's Basecamp accounts46 const authResponse = await fetch('https://launchpad.37signals.com/authorization.json', {47 headers: getBasecampHeaders(tokens.access_token),48 });49 const authData: BasecampAuthorizationResponse = await authResponse.json();5051 // Find the first Basecamp 4 account52 const basecampAccount = authData.accounts.find((a) => a.product === 'bc4');53 if (!basecampAccount) {54 return NextResponse.redirect(new URL('/auth/error?reason=no_basecamp_account', request.url));55 }5657 const response = NextResponse.redirect(new URL('/dashboard', request.url));5859 response.cookies.set('bc_token', tokens.access_token, {60 httpOnly: true,61 secure: process.env.NODE_ENV === 'production',62 maxAge: 60 * 60 * 24 * 365, // Basecamp tokens do not expire — 1 year expiry for cookie63 sameSite: 'lax',64 });65 response.cookies.set('bc_account_id', String(basecampAccount.id), {66 httpOnly: true,67 secure: process.env.NODE_ENV === 'production',68 maxAge: 60 * 60 * 24 * 365,69 sameSite: 'lax',70 });71 response.cookies.delete('bc_state');7273 return response;74 } catch (error) {75 console.error('Basecamp OAuth error:', error);76 return NextResponse.redirect(new URL('/auth/error?reason=server_error', request.url));77 }78}Pro tip: Basecamp access tokens are permanent — they do not expire unless the user revokes the authorization in their Basecamp account settings. Unlike most OAuth APIs, you do not need to implement token refresh logic. Store the token securely (HTTP-only cookie or encrypted database field) and it will remain valid indefinitely.
Expected result: After deploying and registering the callback URL at integrate.37signals.com, navigating to /api/auth/basecamp redirects to 37signals' login page. After authorizing, the user is redirected to /dashboard with their Basecamp token and account ID stored in cookies.
Fetch Basecamp projects and build the overview dashboard
Fetch Basecamp projects and build the overview dashboard
With OAuth tokens secured, you can now fetch the user's Basecamp projects and build a custom project overview. Basecamp's projects endpoint returns a list of all accessible projects (called 'Basecamps' in Basecamp 4 UI) for the authenticated account. The projects API call is GET https://3.basecampapi.com/{accountId}/projects.json. Required headers are Authorization: Bearer {token} and the User-Agent header. The response is a JSON array of project objects with fields: id, name, description, status (active or archived), created_at, updated_at, and dock (an array of tools enabled on the project — to-do lists, message boards, campfire, schedule, etc.). The dock array gives you the ID for each enabled tool (e.g., the to-do tool has its own ID for use in subsequent API calls). For each project, you can fetch the summary stats by calling the individual tool endpoints. The to-do tool summary is at GET /buckets/{projectId}/todolists.json — it returns the active to-do lists. For each to-do list, GET /buckets/{projectId}/todolists/{listId}/todos.json returns the actual tasks. For the dashboard, fetch the count of open to-dos per project without loading all individual tasks (use the todolists endpoint and sum the completed_count and remaining_count from each list). Basecamp's API is paginated — responses include link headers for next/previous pages when results exceed the page limit (typically 50 items). The link header format is: Link: <https://3.basecampapi.com/...?page=2>; rel="next". Parse this header to implement pagination if needed. The dashboard component uses React Server Components to fetch project data server-side. This keeps the Basecamp token in server memory (not sent to the browser) and provides faster initial page load since data is included in the HTML. Add a client-side refresh button that re-fetches from your API routes for manual updates.
Build a Basecamp projects dashboard. Create an API route at app/api/basecamp/projects/route.ts that reads the bc_token and bc_account_id cookies, calls GET https://3.basecampapi.com/{accountId}/projects.json using getBasecampHeaders(), and returns the active projects with id, name, description, status, updated_at, and the dock tools array. Build a projects dashboard page at app/dashboard/page.tsx that fetches from this route and displays each active project as a card showing name, description, last activity time, and a count of enabled tools (from dock.length). Add a 'Connect Basecamp' button for unauthenticated users. Add a breadcrumb showing the connected Basecamp account name. Sort projects by updated_at descending.
Paste this in Bolt.new chat
1// app/api/basecamp/projects/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { BASECAMP_API_BASE, getBasecampHeaders } from '@/lib/basecamp/config';45interface BasecampDockItem {6 id: number;7 title: string;8 name: string;9 enabled: boolean;10 url: string;11 app_url: string;12}1314interface BasecampProject {15 id: number;16 name: string;17 description: string;18 status: 'active' | 'archived' | 'trashed';19 created_at: string;20 updated_at: string;21 dock: BasecampDockItem[];22 bookmark_url: string;23 url: string;24 app_url: string;25}2627export async function GET(request: NextRequest) {28 const token = request.cookies.get('bc_token')?.value;29 const accountId = request.cookies.get('bc_account_id')?.value;3031 if (!token || !accountId) {32 return NextResponse.json({ error: 'Not authenticated with Basecamp' }, { status: 401 });33 }3435 try {36 const response = await fetch(37 `${BASECAMP_API_BASE}/${accountId}/projects.json`,38 { headers: getBasecampHeaders(token) }39 );4041 if (response.status === 401) {42 return NextResponse.json({ error: 'Basecamp token invalid — re-authorize' }, { status: 401 });43 }44 if (!response.ok) {45 return NextResponse.json(46 { error: `Basecamp API error: ${response.status}` },47 { status: response.status }48 );49 }5051 const projects: BasecampProject[] = await response.json();5253 const active = projects54 .filter((p) => p.status === 'active')55 .sort(56 (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()57 )58 .map((p) => ({59 id: p.id,60 name: p.name,61 description: p.description,62 updatedAt: p.updated_at,63 toolCount: p.dock.filter((d) => d.enabled).length,64 tools: p.dock.filter((d) => d.enabled).map((d) => d.name),65 appUrl: p.app_url,66 }));6768 return NextResponse.json({ projects: active, total: active.length });69 } catch (error) {70 const message = error instanceof Error ? error.message : 'Request failed';71 return NextResponse.json({ error: message }, { status: 500 });72 }73}Pro tip: The dock array in each project lists all enabled tools. Tool names include 'todoset' (to-dos), 'message_board', 'campfire' (chat), 'schedule', 'vault' (files), 'questionnaire' (check-ins), and 'inbox'. Use dock.find(d => d.name === 'todoset')?.url to get the to-do list URL for that project without constructing it manually.
Expected result: The projects dashboard shows active Basecamp projects as cards sorted by last activity. Each card shows project name, description, last updated time, and the number of enabled tools. The 'Connect Basecamp' button is shown for unauthenticated users.
Create to-dos and messages via the Basecamp API
Create to-dos and messages via the Basecamp API
Beyond reading data, creating to-dos and messages programmatically is the core value-add for many Basecamp integrations. The email-to-task pattern (the 'email basecamp 3 integration' use case) works by receiving an inbound webhook from an email service, then creating a Basecamp to-do from the email content via this endpoint. Creating a to-do requires knowing the to-do list ID within a project. First, fetch the project's to-do lists with GET /buckets/{projectId}/todolists.json. Each to-do list has an id and a title. Once you have the list ID, create a to-do with POST /buckets/{projectId}/todolists/{listId}/todos.json. The required body field is title (the task name). Optional fields include description (HTML content for the task body), due_on (YYYY-MM-DD date), assignee_ids (array of Basecamp person IDs), and notify (boolean to send email notifications to assignees). Creating a Campfire (chat) message is simpler: POST /buckets/{projectId}/chats/{campfireId}/lines.json with a body containing content (the message text). Find the campfireId from the project's dock array: dock.find(d => d.name === 'campfire')?.id. Posting to the message board uses a different endpoint: POST /buckets/{projectId}/message_boards/{boardId}/messages.json with subject and content fields. Message board posts support rich HTML in the content field. The email-to-task integration pattern requires deploying your app first (to receive inbound email webhooks), but creating tasks manually from a form in your Bolt-built interface works in the WebContainer preview immediately. Build the form-based to-do creation first to validate the API interaction, then add email-triggered creation as an enhancement.
Build a to-do creation feature for the Basecamp integration. Create an API route at app/api/basecamp/todos/route.ts that: (1) GET: fetches to-do lists from a project ID passed as query param (calls /buckets/{projectId}/todolists.json), (2) POST: accepts { projectId, listId, title, description, dueOn } in the request body and creates a to-do via POST /buckets/{projectId}/todolists/{listId}/todos.json. Build a 'Create To-do' modal form with: project selector (dropdown fetched from /api/basecamp/projects), to-do list selector (fetched when project is selected), title input (required), description textarea, and due date picker. On submit, call the POST endpoint. Show success toast and close modal. Use react-hook-form and shadcn/ui Dialog.
Paste this in Bolt.new chat
1// app/api/basecamp/todos/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { BASECAMP_API_BASE, getBasecampHeaders } from '@/lib/basecamp/config';45interface CreateTodoBody {6 projectId: number;7 listId: number;8 title: string;9 description?: string;10 dueOn?: string;11 assigneeIds?: number[];12}1314export async function GET(request: NextRequest) {15 const token = request.cookies.get('bc_token')?.value;16 const accountId = request.cookies.get('bc_account_id')?.value;17 const projectId = request.nextUrl.searchParams.get('projectId');1819 if (!token || !accountId || !projectId) {20 return NextResponse.json({ error: 'Missing required params or auth' }, { status: 400 });21 }2223 const response = await fetch(24 `${BASECAMP_API_BASE}/${accountId}/buckets/${projectId}/todolists.json`,25 { headers: getBasecampHeaders(token) }26 );27 const lists = await response.json();28 return NextResponse.json({ lists });29}3031export async function POST(request: NextRequest) {32 const token = request.cookies.get('bc_token')?.value;33 const accountId = request.cookies.get('bc_account_id')?.value;3435 if (!token || !accountId) {36 return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });37 }3839 const body: CreateTodoBody = await request.json();4041 if (!body.title?.trim()) {42 return NextResponse.json({ error: 'To-do title is required' }, { status: 400 });43 }4445 const todoBody: Record<string, unknown> = { title: body.title };46 if (body.description) todoBody.description = `<div>${body.description}</div>`;47 if (body.dueOn) todoBody.due_on = body.dueOn;48 if (body.assigneeIds?.length) todoBody.assignee_ids = body.assigneeIds;4950 const response = await fetch(51 `${BASECAMP_API_BASE}/${accountId}/buckets/${body.projectId}/todolists/${body.listId}/todos.json`,52 {53 method: 'POST',54 headers: getBasecampHeaders(token),55 body: JSON.stringify(todoBody),56 }57 );5859 if (!response.ok) {60 const error = await response.text();61 return NextResponse.json({ error: `Failed to create to-do: ${error}` }, { status: response.status });62 }6364 const newTodo = await response.json();65 return NextResponse.json({ todo: newTodo }, { status: 201 });66}Pro tip: Basecamp requires to-do descriptions to be wrapped in HTML tags — plain text descriptions should be wrapped in a <div> tag. The description field accepts limited HTML: <strong>, <em>, <ul>, <ol>, <li>, <a href>, and <br>. Unsupported HTML tags are stripped by Basecamp's server.
Expected result: The create to-do modal shows a project dropdown and fetches to-do lists dynamically when a project is selected. Submitting the form creates a new to-do in Basecamp and the task appears immediately in the Basecamp web interface. A success toast confirms the creation.
Common use cases
Custom Project Overview Dashboard
A React dashboard that shows all Basecamp projects with their recent activity, open to-do counts, and overdue tasks. The user connects their Basecamp account via OAuth, and the dashboard aggregates data across all their projects — a single-page overview that Basecamp's native interface spreads across multiple project pages.
Build a Basecamp project overview dashboard. Create Next.js API routes: (1) /api/basecamp/projects that fetches GET https://3.basecampapi.com/{accountId}/projects.json with Bearer token from cookie, returning project names, URLs, and counts. (2) /api/basecamp/projects/[projectId]/todos that fetches the to-do lists and open item counts for a project. Build a dashboard page showing project cards with name, active to-dos count, and last activity time. Show a loading state and handle the case where the user hasn't connected Basecamp with a 'Connect Basecamp' button. Use Tailwind CSS and shadcn/ui Card components. Include the required User-Agent header on all Basecamp API calls.
Copy this prompt to try it in Bolt.new
Email-to-Basecamp To-do Bridge
A serverless function that receives inbound emails via SendGrid Inbound Parse, extracts the email subject and body, and creates a Basecamp to-do from the email content. The email subject becomes the to-do title, the body becomes the description, and a configured project and to-do list receives the task. Solves the 'email basecamp integration' use case programmatically.
Build an email-to-Basecamp bridge. Create a Next.js API route at /api/email/to-basecamp that accepts POST requests from SendGrid Inbound Parse (multipart form data with 'from', 'subject', 'text' fields). Extract the sender email, subject line, and body text. Then call the Basecamp API to create a to-do in a configured project: POST https://3.basecampapi.com/{BASECAMP_ACCOUNT_ID}/buckets/{BASECAMP_PROJECT_ID}/todolists/{BASECAMP_LIST_ID}/todos.json with title=subject, description=body, using the service account token from BASECAMP_ACCESS_TOKEN env var. Return 200 after creating the to-do. Add BASECAMP_ACCOUNT_ID, BASECAMP_PROJECT_ID, and BASECAMP_LIST_ID to .env. Note this requires deployment to receive inbound emails.
Copy this prompt to try it in Bolt.new
Project Activity Feed with Basecamp Webhooks
A real-time activity feed that shows new Basecamp events (to-dos completed, messages posted, files uploaded) across all team projects. Basecamp sends webhook notifications for project activity to a registered URL. The handler stores events in Supabase and the React frontend shows them in a timeline. Requires deployment for webhook registration.
Build a Basecamp activity feed. Create a webhook handler at /api/webhooks/basecamp that accepts POST requests, verifies the X-Basecamp-Signature header using BASECAMP_WEBHOOK_SECRET from process.env, parses the event type and recording data from the payload, saves the event to a Supabase 'basecamp_events' table (event_type, project_name, creator_name, summary, created_at), and returns 200. Build an activity feed page at app/activity/page.tsx that fetches recent events from Supabase and displays them as a timeline list with event type badge, project name, creator, and description. Add a comment that the webhook URL must be registered in Basecamp project settings after deployment.
Copy this prompt to try it in Bolt.new
Troubleshooting
Basecamp API returns 403 Forbidden on all requests even with a valid access token
Cause: The User-Agent header is missing from the API request. 37signals enforces the User-Agent requirement strictly — requests without a properly formatted User-Agent header are blocked regardless of authentication status.
Solution: Verify that every Basecamp API call includes the User-Agent header in the format 'YourAppName (contact@youremail.com)'. Use the getBasecampHeaders() utility from lib/basecamp/config.ts on every fetch call. Check that BASECAMP_APP_USER_AGENT is set in your .env with the correct format and a real contact email address. Even a missing or malformed User-Agent on one API call will cause 403 responses.
1// Every Basecamp API call must include User-Agent2const headers = getBasecampHeaders(token);3// Verify headers contain User-Agent:4console.log('Headers:', JSON.stringify(Object.keys(headers)));5// Expected: ['Authorization', 'User-Agent', 'Content-Type']67// If using raw fetch without the utility:8const response = await fetch(url, {9 headers: {10 Authorization: `Bearer ${token}`,11 'User-Agent': 'MyApp (contact@myapp.com)', // REQUIRED by 37signals12 'Content-Type': 'application/json',13 },14});The OAuth callback returns 'redirect_uri_mismatch' error after authorizing with Basecamp
Cause: The redirect_uri sent in the authorization request does not exactly match the Redirect URI registered for the application at integrate.37signals.com. Basecamp performs a case-sensitive exact string comparison.
Solution: Log in to integrate.37signals.com, find your registered app, and verify the Redirect URI matches exactly — including http vs https, trailing slashes, and path. Update BASECAMP_REDIRECT_URI in your .env to match exactly. Also ensure your deployed app URL matches — if you deployed to Netlify, the redirect URI should be https://yourapp.netlify.app/api/auth/basecamp/callback with your actual subdomain.
1// In .env - the redirect URI must EXACTLY match integrate.37signals.com2BASECAMP_REDIRECT_URI=https://yourapp.netlify.app/api/auth/basecamp/callback34// Check that buildAuthUrl includes this exact URI5// In the auth route:6console.log('Redirect URI:', process.env.BASECAMP_REDIRECT_URI);7// Must match what is registered at integrate.37signals.comAPI calls succeed but return an empty projects list even though the Basecamp account has active projects
Cause: The Basecamp account ID stored in the cookie does not match the account where the projects exist — the user may have multiple Basecamp accounts, and the authorization response selected the wrong one.
Solution: After OAuth, fetch GET https://launchpad.37signals.com/authorization.json and log all accounts in the response. The accounts array may contain multiple entries with different product values (bc4 for Basecamp 4, bc3 for Basecamp 3, etc.) and different account IDs. Ensure you are using the bc4 account ID that corresponds to the Basecamp account where the projects exist. If the user has multiple bc4 accounts, add a step where they select which account to connect.
1// Log all accounts to identify the correct one2const authData = await response.json();3console.log('Available accounts:', authData.accounts.map((a: BasecampAccount) => ({4 id: a.id,5 name: a.name,6 product: a.product,7})));8// Use accounts.find(a => a.product === 'bc4') for Basecamp 4Best practices
- Always include the User-Agent header on every Basecamp API request — create a single getBasecampHeaders() utility that includes it automatically so it cannot be accidentally omitted
- Basecamp OAuth tokens do not expire — but they can be revoked by the user in their account settings. Handle 401 responses by prompting re-authorization rather than assuming tokens are permanently valid
- Test OAuth flow and API calls on a deployed environment (Netlify or Bolt Cloud) — the OAuth callback requires a stable HTTPS URL that Bolt's WebContainer cannot provide
- Paginate project and to-do API responses by parsing the Link header for rel='next' — Basecamp returns maximum 50 items per page, and large accounts can have many more projects and tasks
- Store the Basecamp account ID alongside the access token — all API URLs require the account ID prefix, and storing it avoids an extra /authorization.json call on each request
- Use Basecamp's rate limiting headers (RateLimit-Remaining, RateLimit-Reset) to implement backoff when approaching limits — 37signals imposes strict rate limits and will block apps that exceed them
- For the email-to-task integration, deploy first and validate webhook receipt before building the Basecamp to-do creation logic — email webhook payload formats vary by provider and need field mapping
Alternatives
Choose Asana if you need a more feature-rich task management API with better developer documentation, personal access token authentication (no OAuth required for personal use), and more granular task fields than Basecamp.
Choose Trello if you prefer a Kanban-style project management API with simpler API key authentication — Trello's API is easier to get started with than Basecamp's OAuth flow.
Choose ClickUp if you need a more powerful project management API with custom fields, multiple views, time tracking, and more complex workflow automation than Basecamp supports.
Consider Teamwork as a Basecamp alternative if you need integrated time tracking, invoicing, and client billing alongside project management in a single REST API.
Frequently asked questions
How do I integrate email with Basecamp in a Bolt.new app?
Basecamp 4 does not have native email-to-task conversion, but you can build it with two services: an inbound email webhook provider (SendGrid Inbound Parse, Mailgun's inbound routing, or Postmark's inbound email webhook) plus the Basecamp API. Set up the email service to POST inbound emails to your deployed Bolt app, then parse the email subject and body in your API route and create a Basecamp to-do via POST /buckets/{projectId}/todolists/{listId}/todos.json. This requires deployment since inbound email webhooks need a public HTTPS URL.
Does Basecamp OAuth work in Bolt.new's WebContainer preview?
The OAuth authorization flow requires a stable HTTPS callback URL, which Bolt's WebContainer preview cannot provide (the preview URL changes each session). You need to deploy your app to Netlify or Bolt Cloud first, then register the deployed URL as the redirect URI in your 37signals developer application. However, once you have obtained a Basecamp access token via the deployed OAuth flow, all subsequent API calls (fetching projects, creating to-dos) work perfectly in the Bolt WebContainer preview since they are outbound HTTP requests.
Why does Basecamp return 403 Forbidden even though my access token is valid?
The most common cause is a missing User-Agent header. 37signals requires all Basecamp API requests to include a User-Agent header formatted as 'YourAppName (contact@youremail.com)'. This requirement is strictly enforced — requests without a valid User-Agent are blocked regardless of authentication. Check that every API call includes this header, and use the getBasecampHeaders() utility from lib/basecamp/config.ts to ensure it is never omitted.
Do Basecamp access tokens expire?
No — Basecamp OAuth access tokens are permanent. They remain valid until the user explicitly revokes the authorization in their Basecamp account settings (Profile → Integrations). Unlike most OAuth APIs that use short-lived access tokens requiring periodic refresh, Basecamp tokens are designed for long-term app integrations. Store them securely and handle 401 responses by prompting the user to re-authorize rather than expecting routine token expiration.
Can I access both Basecamp 3 and Basecamp 4 with the same OAuth app?
Yes — 37signals' unified OAuth system covers all their products. After authorization, the /authorization.json endpoint returns all of the user's 37signals accounts, with product values of 'bc3' for Basecamp 3 and 'bc4' for Basecamp 4. Use the account's href field to construct API URLs for the appropriate version. Basecamp 3 API lives at https://3.basecampapi.com/{accountId} and Basecamp 4 uses the same base URL — check your account's product field to confirm which version you are using.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation