---
title: "HubSpot Integration"
description: "The HubSpot integration bridges customer relationship management with link attribution, enabling automated conversion tracking and partner commission generation. By synchronizing CRM contacts and d..."
last_updated: "2026-10-05T05:07:35.154101+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/hubspot-integration"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/app/ee/api/hubspot/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts)
- [apps/web/lib/integrations/hubspot/oauth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts)
- [apps/web/lib/integrations/hubspot/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts)
- [apps/web/app/ee/api/hubspot/webhook/process/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts)
- [apps/web/lib/integrations/hubspot/api.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts)
- [apps/web/app/ee/api/intercom/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts)
- [apps/web/app/ee/api/cron/import/partnerstack/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts)
- [apps/web/lib/integrations/hubspot/track-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/api/oauth/authorize/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/authorize/route.ts)
- [apps/web/app/ee/api/shopify/integration/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts)
- [apps/web/app/ee/api/cron/import/firstpromoter/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts)
- [apps/web/app/api/slack/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/slack/callback/route.ts)
- [apps/web/lib/integrations/hubspot/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/constants.ts)
- [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts)
- [apps/web/lib/integrations/hubspot/ui/settings.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/ui/settings.tsx)
- [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts)
- [packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json)
- [packages/stripe-app/src/views/AppSettings.tsx](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx)
- [packages/stripe-app/src/utils/oauth.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/lib/integrations/hubspot/schema.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts)
- [apps/web/lib/integrations/shopify/create-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts)
- [packages/hubspot-app/src/app/app-hsmeta.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json)
- [apps/web/app/api/oauth/token/exchange-code-for-token.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/simulate-shopify-conversion.ts)
</details>

## Overview

The HubSpot integration bridges customer relationship management with link attribution, enabling automated conversion tracking and partner commission generation. By synchronizing CRM contacts and deals with click metadata, workspaces can attribute leads and revenue directly to referral sources.

Sources: [packages/hubspot-app/src/app/app-hsmeta.json:5-6](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json#L5-L6)

## OAuth Handshake and Installation

### OAuth Handshake and Installation

The HubSpot integration initiates its connection workflow through an OAuth 2.0 authorization process configured via the `hubSpotOAuthProvider` instance. During local development, incoming requests outside of localhost automatically redirect through `http://localhost:8888/api/hubspot/callback`.

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:20-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L20-L28), [apps/web/lib/integrations/hubspot/oauth.ts:100-118](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L100-L118)

### Authorization Configuration and Scopes

The provider establishes connection parameters using specific endpoints and required scopes defined in both the server configuration and the application metadata file.

| Configuration Parameter | Value / Endpoint |
| :--- | :--- |
| Provider Name | `HubSpot` |
| Auth URL | `https://app.hubspot.com/oauth/authorize` |
| Token URL | `https://api.hubapi.com/oauth/v1/token` |
| Redirect URI | `${APP_DOMAIN_WITH_NGROK}/api/hubspot/callback` |
| Redis State Prefix | `hubspot:oauth:state` |
| Required Scopes | `oauth`, `crm.objects.contacts.read`, `crm.objects.contacts.write`, `crm.objects.deals.read`, `crm.schemas.contacts.write` |

Sources: [apps/web/lib/integrations/hubspot/oauth.ts:100-118](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L100-L118), [packages/hubspot-app/src/app/app-hsmeta.json:14-20](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json#L14-L20)

### Callback Execution Walkthrough

When HubSpot redirects back to Dub, the callback route processes the authorization grant and establishes the workspace integration through a strict sequence of validation and persistence steps:

1. `getSession()` verifies the active user session; if no valid user ID is present, a `DubApiError` with code `unauthorized` is thrown.
2. `hubSpotOAuthProvider.exchangeCodeForToken<string>(req)` exchanges the authorization code for an OAuth token and workspace context ID (`workspaceId`).
3. `prisma.project.findUniqueOrThrow()` queries the workspace and validates that the current user is a member (`workspace.users.length === 0` throws `bad_request`) and holds an `owner` role (`workspace.users[0].role !== "owner"` throws `bad_request`).
4. `prisma.integration.findUniqueOrThrow()` fetches the integration record for the `hubspot` slug.
5. `encrypt()` secures both the `access_token` and `refresh_token` fields before storing them alongside `created_at: Date.now()`.
6. `installIntegration()` saves the installed integration configuration to the database using the encrypted credentials.
7. `waitUntil()` dispatches an asynchronous batch creation of contact properties (`HUBSPOT_DUB_CONTACT_PROPERTIES`) using a new `HubSpotApi` instance initialized with the raw access token.
8. `redirect()` sends the user to `/${workspace.slug}/settings/integrations/hubspot`.

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:31-118](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L31-L118)

