# HubSpot

- Tool: Bubble
- Difficulty: Intermediate
- Time required: 45–90 minutes
- Last updated: July 2026

## TL;DR

Connect Bubble to HubSpot by installing the API Connector plugin, adding your HubSpot Private App Bearer token as a Private header, and calling the CRM v3 REST API — no OAuth flow required. Sync form submissions as new contacts, search and update existing records, and display deal pipeline data in Bubble repeating groups, all server-side with no CORS issues.

## Bubble to HubSpot: CRM sync without an OAuth flow

Many Bubble founders want HubSpot to capture leads from their Bubble landing pages, track deal progress in an internal admin view, or sync customer data between their app and their sales team's CRM. HubSpot's Private App token pattern makes this practical: instead of a multi-step OAuth flow that Bubble cannot orchestrate on its own, you generate a single long-lived token scoped to exactly the CRM resources you need, then paste it into the API Connector as a Private header. From that point, every Bubble workflow that fires on a form submission, a user signup, or a button click can create or update HubSpot records in real time. In the other direction, GET calls to the contacts and deals endpoints power Bubble admin pages where your team can see the full CRM pipeline without switching tabs. The main Bubble-specific gotcha to plan for: HubSpot wraps every contact property inside a nested `properties` object in its JSON responses, so your Bubble data paths need to go two levels deep — `results[0] > properties > email` — rather than the flat structure most APIs return.

## Before you start

- A HubSpot account — the free CRM tier is sufficient for contact and deal management
- A Bubble app on any plan (API Connector calls work on the free plan; Backend Workflow webhooks require a paid Bubble plan)
- The API Connector plugin installed in your Bubble app (Plugins tab → Add plugins → search 'API Connector' by Bubble → Install)
- Basic familiarity with Bubble workflows and repeating groups

## Step-by-step guide

### 1. Step 1 — Create a HubSpot Private App with CRM scopes

Log in to HubSpot and click the settings gear in the top navigation bar. In the left sidebar, scroll down to Integrations and click Private Apps. Click Create a private app in the top right. On the Basic Info tab, give the app a name like 'Bubble Integration' and optionally add a description. Switch to the Scopes tab. Under CRM, check the following permissions based on what your Bubble app will do: `crm.objects.contacts.read` and `crm.objects.contacts.write` are required for any contact operation. Add `crm.objects.deals.read` if you plan to display pipeline data. Add `crm.objects.companies.read` if you want company records. Do not request scopes you do not need — the principle of least privilege protects your HubSpot data if the token is ever compromised. Click Create app. HubSpot generates a token that starts with `pat-` followed by a region code and a long string. Copy this token immediately and store it somewhere safe — HubSpot shows it in full on this screen, and while you can retrieve it again from the Private Apps list later, it is easiest to copy it now. Important: if you add new scopes to the app later, HubSpot regenerates a different token and you must update the Bubble API Connector with the new value. Plan your scope list carefully before creating the app to minimize token rotation events.

**Expected result:** You have a pat- prefixed Private App token copied to your clipboard and a record of the scopes you selected.

### 2. Step 2 — Add the API Connector and configure HubSpot credentials

In your Bubble editor, click the Plugins tab in the left sidebar. Click Add plugins. In the search box, type 'API Connector' and install the plugin named API Connector by Bubble — it is the official one with the blue Bubble icon. After installation, the API Connector appears in your plugins list. Click API Connector to open its configuration panel. Click Add another API. In the API Name field, type 'HubSpot'. In the Authentication dropdown, select None — you will add the token manually as a shared header, which gives you full control over the header name and Private checkbox. In the Shared headers section, click Add a header. Set the key to `Authorization` and the value to `Bearer pat-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` — paste your actual token after the word Bearer, with one space between them. Click the checkbox marked Private next to this header. This is the most critical step: when a header is marked Private, Bubble strips it from any page HTML sent to browsers. Without the Private checkbox, the token appears in your app's client-side network requests and anyone with browser developer tools can read and misuse it. Add a second shared header: key `Content-Type`, value `application/json`. This one does not need to be Private because it contains no sensitive data. Leave the root API base URL field empty for now — you will specify the full URL in each individual call you define.

```
{
  "api_name": "HubSpot",
  "shared_headers": [
    {
      "key": "Authorization",
      "value": "Bearer pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "private": true
    },
    {
      "key": "Content-Type",
      "value": "application/json",
      "private": false
    }
  ]
}
```

