Skip to main content
RapidDev - Software Development Agency
bolt-ai-integrationsBolt Chat + API Route

How to Integrate Bolt.new with Basecamp

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.

What you'll learn

  • How to register a Basecamp developer application at integrate.37signals.com and configure OAuth credentials
  • How to implement the 37signals OAuth 2.0 flow in Next.js API routes with the required User-Agent header
  • How to fetch Basecamp projects, to-do lists, and message boards via the API to build a custom project dashboard
  • How to create to-dos and Campfire messages programmatically from a Bolt.new app using Basecamp's REST endpoints
  • How to handle Basecamp webhooks for new project activity after deploying to Netlify or Bolt Cloud
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate20 min read25 minutesOtherApril 2026RapidDev Engineering Team
TL;DR

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

Bolt Chat + API Route

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

1

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.

Bolt.new Prompt

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

lib/basecamp/config.ts
1// lib/basecamp/config.ts
2export 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';
5
6// CRITICAL: 37signals REQUIRES a descriptive User-Agent on every request
7// Format: 'App Name (contact@youremail.com)'
8// Without this header, requests return 403 Forbidden
9const USER_AGENT = process.env.BASECAMP_APP_USER_AGENT ?? 'MyApp (contact@example.com)';
10
11export interface BasecampTokenResponse {
12 access_token: string;
13 refresh_token?: string;
14 expires_in: number;
15 token_type: string;
16}
17
18export interface BasecampAccount {
19 id: number;
20 name: string;
21 product: string;
22 href: string;
23 app_href: string;
24}
25
26export 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}
36
37export 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}
47
48export 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.

2

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.

Bolt.new Prompt

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

app/api/auth/basecamp/callback/route.ts
1// app/api/auth/basecamp/callback/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import {
4 BASECAMP_TOKEN_URL,
5 getBasecampHeaders,
6 type BasecampTokenResponse,
7 type BasecampAuthorizationResponse,
8} from '@/lib/basecamp/config';
9
10export 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;
15
16 // CSRF validation
17 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 }
23
24 try {
25 // Exchange authorization code for access token
26 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 });
37
38 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 }
42
43 const tokens: BasecampTokenResponse = await tokenResponse.json();
44
45 // Get user's Basecamp accounts
46 const authResponse = await fetch('https://launchpad.37signals.com/authorization.json', {
47 headers: getBasecampHeaders(tokens.access_token),
48 });
49 const authData: BasecampAuthorizationResponse = await authResponse.json();
50
51 // Find the first Basecamp 4 account
52 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 }
56
57 const response = NextResponse.redirect(new URL('/dashboard', request.url));
58
59 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 cookie
63 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');
72
73 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.

3

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.

Bolt.new Prompt

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

app/api/basecamp/projects/route.ts
1// app/api/basecamp/projects/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { BASECAMP_API_BASE, getBasecampHeaders } from '@/lib/basecamp/config';
4
5interface BasecampDockItem {
6 id: number;
7 title: string;
8 name: string;
9 enabled: boolean;
10 url: string;
11 app_url: string;
12}
13
14interface 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}
26
27export async function GET(request: NextRequest) {
28 const token = request.cookies.get('bc_token')?.value;
29 const accountId = request.cookies.get('bc_account_id')?.value;
30
31 if (!token || !accountId) {
32 return NextResponse.json({ error: 'Not authenticated with Basecamp' }, { status: 401 });
33 }
34
35 try {
36 const response = await fetch(
37 `${BASECAMP_API_BASE}/${accountId}/projects.json`,
38 { headers: getBasecampHeaders(token) }
39 );
40
41 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 }
50
51 const projects: BasecampProject[] = await response.json();
52
53 const active = projects
54 .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 }));
67
68 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.

4

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.

Bolt.new Prompt

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

app/api/basecamp/todos/route.ts
1// app/api/basecamp/todos/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { BASECAMP_API_BASE, getBasecampHeaders } from '@/lib/basecamp/config';
4
5interface CreateTodoBody {
6 projectId: number;
7 listId: number;
8 title: string;
9 description?: string;
10 dueOn?: string;
11 assigneeIds?: number[];
12}
13
14export 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');
18
19 if (!token || !accountId || !projectId) {
20 return NextResponse.json({ error: 'Missing required params or auth' }, { status: 400 });
21 }
22
23 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}
30
31export async function POST(request: NextRequest) {
32 const token = request.cookies.get('bc_token')?.value;
33 const accountId = request.cookies.get('bc_account_id')?.value;
34
35 if (!token || !accountId) {
36 return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
37 }
38
39 const body: CreateTodoBody = await request.json();
40
41 if (!body.title?.trim()) {
42 return NextResponse.json({ error: 'To-do title is required' }, { status: 400 });
43 }
44
45 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;
49
50 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 );
58
59 if (!response.ok) {
60 const error = await response.text();
61 return NextResponse.json({ error: `Failed to create to-do: ${error}` }, { status: response.status });
62 }
63
64 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.

Bolt.new Prompt

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.

Bolt.new Prompt

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.

Bolt.new Prompt

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.

typescript
1// Every Basecamp API call must include User-Agent
2const headers = getBasecampHeaders(token);
3// Verify headers contain User-Agent:
4console.log('Headers:', JSON.stringify(Object.keys(headers)));
5// Expected: ['Authorization', 'User-Agent', 'Content-Type']
6
7// 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 37signals
12 '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.

typescript
1// In .env - the redirect URI must EXACTLY match integrate.37signals.com
2BASECAMP_REDIRECT_URI=https://yourapp.netlify.app/api/auth/basecamp/callback
3
4// Check that buildAuthUrl includes this exact URI
5// In the auth route:
6console.log('Redirect URI:', process.env.BASECAMP_REDIRECT_URI);
7// Must match what is registered at integrate.37signals.com

API 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.

typescript
1// Log all accounts to identify the correct one
2const 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 4

Best 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

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.