> [!WARNING]
> Only workspace members possessing the `owner` role are permitted to install the HubSpot integration. Attempts to install by non-owners result in a `bad_request` API error.

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:70-76](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L70-L76)

### Token Validation and Refresh Lifecycle

The `HubSpotOAuthProvider` class manages token longevity and validity checks through dedicated helper methods. A token is evaluated using `isTokenValid()`, which incorporates a 60-second early buffer (`60 * 1000` ms) prior to actual expiration.

```typescript
  isTokenValid(token: HubSpotAuthToken) {
    if (!token.created_at) {
      return false;
    }

    const buffer = 60 * 1000; // refresh 1 min early
    const expiresAt = token.created_at + token.expires_in * 1000;

    return Date.now() < expiresAt - buffer;
  }
```

Sources: [apps/web/lib/integrations/hubspot/oauth.ts:88-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L88-L97)

When an installation requires a refreshed token via `refreshTokenForInstallation()`, the provider parses existing credentials, decrypts them with `decryptOrPassthrough()`, and if validation fails, fetches a new token, re-encrypts the resulting access and refresh tokens, and updates the database record via `prisma.installedIntegration.update()`.

Sources: [apps/web/lib/integrations/hubspot/oauth.ts:16-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L16-L52)

## Contact Property Synchronization

### Contact Property Synchronization

### Overview

Following a successful OAuth installation, Dub initializes tracking properties in HubSpot so that click IDs, short links, and partner attribution emails flow seamlessly into CRM contact records. The `HubSpotApi` client handles communication with HubSpot's v3 CRM API (`https://api.hubapi.com/crm/v3`), managing batch custom property creations, individual contact retrievals, and partial contact updates via HTTP PATCH requests.

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:101-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L101-L112), [apps/web/lib/integrations/hubspot/api.ts:8-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L8-L14)

### Custom Contact Properties

When the OAuth callback completes, `waitUntil()` triggers `createPropertiesBatch()` to register Dub-specific tracking fields under contact object type `0-1`. The definitions are supplied by `HUBSPOT_DUB_CONTACT_PROPERTIES`, which configures three custom properties (`dub_id`, `dub_link`, and `dub_partner_email`) within the standard `contactinformation` group.