**Expected result:** The HubSpot API is configured in the API Connector with the Authorization header marked Private and Content-Type set. No individual calls are defined yet.

### 3. Step 3 — Create the createContact POST call

Inside the HubSpot API you just created, click New call. Name it 'createContact'. Set the method to POST. In the URL field, enter `https://api.hubapi.com/crm/v3/objects/contacts`. Set the Body type to JSON body. In the body editor, enter the structure that HubSpot's v3 contacts endpoint expects — a `properties` object containing the contact fields. The angle-bracket placeholders like `<email>` become dynamic parameters in Bubble, meaning when you invoke this call from a workflow, Bubble shows you input fields for each one to map your Bubble data. Set Use as to Action rather than Data, because this call creates a record rather than fetching data for display. Now click Initialize call. For the initialization, you must supply real values — Bubble actually sends the POST request to HubSpot and requires a genuine successful response (HTTP 201) to save the call and detect the response schema. Use a test email address you own. If the initialize call returns a 409 Conflict, it means that email already exists as a HubSpot contact — delete that contact in HubSpot's Contacts section and try again with a different test address. Once initialization succeeds, Bubble saves the call and makes it available as an action in your workflow editor under Plugins → HubSpot - createContact. The response `id` field (the newly created HubSpot contact ID) is also available for subsequent workflow steps.

```
POST https://api.hubapi.com/crm/v3/objects/contacts

Headers (shared):
  Authorization: Bearer pat-xxxx  [Private]
  Content-Type: application/json

Body:
{
  "properties": {
    "email": "<email>",
    "firstname": "<firstname>",
    "lastname": "<lastname>",
    "phone": "<phone>"
  }
}
```

**Expected result:** The createContact call is saved in the API Connector, initialized with a 201 response. The call appears as an available action when you open the Plugins section of a Bubble workflow.

### 4. Step 4 — Create the searchContacts POST call for duplicate prevention

In the same HubSpot API, click New call again. Name it 'searchContacts'. Set the method to POST. URL: `https://api.hubapi.com/crm/v3/objects/contacts/search`. Set Body type to JSON body. The search endpoint accepts a `filterGroups` array with filter conditions. To search by email, the body structure uses a nested filter object specifying `propertyName`, `operator`, and `value`. The `<search_email>` placeholder is the dynamic value you will pass from your Bubble workflow. Also include a `properties` array to tell HubSpot which contact fields to return in the results — include `hs_object_id` here because you will need it if you later want to PATCH the existing contact. Set Use as to Data, because you will use the results list to drive a condition check in your workflow. Click Initialize call and provide a real email address that exists as a contact in your HubSpot account — Bubble needs a response with actual data to detect the nested `results[0] > properties > email` schema. After initialization, Bubble exposes `results[0]'s id`, `results[0]'s properties > email`, and similar field paths. In your workflow, check 'Result of searchContacts's results:count = 0' to determine whether the contact exists before deciding to create or update.

```
POST https://api.hubapi.com/crm/v3/objects/contacts/search

Headers (shared):
  Authorization: Bearer pat-xxxx  [Private]
  Content-Type: application/json

Body:
{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "email",
          "operator": "EQ",
          "value": "<search_email>"
        }
      ]
    }
  ],
  "properties": ["email", "firstname", "lastname", "phone", "hs_object_id"]
}
```

**Expected result:** The searchContacts call is saved and initialized. You can now reference search result fields like results[0]'s properties > email in Bubble workflow conditions and subsequent steps.

### 5. Step 5 — Wire the form submission workflow with 409 handling

Open the Bubble page where your lead capture form lives. In the workflow editor, click the Submit Button is clicked event (or whichever event triggers your form). Add a first step: Plugins → HubSpot - searchContacts. Set search_email to Input Email's value. This checks whether the contact already exists. Now add a second step with a condition: only when Result of Step 1's results:count = 0. Set this step to Plugins → HubSpot - createContact, and map the input fields — email to Input Email's value, firstname to Input FirstName's value, and so on. For the case where the contact already exists (count greater than 0), add a third step: create a PATCH call in your API Connector named 'updateContact' with URL `https://api.hubapi.com/crm/v3/objects/contacts/<contactId>`, method PATCH, and a body that updates the relevant properties. In this third workflow step, set contactId to Result of Step 1's results[0]'s properties > hs_object_id. After the create or update step, add a visual feedback step — toggle a success group's visibility or show a Bubble alert so the user knows the submission worked. RapidDev's team has built hundreds of Bubble apps with CRM integrations like this — if your form-to-HubSpot pipeline involves complex segmentation or multi-step lead scoring, a free scoping call is available at rapidevelopers.com/contact.

