# HubSpot
> Load HubSpot contacts, companies, deals, and pipelines into MotherDuck on a schedule with a Flight that runs dlt's HubSpot source.
HubSpot is a CRM platform covering marketing, sales, and service. Its CRM objects, contacts, companies, deals, and tickets, are what most revenue and funnel analysis is built on. To analyze them in MotherDuck, run [dlt](https://dlthub.com/)'s HubSpot source and load them into a MotherDuck database.

## How it works with MotherDuck

dlt ships a [HubSpot verified source](https://dlthub.com/docs/dlt-ecosystem/verified-sources/hubspot) that reads the CRM v3 API and resolves object properties for you, and you can run it in a **[Flight](/concepts/flights)**, so MotherDuck runs the pipeline on a schedule with no infrastructure of your own.

The source exposes these resources:

| Resource | Contents |
|---|---|
| `contacts`, `companies`, `deals`, `tickets`, `products`, `quotes` | Core CRM objects with their properties. |
| `owners` | Users who can own records. |
| `pipelines_deals`, `pipelines_tickets` | Pipeline and stage definitions, for labeling stage IDs on records. |
| `stages_timing_deals`, `stages_timing_tickets` | Time each record spent in each pipeline stage. |
| `properties` | Custom-label metadata. Ships empty, see [Known limitations](#known-limitations). |

All twelve load by default, so use `with_resources()` to narrow the set. A separate `hubspot_events_for_objects` resource pulls web analytics events for a specific list of object IDs.

## Prerequisites

- A [MotherDuck account](https://app.motherduck.com) on a plan that includes Flights.
- A HubSpot [private app access token](https://developers.hubspot.com/docs/guides/apps/private-apps/overview). HubSpot retired plain API keys, so a private app token or OAuth token is the only option.
- Read scopes on the objects you want, such as `crm.objects.contacts.read`, `crm.objects.companies.read`, and `crm.objects.deals.read`. Scope the token to reads only: ingestion never writes back.
- A target database in MotherDuck. The examples use `hubspot`.

## Store the token as a Flight secret

The token is a credential, so it belongs in a [Flight secret](/key-tasks/flights/flights-authentication-config-and-secrets#secrets-sensitive-environment-variables) rather than the Flight's `config` map. Name the key after dlt's own config variable so dlt resolves it without any glue code in your Flight.

The secret has to exist before you create the Flight, otherwise `MD_CREATE_FLIGHT` rejects the reference with `user_secret not found`.

The quickest way is a pre-filled dialog. This link opens **Add secret** with the type, name, and parameter row already set, so you only paste the token:

**[Create the `hubspot` Flight secret in your own MotherDuck account](https://app.motherduck.com/settings/secrets?action=create&type=flights&name=hubspot&params=SOURCES__HUBSPOT__API_KEY)**.

You can also open [Settings > Secrets](https://app.motherduck.com/settings/secrets) and add it by hand with type **Flights**, or use SQL from a write-enabled connection:

```sql
CREATE SECRET hubspot IN motherduck (
    TYPE flights,
    PARAMS MAP {
        'SOURCES__HUBSPOT__API_KEY': '<your_private_app_token>'
    }
);
```

To keep the literal token out of your SQL and shell history, run that statement from the duckdb CLI, where `getenv()` resolves client-side:

```sql
CREATE SECRET hubspot IN motherduck (
    TYPE flights,
    PARAMS MAP {
        'SOURCES__HUBSPOT__API_KEY': getenv('HUBSPOT_PRIVATE_APP_TOKEN')
    }
);
```

:::note
The key is named `API_KEY` because that's the argument name in dlt's source, not because HubSpot API keys still work. The value must be a private app or OAuth access token.
:::

## Create the Flight

The HubSpot source isn't a single file, so install it as a dependency instead of pasting it into `source_code`. See [Use a dlt verified source](/key-tasks/flights/packages-and-runtime#use-a-dlt-verified-source) for how this works and what to watch for.

HubSpot needs no packages beyond dlt itself:

```text
duckdb==1.5.5
dlt[motherduck]==1.30.0
dlt-verified-sources @ https://github.com/dlt-hub/verified-sources/archive/3957506893a7da821dbcc6acd51c7ca4475d1f53.tar.gz
```

That commit is a known-good pin. Check [the commit history](https://github.com/dlt-hub/verified-sources/commits/master) for a newer one, and keep a SHA rather than `master.tar.gz`: a Flight reinstalls its dependencies on every run, so an unpinned URL can change the connector between two runs of a Flight you haven't touched.

`MOTHERDUCK_TOKEN` is injected for you, so dlt's MotherDuck destination picks up the credential without configuration.

```python
import os

import dlt
import duckdb

from sources.hubspot import hubspot

DB = "hubspot"

def main():
    os.environ.setdefault("HOME", "/tmp")
    os.environ["DESTINATION__MOTHERDUCK__CREDENTIALS__DATABASE"] = DB

    # dlt attaches the database but never creates it, so make sure it exists.
    duckdb.connect("md:").execute(f'CREATE DATABASE IF NOT EXISTS "{DB}"')

    pipeline = dlt.pipeline(
        pipeline_name="hubspot",
        destination="motherduck",
        dataset_name="hubspot_raw",
    )

    source = hubspot(include_custom_props=True).with_resources(
        "contacts",
        "companies",
        "deals",
        "owners",
        "pipelines_deals",
    )

    print(pipeline.run(source, loader_file_format="parquet"))

if __name__ == "__main__":
    main()
```

Create the Flight with [`MD_CREATE_FLIGHT`](/sql-reference/motherduck-sql-reference/flights/md-create-flight), passing that Python as `source_code`, the pinned dependencies as `requirements_txt`, and `flight_secret_names := ['hubspot']` so the token reaches the run. Leave `schedule_cron` off until a manual [`MD_RUN_FLIGHT`](/sql-reference/motherduck-sql-reference/flights/md-run-flight) succeeds, then add a schedule with [`MD_UPDATE_FLIGHT`](/sql-reference/motherduck-sql-reference/flights/md-update-flight).

## Query the result

dlt creates one table per resource in the `hubspot_raw` schema. Join deals to their pipeline definitions to turn stage IDs into labels:

```sql
SELECT
    stages.label AS stage,
    count(*) AS deals,
    sum(deals.amount) AS total_amount
FROM hubspot.hubspot_raw.deals AS deals
JOIN hubspot.hubspot_raw.pipelines_deals__stages AS stages
    ON deals.dealstage = stages.id
GROUP BY ALL
ORDER BY total_amount DESC;
```

## Source options

| Argument | Default | Effect |
|---|---|---|
| `include_custom_props` | `true` | Add every custom property to whatever the connector already selects. "Custom" here means any property whose name doesn't start with `hs_`, which also covers HubSpot-native fields like `amount` and `email`. |
| `properties` | `None` | Per-object property lists, keyed by the **singular** object type: `{"deal": [...], "contact": [...]}`. Replaces the connector's defaults outright rather than merging with them. |
| `include_history` | `false` | Also load property change history into `{resource}_property_history` tables. |
| `soft_delete` | `false` | Load archived records with a deleted flag instead of dropping them. |

## Known limitations

- **`include_history=True` multiplies the row count.** It adds one row per property change per record. Turn it on only for the objects you need it for, and expect a much longer first load.
- **Narrowing properties takes two arguments, not one.** A portal with hundreds of custom properties makes the batch reads large and slow, but a `properties` list doesn't shrink them on its own. With the default `include_custom_props=True`, the connector unions your list with every custom property it finds, so asking for two properties on a portal with 200 custom ones still selects 202. Pass `include_custom_props=False` alongside `properties` to get only what you asked for.
- **A `properties` dict has to cover every object you load.** Because it replaces the defaults instead of merging with them, an object type you leave out reaches the fetch with `None` and the run fails with `TypeError: 'NoneType' object is not iterable`. A property name that doesn't exist in your portal fails earlier, with `ValueError: The requested props {...} don't exist in the source!`.
- **The `properties` resource loads nothing.** It reads a `PROPERTIES_WITH_CUSTOM_LABELS` list in the connector's `settings.py`, which ships empty, so the resource yields no rows and dlt creates no table for it. Populating that list means editing the connector, which a dependency install rules out. Read property metadata from HubSpot's [properties API](https://developers.hubspot.com/docs/guides/api/crm/properties) instead.
- **Deleted records are absent by default.** Without `soft_delete=True`, a record deleted in HubSpot vanishes from the next load with no trace of when it went. With it on, archived records load with an `is_deleted` flag.
- **Daily API quotas apply per account.** A scheduled load competes with everything else using the same token. Give ingestion its own private app so you can see and limit its usage.
- **The connector isn't editable when installed as a dependency.** To change extraction logic, use `dlt init hubspot motherduck` in a local project.

## Send data back to HubSpot

To go the other direction and drive HubSpot from MotherDuck data, see [Update a HubSpot list from a MotherDuck query with a Flight](/cookbook/flight-hubspot-list-sync), which reconciles a static contact list against a query result.

## Managed alternatives

If you'd rather not run the pipeline yourself, [Fivetran](/integrations/ingestion/fivetran) and [Airbyte](/integrations/ingestion/airbyte) both offer a HubSpot source and a MotherDuck destination.

## Related content

- [Load data with dlt from HubSpot to MotherDuck](https://dlthub.com/docs/pipelines/hubspot/load-data-with-python-from-hubspot-to-motherduck)
- [dlt HubSpot verified source reference](https://dlthub.com/docs/dlt-ecosystem/verified-sources/hubspot)
- [Run a dlt ingest pipeline in a Flight](/key-tasks/flights/run-dlt-ingest-pipeline)
- [Packages and recommended libraries](/key-tasks/flights/packages-and-runtime)
- [HubSpot private apps](https://developers.hubspot.com/docs/guides/apps/private-apps/overview)


---

## Docs feedback

MotherDuck accepts optional user-submitted feedback about this page at `GET https://motherduck.com/docs/api/feedback/agent`.
For agents and automated tools, feedback submission should be user-confirmed before sending.

URL-encode query parameter values and send a GET request:

```text
GET https://motherduck.com/docs/api/feedback/agent?page_path=%2Fintegrations%2Fingestion%2Fhubspot%2F&page_title=HubSpot&text=<url-encoded user feedback, max 2000 characters>
```

Optionally append `&source=<url-encoded interface identifier>` such as `claude.ai` or `chatgpt`.

`page_path` and `text` are required; `page_title` and `source` are optional. Responses: `200 {"feedback_id": "<uuid>"}`, `400` for malformed query parameters, and `429` when rate-limited.