| Property Label | Internal Name | Data Type | Field Type | Group Name | Form Field Allowed |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Dub Click ID | `dub_id` | `string` | `text` | `contactinformation` | `true` |
| Dub Link | `dub_link` | `string` | `text` | `contactinformation` | `false` |
| Dub Partner Email | `dub_partner_email` | `string` | `text` | `contactinformation` | `false` |

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:106-111](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L106-L111), [apps/web/lib/integrations/hubspot/constants.ts:17-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/constants.ts#L17-L40)

> [!NOTE]
> The `dub_id` property explicitly enables `formField: true`, permitting it to be rendered directly inside HubSpot forms for automated tracking capture.

Sources: [apps/web/lib/integrations/hubspot/constants.ts:17-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/constants.ts#L17-L25)

### API Client and Contact Update Operations

The `HubSpotApi` class provides core methods for inspecting and updating CRM records. Requests include a Bearer token in the `Authorization` header and automatically apply `Content-Type: application/json` when a request body is present. Responses are parsed and validated against Zod schemas before being returned to callers.

```typescript
  async updateContact({
    contactId,
    properties,
  }: {
    contactId: number | string;
    properties: Record<string, unknown>;
  }) {
    try {
      const result = await this.fetch(`/objects/contacts/${contactId}`, {
        method: "PATCH",
        body: {
          properties,
        },
      });

      return result;
    } catch (error) {
      console.error(
        `[HubSpot] Failed to update contact ${contactId}: ${error}`,
      );
      return null;
    }
  }
```

Sources: [apps/web/lib/integrations/hubspot/api.ts:16-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L16-L53), [apps/web/lib/integrations/hubspot/api.ts:86-108](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L86-L108)

Contact data retrieval via `getContact()` specifically requests properties for email, names, lifecyclestage, and all three Dub tracking fields:

```typescript
  async getContact(contactId: number | string) {
    try {
      const contact = await this.fetch(
        `/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id,dub_link,dub_partner_email,lifecyclestage`,
      );

      return hubSpotContactSchema.parse(contact);
    } catch (error) {
      console.error(
        `[HubSpot] Failed to retrieve contact ${contactId}: ${error}`,
      );
      return null;
    }
  }
```

Sources: [apps/web/lib/integrations/hubspot/api.ts:56-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L56-L69)

## Webhook Ingestion and Verification

### Webhook Subscription Configuration

HubSpot webhook events are managed through metadata definitions specifying target endpoints, concurrency limits, and active CRM object subscriptions. The integration targets `https://app.dub.co/api/hubspot/webhook` with a maximum concurrency of 10 requests. Subscriptions monitor CRM objects (`contact` and `deal`) for creations and property changes.

| Subscription Type | Object Type | Property Name | Active |
| :--- | :--- | :--- | :--- |
| `object.creation` | `contact` | — | `true` |
| `object.creation` | `deal` | — | `true` |
| `object.propertyChange` | `deal` | `dealstage` | `true` |
| `object.propertyChange` | `contact` | `lifecyclestage` | `true` |

Sources: [packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json:4-34](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json#L4-L34)

### Signature Verification and Event Fan-Out

Incoming webhooks at `/api/hubspot/webhook` are received as raw text. The endpoint validates the `X-HubSpot-Signature` header against the `HUBSPOT_CLIENT_SECRET` environment variable by generating a SHA-256 hash of the concatenated secret and raw request body, comparing them using `timingSafeCompare`.

```typescript
    const rawBody = await req.text();
    const signature = req.headers.get("X-HubSpot-Signature");

    if (!signature) {
      throw new DubApiError({
        code: "bad_request",
        message: "Missing X-HubSpot-Signature header.",
      });
    }

    if (!HUBSPOT_CLIENT_SECRET) {
      throw new DubApiError({
        code: "internal_server_error",
        message: "Missing HUBSPOT_CLIENT_SECRET environment variable.",
      });
    }

    const sourceString = HUBSPOT_CLIENT_SECRET + rawBody;
    const expectedHash = crypto
      .createHash("sha256")
      .update(sourceString)
      .digest("hex");

    if (!timingSafeCompare(signature, expectedHash)) {
      throw new DubApiError({
        code: "unauthorized",
        message: "Invalid webhook signature.",
      });
    }
```

Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:13-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L13-L45)

> [!WARNING]
> Requests lacking the `X-HubSpot-Signature` header or failing constant-time comparison are rejected immediately with `bad_request` or `unauthorized` error codes before any JSON parsing occurs.

Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:18-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L18-L45)

Because HubSpot can dispatch multiple events within a single HTTP payload, the endpoint normalizes the body into an array and fans out each event independently using `enqueueBatchJobs` via QStash. This ensures slow or failing events do not block processing for the rest of the batch.

```typescript
    const events = JSON.parse(rawBody) as any[];
    const finalEvents = Array.isArray(events) ? events : [events];

    const qstashResponse = await enqueueBatchJobs(
      finalEvents.map((event) => ({
        queueName: "process-hubspot-webhook",
        url: `${APP_DOMAIN_WITH_NGROK}/api/hubspot/webhook/process`,
        deduplicationId: event.eventId,
        method: "POST",
        body: event,
      })),
    );
```

Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:47-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L47-L62)

### Asynchronous Queue Processing

Individual webhook events dispatched by QStash arrive at `/api/hubspot/webhook/process`. The processing flow executes the following sequence:
`req.text()` → `verifyQstashSignature()` → `hubSpotWebhookSchema.parse()` → `prisma.installedIntegration.findFirst()` → `hubSpotOAuthProvider.refreshTokenForInstallation()` → `getHubSpotEventAction()` → event routing (`trackHubSpotLeadEvent` / `trackHubSpotSaleEvent`) → `captureWebhookLog()`.

```typescript
  try {
    const rawBody = await req.text();

    await verifyQstashSignature({
      req,
    });

    body = JSON.parse(rawBody);

    const { objectTypeId, portalId, subscriptionType } =
      hubSpotWebhookSchema.parse(body);

    const installation = await prisma.installedIntegration.findFirst({
      where: {
        integration: {
          slug: "hubspot",
        },
        credentials: {
          path: "$.hub_id",
          equals: portalId,
        },
      },
      include: {
        project: {
          select: {
            id: true,
            stripeConnectId: true,
            webhookEnabled: true,
          },
        },
      },
    });
```