```
Workflow: Submit Button is clicked

Step 1: API Connector → searchContacts
  search_email = Input Email's value

Step 2 (condition: Result of Step 1's results:count = 0):
  API Connector → createContact
    email     = Input Email's value
    firstname = Input FirstName's value
    lastname  = Input LastName's value
    phone     = Input Phone's value

Step 3 (condition: Result of Step 1's results:count > 0):
  API Connector → updateContact  [PATCH]
    contactId = Result of Step 1's results[0]'s properties > hs_object_id
    email     = Input Email's value
    firstname = Input FirstName's value

Step 4:
  Toggle success message group visible
```

**Expected result:** Submitting the form with a new email creates a HubSpot contact and shows a success message. Submitting with an existing email updates the record instead of returning a visible error to the user.

### 6. Step 6 — Display deal pipeline data in a Bubble repeating group

In the same HubSpot API, create a third call. Name it 'getDeals'. Method: GET. URL: `https://api.hubapi.com/crm/v3/objects/deals`. Add a URL parameter key `properties` with value `dealname,amount,dealstage,closedate,hubspot_owner_id` — this tells HubSpot which deal fields to return in the response. Add another URL parameter `limit` with value `100` to control how many deals are returned per page. Set Use as to Data. Click Initialize call — HubSpot must have at least one deal in your account for Bubble to detect the response schema. After initialization, HubSpot returns a `results` array where each item has an `id` and a `properties` object. On your Bubble admin page, add a Repeating Group element. Set its Type of content to External API data and Data source to Get data from an external API → HubSpot getDeals. Bubble maps the `results` list as the repeating group's data set. Inside each row, bind text elements to: Current cell's id (the deal ID), Current cell's properties > dealname (deal name), Current cell's properties > amount (deal value as a string — convert with ':converted to number'), and Current cell's properties > dealstage (internal stage ID like 'appointmentscheduled'). To display human-readable stage names, make a separate GET call to `/crm/v3/pipelines/deals` and store the stage ID-to-name mapping in a Bubble data type for lookup. Keep WU costs in mind: each page load that fetches deals from HubSpot consumes Workload Units. For dashboards viewed frequently by your team, consider caching deal data in a Bubble database table refreshed by a scheduled Backend Workflow (paid plan) rather than fetching live from HubSpot on every visit.

```
GET https://api.hubapi.com/crm/v3/objects/deals
  ?properties=dealname,amount,dealstage,closedate,hubspot_owner_id
  &limit=100

Headers (shared):
  Authorization: Bearer pat-xxxx  [Private]

Sample response:
{
  "results": [
    {
      "id": "12345",
      "properties": {
        "dealname": "Acme Corp Expansion",
        "amount": "5000",
        "dealstage": "appointmentscheduled",
        "closedate": "2026-09-30T00:00:00.000Z",
        "hubspot_owner_id": "67890"
      }
    }
  ],
  "paging": {
    "next": {
      "after": "10"
    }
  }
}
```

**Expected result:** The Bubble admin page repeating group shows a live list of HubSpot deals with deal name, amount, and stage data populated from HubSpot API responses on page load.

## Best practices