Sources: [apps/web/app/ee/api/hubspot/webhook/process/route.ts:27-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts#L27-L60)

| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| **Batch fan-out via QStash** | Isolates failures per event; prevents batch-wide transaction rollbacks | Higher HTTP request volume and dependency on QStash service |
| **Constant-time signature check** | Mitigates timing attacks during HMAC signature verification | Requires explicit byte-length handling or utility wrappers |
| **Prisma JSON path credential lookup** | Directly queries encrypted portal IDs within stored credential JSON blobs | Couples database schema queries to JSON structure paths (`$.hub_id`) |

Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:50-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L50-L62), [apps/web/app/ee/api/hubspot/webhook/route.ts:40-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L40-L45), [apps/web/app/ee/api/hubspot/webhook/process/route.ts:40-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts#L40-L49)

## Lead and Conversion Tracking

### Overview

Lead and conversion tracking in Dub parses incoming HubSpot contact and deal webhook payloads, extracts associated click metadata (`dub_id`), and records conversion events against workspace projects using the core `trackLead` API. Incoming events are routed based on `objectTypeId`, `subscriptionType`, and the configured `leadTriggerEvent` settings.

Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:10-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L10-L31), [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:5-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L5-L16)

### Event Action Resolution

The function `getHubSpotEventAction` inspects incoming webhook properties to determine whether an event should trigger lead tracking, sale tracking, or be skipped entirely. 

```typescript
export function getHubSpotEventAction({
  event,
  settings,
}: {
  event: Pick<
    z.infer<typeof hubSpotLeadEventSchema>,
    "objectTypeId" | "subscriptionType" | "propertyName" | "propertyValue"
  >;
  settings: z.infer<typeof hubSpotSettingsSchema>;
}): "trackLead" | "trackSale" | "skip" {
  const { objectTypeId, subscriptionType, propertyName, propertyValue } = event;
  const { leadTriggerEvent } = settings;

  const isCreated = subscriptionType === "object.creation";
  const isPropertyChanged = subscriptionType === "object.propertyChange";
```

Sources: [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:5-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L5-L20)

| Object Type ID (`objectTypeId`) | Subscription Type (`subscriptionType`) | Property Filter / Condition | Action Result |
| :--- | :--- | :--- | :--- |
| `0-1` (Contacts) | `object.creation` | None | `trackLead` (Deferred) |
| `0-1` (Contacts) | `object.propertyChange` | `leadTriggerEvent === "lifecycleStageReached"` | `trackLead` |
| `0-3` (Deals) | `object.creation` | `leadTriggerEvent === "dealCreated"` | `trackLead` |
| `0-3` (Deals) | `object.propertyChange` | `propertyName === "dealstage"` && `newDealStage === leadDealStageId` | `trackLead` |
| `0-3` (Deals) | `object.propertyChange` | `propertyName === "dealstage"` && `newDealStage === closedWonDealStageId` | `trackSale` |

Sources: [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:21-66](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L21-L66)

### Lead Event Processing Call Chain

When processing a HubSpot webhook event for lead creation or stage progression, `trackHubSpotLeadEvent` coordinates validation, CRM fetching, customer lookups, and tracking API calls.

The execution walkthrough follows this path:
`trackHubSpotLeadEvent()` → `hubSpotLeadEventSchema.parse()` → `hubSpotApi.getContact()` / `hubSpotApi.getDeal()` → `prisma.customer.findFirst()` → `trackLead()` → `updateHubSpotContact()`.

```typescript
    const trackLeadResult = await trackLead({
      clickId: properties.dub_id,
      eventName: "Sign up",
      customerEmail: properties.email,
      customerExternalId: properties.email,
      customerName,
      mode: "deferred",
      workspace,
      source: "hubspot",
      commissionSource: CommissionSource.hubspot,
    });
```

Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:10-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L10-L61)

> [!WARNING]
> Deferred lead tracking on contact creation (`objectTypeId === "0-1"` and `subscriptionType === "object.creation"`) requires the contact property `dub_id` to be present. If `dub_id` is missing from the retrieved contact properties, the tracking execution terminates early with a descriptive string response.

Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:33-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L33-L45)

### Deal-Associated Final Lead Tracking

For deal-triggered lead events (`objectTypeId === "0-3"`), `trackFinalLead` retrieves the deal object, extracts associated contacts from `deal.associations.contacts.results`, and fetches contact details to associate the conversion with an existing customer record in Prisma.

```typescript
const trackFinalLead = async ({
  dealId,
  workspace,
  hubSpotApi,
}: {
  dealId: number;
  workspace: Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">;
  hubSpotApi: HubSpotApi;
}) => {
  const deal = await hubSpotApi.getDeal(dealId);

  if (!deal) {
    return `No deal found for deal ${dealId}.`;
  }

  const { properties, associations } = deal;

  const contact = associations?.contacts?.results?.[0];

  if (!contact) {
    return `No contact found for deal ${dealId}.`;
  }

  const contactInfo = await hubSpotApi.getContact(contact.id);
```

Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:180-210](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L180-L210)

## Deal Ingestion and Sale Attribution

### Deal Ingestion and Sale Attribution

When a deal webhook event matches the configured closed-won criteria, HubSpot deal and contact properties are parsed to attribute and record revenue conversion sales through Dub's internal tracking services.

### Deal Sale Processing Call Chain

The execution path for processing a closed-won sale event coordinates webhook verification, deal retrieval, contact association lookups, customer identification, and sale recording:
`POST` (route handler) → `trackHubSpotSaleEvent()` → `hubSpotSaleEventSchema.parse()` → `hubSpotApi.getDeal()` → `hubSpotApi.getContact()` → `prisma.customer.findFirst()` → `trackSale()`.

```typescript
  const deal = await hubSpotApi.getDeal(objectId);

  if (!deal) {
    return `No deal found for deal ${objectId}`;
  }

  const { id: dealId, properties, associations } = deal;

  if (!properties.amount) {
    return `Amount is not set for deal ${dealId}`;
  }
```

Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:42-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts#L42-L52)

### Deal and Contact Schema Properties

HubSpot deals and associated contacts are validated against strict Zod schemas before revenue amounts and metadata are extracted for attribution.

| Schema Model | Property Field | Type | Description |
| :--- | :--- | :--- | :--- |
| `hubSpotDealSchema` | `id` | `string` | Unique HubSpot deal identifier |
| `hubSpotDealSchema` | `properties.dealname` | `string` | Name of the CRM deal |
| `hubSpotDealSchema` | `properties.amount` | `string \| null` | Monetary value of the deal |
| `hubSpotDealSchema` | `properties.dealstage` | `string` | Current pipeline stage ID of the deal |
| `hubSpotDealSchema` | `associations.contacts` | `object` | Associated contact references on the deal |
| `hubSpotSaleEventSchema` | `subscriptionType` | `literal("object.propertyChange")` | Restricted to property change events |
| `hubSpotSaleEventSchema` | `propertyName` | `literal("dealstage")` | Restricted to deal stage property updates |