- Always mark the Authorization header as Private in the API Connector — without this, the pat- token is visible in browser developer tools and can be used to read or write your entire HubSpot CRM.
- Request only the CRM scopes your Bubble app actually uses when creating the Private App. Adding scopes later regenerates the token, so think through your scope requirements before clicking Create.
- Use the search API (POST /crm/v3/objects/contacts/search) instead of listing all contacts and filtering on the Bubble side. The search endpoint is server-side, returns only matching records, and avoids hitting rate limits from fetching thousands of contacts to find one.
- Cache HubSpot deal stage names from GET /crm/v3/pipelines/deals in a Bubble data type table on app load rather than fetching the pipeline definition on every page view. Stage names change rarely, and the cached lookup eliminates one live API call per repeating group row.
- Enable privacy rules on any Bubble data type that stores HubSpot contact IDs or CRM data. Navigate to Data tab → Privacy and set 'Find in searches' and 'View all fields' rules to restrict access by user role — without this, any logged-in user can query all records through Bubble's built-in data API.
- Log the HubSpot contact ID returned by createContact into the matching Bubble user's record immediately after creation. Future workflows that update, enrich, or associate the contact with a deal need this ID — rebuilding it via a search call each time consumes extra WU and hits rate limits faster.
- Use Bubble's Logs tab → Workflow logs to see the exact API response from HubSpot, including error messages and HTTP status codes. This is your primary debugging tool — do not guess what HubSpot returned; read the actual log entry for the specific workflow run.
- For high-volume lead forms that can receive multiple submissions per minute, consider throttling with a scheduled Backend Workflow queue (paid Bubble plan) rather than firing createContact directly from every form submit event — this prevents WU spikes and rate limit collisions under load.

## Use cases

### Landing page lead capture → HubSpot contact

A Bubble landing page collects email, first name, and company from a signup or waitlist form. On form submit, a Bubble workflow fires the createContact API Connector call, posting the user's data as a new HubSpot contact. Duplicate submissions are handled with a 409 catch that switches to a PATCH update so existing records are enriched rather than rejected.

Prompt example:

```
When the signup form is submitted, run workflow: API Connector → HubSpot createContact (email = Input Email's value, firstname = Input FirstName's value). If the call returns error 409, run: API Connector → HubSpot updateContact with the contact ID from the search result.
```

### Internal deal pipeline dashboard

A Bubble admin page shows a repeating group of HubSpot deals filtered by stage. The page's data source is the HubSpot getDeals API Connector call. Each row displays deal name, owner, amount, and stage label fetched from GET /crm/v3/objects/deals with properties parameters to surface the linked contact name alongside the deal value.

Prompt example:

```
On page load, fetch all open deals via API Connector → HubSpot getDeals (properties=dealname,amount,dealstage,hubspot_owner_id). In the repeating group, show each deal's properties > dealname and a formatted currency value of properties > amount.
```

### Contact lookup before workflow action

Before sending a transactional email or starting an automated sequence, a Bubble workflow searches HubSpot for the user's email address using POST /crm/v3/objects/contacts/search. If the contact exists, the workflow extracts the HubSpot contact ID and proceeds; if not, it first creates the contact. This pattern prevents orphaned records and keeps HubSpot data clean.

Prompt example:

```
In the workflow, step 1: API Connector → HubSpot searchContacts (filterGroups[0].filters[0].propertyName=email, value=Current User's email). Step 2: If result is empty, run createContact; otherwise use result's id for the next action.
```

## Troubleshooting

### API Connector returns a 401 Unauthorized error on every call

Cause: The Authorization header value is missing the 'Bearer ' prefix, the Private App token is incorrect or was regenerated after a scope change, or the Private checkbox is not ticked.

Solution: Open API Connector → HubSpot → shared headers. Confirm the Authorization value is exactly 'Bearer pat-na1-xxxx...' with one space after 'Bearer'. Confirm the Private checkbox is ticked. Copy the token fresh from HubSpot Settings → Integrations → Private Apps and paste it again. If you recently added scopes to the Private App, HubSpot generates a new token — always copy the updated token after any scope change and replace the header value in Bubble.

### createContact returns 409 Conflict: 'Contact already exists'

Cause: A HubSpot contact with that email address already exists. HubSpot's POST create endpoint does not upsert — it returns a 409 error if the email is already registered.

Solution: Use the searchContacts call before createContact to check for an existing record. If the search returns a result (count > 0), switch to a PATCH call to update the existing contact instead. In your Bubble workflow, add two conditional branches: create when count is 0, update when count is greater than 0. The PATCH endpoint requires the contact's internal HubSpot ID, which the search call returns as 'properties > hs_object_id'.

```
PATCH https://api.hubapi.com/crm/v3/objects/contacts/<contactId>

Body:
{
  "properties": {
    "firstname": "<firstname>",
    "lastname": "<lastname>"
  }
}
```

### Repeating group shows no data even though the API call is configured correctly

Cause: HubSpot wraps all contact and deal properties inside a nested 'properties' object. Bubble's default response mapping may expose 'results' as a list but the inner field paths need to go two levels deep.