Sources: [apps/web/lib/integrations/hubspot/schema.ts:62-81](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts#L62-L81), [apps/web/lib/integrations/hubspot/schema.ts:98-103](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts#L98-L103)

> [!WARNING]
> Sale attribution via `trackHubSpotSaleEvent` enforces strict filter validation: the incoming event must have `subscriptionType === "object.propertyChange"`, `propertyName === "dealstage"`, and a `propertyValue` matching the workspace's configured `closedWonDealStageId` (defaulting to `"closedwon"` case-insensitively).

Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:24-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts#L24-L36)

### Customer Resolution and Sale Recording

Once the deal and contact info are fetched, `trackHubSpotSaleEvent` queries Prisma to locate the corresponding customer by matching email or external IDs against contact properties, and then invokes `trackSale` with converted cent amounts and HubSpot metadata.

```typescript
  await trackSale({
    customerExternalId: customer.externalId!,
    amount: Number(properties.amount) * 100,
    eventName: `${properties.dealname} ${properties.dealstage}`,
    paymentProcessor: "custom",
    invoiceId: dealId,
    workspace,
    metadata: {
      hubspotDealId: dealId,
      hubspotContactId: contactInfo.id,
    },
    source: "hubspot",
    commissionSource: CommissionSource.hubspot,
  });
```

Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:84-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts#L84-L97)

## Integration Settings and Management

### Integration Settings and Management

### Overview

Workspace administrators configure and manage the HubSpot integration preferences directly through the Dub workspace UI. The `HubSpotSettings` component allows users to choose lead attribution trigger events, configure stage identifiers, and save preferences via server actions, while token lifecycle operations handle token validity checks, secure decryption, and portal uninstallation.

Sources: [apps/web/lib/integrations/hubspot/ui/settings.tsx:17-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/ui/settings.tsx#L17-L43), [apps/web/lib/integrations/hubspot/oauth.ts:16-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L16-L52)

### Settings Schema Options

The integration settings are validated and typed using Zod schemas, defining default behaviors and constraints for event triggers and stage identifiers.

| Setting Field | Type | Default Value | Description |
| :--- | :--- | :--- | :--- |
| `leadTriggerEvent` | `enum` | `"dealCreated"` | Event that triggers final lead tracking for the contact (`lifecycleStageReached`, `dealCreated`, `dealStageReached`) |
| `leadLifecycleStageId` | `string \| null` | `null` | Contact lifecycle stage ID representing a qualified lead (used when trigger is `lifecycleStageReached`) |
| `leadDealStageId` | `string \| null` | `null` | Deal stage ID representing a qualified lead (used when trigger is `dealStageReached`) |
| `closedWonDealStageId` | `string \| null` | `"closedwon"` | Deal stage ID representing a closed won deal |

Sources: [apps/web/lib/integrations/hubspot/schema.ts:18-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts#L18-L46)

### Preference Update Execution Flow

Updating integration settings follows a secure server action pipeline from client interaction through validation and database persistence:
`HubSpotSettings` (UI form submit) → `updateHubSpotSettingsAction` (auth action client) → schema extension & rule validation → `prisma.installedIntegration.findFirst()` → `prisma.installedIntegration.update()` → `revalidatePath()`.

```typescript
export const updateHubSpotSettingsAction = authActionClient
  .inputSchema(schema)
  .action(async ({ parsedInput, ctx }) => {
    const { workspace } = ctx;
    const {
      leadTriggerEvent,
      leadLifecycleStageId,
      leadDealStageId,
      closedWonDealStageId,
    } = parsedInput;

    if (leadTriggerEvent === "dealStageReached") {
      if (!leadDealStageId) {
        throw new Error("Lead deal stage ID is required.");
      }

      if (
        leadDealStageId.toLowerCase() === closedWonDealStageId?.toLowerCase()
      ) {
        throw new Error(
          "Lead deal stage ID must be different from the closed won deal stage ID.",
        );
      }
    }
```

Sources: [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts:14-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts#L14-L37)

> [!WARNING]
> When `leadTriggerEvent` is set to `"dealStageReached"`, the server action strictly enforces that `leadDealStageId` is provided and that it differs case-insensitively from `closedWonDealStageId`.

Sources: [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts:25-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts#L25-L36)

### Token Lifecycle Management

The `HubSpotOAuthProvider` manages authentication token validity, decryption of credentials retrieved from the database, automatic refreshing of expired tokens with a 60-second buffer, and uninstallation requests sent to HubSpot's external install API.

```typescript
  async refreshTokenForInstallation(
    installation: InstalledIntegration,
  ): Promise<HubSpotAuthToken> {
    let token = hubSpotAuthTokenSchema.parse(installation.credentials);

    token = {
      ...token,
      access_token: decryptOrPassthrough(token.access_token),
      refresh_token: decryptOrPassthrough(token.refresh_token),
    };

    if (this.isTokenValid(token)) {
      return token;
    }

    const newToken = await this.refreshToken(token.refresh_token);

    const credentials = {
      ...newToken,
      created_at: Date.now(),
    };

    await prisma.installedIntegration.update({
      where: {
        id: installation.id,
      },
      data: {
        credentials: {
          ...credentials,
          access_token: encrypt(credentials.access_token),
          refresh_token: encrypt(credentials.refresh_token),
        },
      },
    });

    return credentials;
  }
```

Sources: [apps/web/lib/integrations/hubspot/oauth.ts:16-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L16-L52)

> [!TIP]
> `isTokenValid` evaluates token expiration using a 60-second safety buffer (`const buffer = 60 * 1000`) before the actual `expires_in` timestamp elapses, refreshing tokens proactively to prevent mid-request expiration errors.

Sources: [apps/web/lib/integrations/hubspot/oauth.ts:88-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L88-L97)

## Related

- [Conversion and Event Tracking](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-and-tracking/conversion-and-event-tracking)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