Solution: After initializing the getContacts or getDeals call with a real account that has existing records, open the API Connector response inspector. Expand 'results', then expand item [0], then expand 'properties' to see the specific field names. In your repeating group cell, reference fields as 'Current cell's properties > email' using the nested path notation. If Bubble does not expose the nested fields automatically, re-initialize the call while HubSpot data is present so Bubble can detect the full schema.

### Initialize call fails with 'There was an issue setting up your call'

Cause: The Initialize call requires a real, successful response from HubSpot. A bad token, an already-existing test contact email, or an invalid JSON body in the API Connector all cause initialization to fail.

Solution: Check three things: (1) your Authorization header has a valid pat- token with the Private checkbox ticked, (2) your test email in the initialize fields does not already exist in HubSpot (delete it in HubSpot's Contacts if needed), and (3) the JSON body in the API Connector is syntactically valid. Use Bubble's API Connector response viewer to read the actual HubSpot error message — it usually specifies which property is invalid or which scope is missing.

### Rate limit errors (429 Too Many Requests) when loading a page with multiple API calls

Cause: HubSpot's rate limit is approximately 100 requests per 10 seconds on most plans. A Bubble repeating group that makes individual API calls per row, or a page that triggers several API Connector calls simultaneously on load, can hit this limit quickly.

Solution: Consolidate data fetching: use the HubSpot search API to retrieve multiple contacts in one call with a filterGroups array rather than fetching each contact individually. For deal pipeline pages, fetch all deals in a single getDeals call with a high limit parameter. For dashboards viewed frequently, cache results in a Bubble database table and refresh the cache on a schedule using a Backend Workflow (paid Bubble plan) — this reduces live API calls and WU consumption.

## Frequently asked questions

### Does the HubSpot Private App token expire?

No — a Private App token does not expire unless you manually rotate it in HubSpot Settings → Integrations → Private Apps → Rotate token. The only other event that regenerates the token is adding or removing scopes on the Private App. Unlike a personal OAuth user token, there is no automatic expiry and no refresh token flow to manage in Bubble.

### Can I use a free HubSpot account with this integration?

Yes. HubSpot's free CRM tier supports contacts, companies, and deals with the REST API. Private Apps are available on all plans including free. The CRM API scopes (crm.objects.contacts.read, crm.objects.contacts.write) are accessible on the free plan. Marketing Hub features like email campaigns and sequences require paid plans, but contact and deal management via the API is free.

### Why does HubSpot return contact properties inside a 'properties' object instead of flat fields?

This is by design in HubSpot's CRM v3 API — every CRM object (contact, deal, company) uses a consistent envelope: { id, properties: { email, firstname, ... }, associations: { ... } }. In Bubble, you navigate this by using two levels of path notation: results[0]'s properties > email. Re-initialize your API Connector calls while HubSpot data is present so Bubble detects all the nested fields automatically.

### Do I need a paid Bubble plan to connect to HubSpot?

No. The API Connector plugin works on Bubble's free plan for outbound API calls — creating contacts, searching, fetching deals. You only need a paid Bubble plan if you want to receive inbound HubSpot webhook events into a Backend Workflow endpoint. Backend Workflows (also called API Workflows) are a paid-plan-only feature in Bubble.

### How do I handle HubSpot pagination when fetching large contact or deal lists?

HubSpot's list endpoints return a paging.next.after cursor when more results are available. To load the next page, pass the 'after' value as a URL parameter in your next API Connector call. In Bubble, store the cursor in a custom state, then trigger a second API call on a 'Load more' button click. For background sync of large datasets, a scheduled Backend Workflow (paid plan) can loop through pages and store results in a Bubble database table.

### Can I display HubSpot data in a Bubble repeating group that updates automatically?

Not via a live WebSocket connection — the API Connector fetches data on demand. The most practical approach is to load the repeating group on page load and add a Refresh button that re-runs the API call. For near-real-time updates, set up a HubSpot webhook pointing to a Bubble Backend Workflow endpoint (paid plan) that writes incoming changes to a Bubble database table. The repeating group then reads from that table, giving you updates within seconds of a HubSpot change.

---

Source: https://www.rapidevelopers.com/bubble-integrations/hubspot
© RapidDev — https://www.rapidevelopers.com/bubble-integrations/hubspot
