---
title: "HubSpot & Intercom"
description: "Dub features integrations with HubSpot and Intercom to synchronize CRM data, attribute conversions, and route partner communications directly within workspaces. The HubSpot integration handles OAut..."
last_updated: "2026-10-12T07:00:10.142659+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/integrations-subsystem/hubspot-intercom"
---

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

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

- [apps/web/app/ee/api/intercom/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/route.ts)
- [apps/web/app/ee/api/intercom/callback/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/callback/route.ts)
- [apps/web/lib/integrations/hubspot/track-lead.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts)
- [apps/web/app/ee/api/intercom/webhook/process/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/route.ts)
- [apps/web/app/ee/api/hubspot/webhook/process/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts)
- [apps/web/app/ee/api/hubspot/callback/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/callback/route.ts)
- [apps/web/lib/integrations/intercom/forward-message.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/forward-message.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts)
- [apps/web/app/ee/api/cron/import/partnerstack/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts)
- [apps/web/app/api/ai/support-chat/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/ai/support-chat/route.ts)
- [apps/web/app/ee/api/intercom/webhook/health-check/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/health-check/route.ts)
- [apps/web/app/ee/api/cron/import/tolt/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/tolt/route.ts)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/ee/api/cron/import/firstpromoter/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts)
- [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts)
- [apps/web/lib/integrations/intercom/client.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/client.ts)
- [apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts)
- [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts)
- [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts)
- [apps/web/lib/integrations/hubspot/track-sale.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-sale.ts)
- [apps/web/lib/integrations/hubspot/constants.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/constants.ts)
- [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts)
- [apps/web/lib/integrations/intercom/schema.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/schema.ts)
- [apps/web/lib/integrations/hubspot/oauth.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/oauth.ts)
- [apps/web/lib/integrations/hubspot/ui/settings.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/ui/settings.tsx)
- [apps/web/app/ee/api/intercom/webhook/verify-webhook-signature.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts)
- [apps/web/lib/integrations/hubspot/schema.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/schema.ts)
- [apps/web/lib/integrations/hubspot/api.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/api.ts)
- [apps/web/scripts/dev/seed-integration.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/dev/seed-integration.ts)
</details>

## Overview

Dub features integrations with HubSpot and Intercom to synchronize CRM data, attribute conversions, and route partner communications directly within workspaces. The HubSpot integration handles OAuth authentication, automatic property provisioning, and webhook event processing via QStash to track leads and closed-won sales from contact and deal objects. Concurrently, the Intercom integration manages app installation workflows, HMAC signature verification, health checks, and bi-directional message routing between partners and workspace admins with attachment synchronization.

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:15-112](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/callback/route.ts#L15-L112), [apps/web/app/ee/api/hubspot/webhook/route.ts:12-69](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L12-L69), [apps/web/app/ee/api/intercom/callback/route.ts:16-125](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/callback/route.ts#L16-L125), [apps/web/app/ee/api/intercom/webhook/route.ts:12-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/route.ts#L12-L41), [apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts:33-133](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts#L33-L133), [apps/web/lib/integrations/hubspot/api.ts:111-134](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/api.ts#L111-L134)

## HubSpot OAuth and Schema Configuration

### OAuth Flow and Installation

The HubSpot OAuth implementation uses `HubSpotOAuthProvider` configured with specific authentication parameters, token endpoints, and requested permission scopes. During the OAuth callback at `/api/hubspot/callback`, authorization codes are exchanged for access and refresh tokens, workspace membership is verified, and only workspace owners are permitted to install the integration.

```typescript
export const hubSpotOAuthProvider = new HubSpotOAuthProvider({
  name: "HubSpot",
  clientId: process.env.HUBSPOT_CLIENT_ID!,
  clientSecret: process.env.HUBSPOT_CLIENT_SECRET!,
  authUrl: "https://app.hubspot.com/oauth/authorize",
  tokenUrl: "https://api.hubapi.com/oauth/v1/token",
  redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/hubspot/callback`,
  redisStatePrefix: "hubspot:oauth:state",
  scopes: [
    "oauth",
    "crm.objects.contacts.read",
    "crm.objects.contacts.write",
    "crm.objects.deals.read",
    "crm.schemas.contacts.write",
  ].join(" "),
  tokenSchema: hubSpotAuthTokenSchema,
  bodyFormat: "form",
  authorizationMethod: "body",
});
```
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:100-118](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/oauth.ts#L100-L118)

> [!WARNING]
> Only users with the `owner` role within a workspace are authorized to complete the HubSpot installation flow and store encrypted tokens.
Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:70-76](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/callback/route.ts#L70-L76)

### Token Refresh Lifecycle

Token validation checks whether the current token has expired, incorporating a one-minute buffer (`60 * 1000` ms) before expiration to preemptively refresh credentials. When a token is invalid or expired, `refreshTokenForInstallation` requests a new token set from HubSpot, encrypts both access and refresh tokens before updating the database record.

```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/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/oauth.ts#L88-L97)

Uninstalling the HubSpot integration invokes an external deletion request against the HubSpot app installs API using a valid bearer token.
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:54-86](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/oauth.ts#L54-L86)

### Custom Property Provisioning

Upon successful integration installation, Dub automatically provisions custom contact properties in HubSpot via a batch creation request to store attribution identifiers (`dub_id`, `dub_link`, and `dub_partner_email`).

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

Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:101-112](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/callback/route.ts#L101-L112), [apps/web/lib/integrations/hubspot/constants.ts:17-40](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/constants.ts#L17-L40)

## HubSpot Webhook Ingestion and Dispatch

### Signature Validation and Payload Fan-Out

HubSpot webhooks arrive at `/api/hubspot/webhook` carrying an `X-HubSpot-Signature` header. The route extracts the raw request body and verifies authenticity by hashing the concatenated string of `HUBSPOT_CLIENT_SECRET` and the raw body using SHA-256 (`crypto.createHash("sha256")`), comparing the resulting hex digest against the incoming signature header through constant-time comparison (`timingSafeCompare`). If the signature is absent, the environment secret is missing, or the comparison fails, a `DubApiError` (`bad_request`, `internal_server_error`, or `unauthorized`) is thrown and handled by `handleAndReturnErrorResponse`.

```typescript
export const POST = withAxiom(async (req) => {
  try {
    const rawBody = await req.text();
    const signature = req.headers.get("X-HubSpot-Signature");

    // Verify webhook 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.",
      });
    }

    // Create expected hash: client_secret + request_body
    const sourceString = HUBSPOT_CLIENT_SECRET + rawBody;
    const expectedHash = crypto
      .createHash("sha256")
      .update(sourceString)
      .digest("hex");

    // Compare with provided signature using constant-time comparison
    if (!timingSafeCompare(signature, expectedHash)) {
      throw new DubApiError({
        code: "unauthorized",
        message: "Invalid webhook signature.",
      });
    }

```
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:12-46](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L12-L46)

Once verified, the payload is parsed as an array (or normalized to a single-element array if received as an object). Because HubSpot can transmit multiple events in a single HTTP request, the endpoint fans out the events to QStash by invoking `enqueueBatchJobs`. Each enqueued job targets `/api/hubspot/webhook/process` via a POST request with the queue name `"process-hubspot-webhook"`, using `event.eventId` as the deduplication ID.

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

// HubSpot can send multiple events in a single request, so we fan them out
// to QStash and process each event independently in /api/hubspot/webhook/process.
// This keeps the webhook handler fast and ensures a slow/failing event doesn't
// block or fail the rest of the batch.
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,
  })),
);

console.log(
  `[hubspot/webhook] Enqueued ${finalEvents.length} webhook events to be processed.`,
  qstashResponse,
);

return logAndRespond("Webhook received.");
```
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:47-69](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L47-L69)

> [!WARNING]
> Fanning out webhook batches individually to QStash ensures that a slow or failing event in a batch does not block or fail the remaining events.
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:50-53](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L50-L53)

### Event Processing and Action Routing

The `/api/hubspot/webhook/process` route receives each individual event from QStash, verifies the incoming QStash signature using `verifyQstashSignature`, and parses the body via `hubSpotWebhookSchema` to extract `objectTypeId`, `portalId`, and `subscriptionType`. It queries Prisma for an installed installation where the credential path `$.hub_id` matches the `portalId` and the integration slug is `"hubspot"`, loading the associated workspace project (`id`, `stripeConnectId`, `webhookEnabled`).

```typescript
export const POST = withAxiom(async (req) => {
  const startTime = Date.now();

  let body: any;
  let workspace:
    | Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">
    | undefined;

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

    await verifyQstashSignature({
      req,
      rawBody,
    });

    body = JSON.parse(rawBody);

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

    // Find the installation
    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,
          },
        },
      },
    });

    if (!installation) {
      return logAndRespond(
        `[HubSpot] Installation is not found for portalId ${portalId}.`,
      );
    }

    workspace = installation.project;

```
Sources: [apps/web/app/ee/api/hubspot/webhook/process/route.ts:18-68](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts#L18-L68)

After refreshing the OAuth token via `hubSpotOAuthProvider.refreshTokenForInstallation(installation)` and parsing integration settings through `hubSpotSettingsSchema`, `getHubSpotEventAction` evaluates the event against the workspace configuration to determine whether to track a lead, track a sale, or skip the event.

```typescript
export function getHubSpotEventAction({
  event,
  settings,
}: {
  event: Pick<
    z.infer<typeof hubSpotLeadEventSchema>,
    "objectTypeId" | "subscriptionType" | "propertyName" | "propertyValue"
  >;
  settings: z.infer<typeof hubSpotSettingsSchema>;
}): "trackLead" | "trackSale" | "skip" {
```
Sources: [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:5-14](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L5-L14)

The evaluation rules depend on the CRM object type ID (`0-1` for contacts and `0-3` for deals) and the subscription type (`object.creation` or `object.propertyChange`):

| Object Type ID | Subscription Type | Property Name | Condition | Action Result |
| :--- | :--- | :--- | :--- | :--- |
| `0-1` (Contact) | `object.creation` | *any* | Contact created | `trackLead` |
| `0-1` (Contact) | `object.propertyChange` | *any* | `leadTriggerEvent === "lifecycleStageReached"` | `trackLead` |
| `0-3` (Deal) | `object.creation` | *any* | `leadTriggerEvent === "dealCreated"` | `trackLead` |
| `0-3` (Deal) | `object.propertyChange` | `dealstage` | `leadTriggerEvent === "dealStageReached"` and `newDealStage === leadDealStageId` | `trackLead` |
| `0-3` (Deal) | `object.propertyChange` | `dealstage` | `newDealStage === closedWonDealStageId` | `trackSale` |
| *other* | *any* | *any* | Does not match configured trigger criteria | `skip` |

Sources: [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:22-69](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L22-L69)

## HubSpot Contact and Deal Tracking

### Deferred Lead Tracking and Lifecycle Triggers

When a new HubSpot contact is created (`objectTypeId === "0-1"` and `subscriptionType === "object.creation"`), the tracking pipeline fetches the contact record via `hubSpotApi.getContact(objectId)` and verifies that the `dub_id` custom property is present on the contact properties. If present, it executes a deferred lead event by invoking `trackLead` with `mode: "deferred"`, `source: "hubspot"`, and `commissionSource: CommissionSource.hubspot`, pulling first and last name properties to assemble the customer name.

```typescript
// A new contact is created (deferred lead tracking)
if (objectTypeId === "0-1" && subscriptionType === "object.creation") {
  const contactInfo = await hubSpotApi.getContact(objectId);

  if (!contactInfo) {
    return `No contact info found for contact ${objectId}.`;
  }

  const { properties } = contactInfo;

  if (!properties.dub_id) {
    return `No dub_id found for contact ${objectId}.`;
  }

  const customerName =
    [properties.firstname, properties.lastname].filter(Boolean).join(" ") ||
    null;

  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,
  });

  if (trackLeadResult) {
    await updateHubSpotContact({
      contact: contactInfo,
      trackLeadResult,
      hubSpotApi,
    });
  }

  return `Deferred lead tracked for contact ${objectId}.`;
}
```
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:33-72](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts#L33-L72)

When a contact's lifecycle stage reaches the configured target (`leadTriggerEvent === "lifecycleStageReached"`), the handler verifies that the contact's `lifecyclestage` property matches `settings.leadLifecycleStageId` case-insensitively. It queries Prisma for an existing customer matching either the HubSpot contact ID or email address, and tracks an asynchronous lead event via `trackLead` using `mode: "async"`.

```typescript
// Case 2: When the contact's lifecycle stage is changed to a qualified lead
if (
  objectTypeId === "0-1" &&
  subscriptionType === "object.propertyChange" &&
  settings.leadTriggerEvent === "lifecycleStageReached"
) {
  if (!settings.leadLifecycleStageId) {
    return `leadLifecycleStageId is not set.`;
  }

  const contactInfo = await hubSpotApi.getContact(objectId);

  if (!contactInfo) {
    return `No contact info found for contact ${objectId}.`;
  }

  const { properties } = contactInfo;

  if (
    properties.lifecyclestage?.toLowerCase() !==
    settings.leadLifecycleStageId?.toLowerCase()
  ) {
    return `Unknown contact lifecyclestage ${properties.lifecyclestage}. Expected ${settings.leadLifecycleStageId}.`;
  }

  if (!properties.dub_id) {
    return `No dub_id found for contact ${objectId}.`;
  }

  const customer = await prisma.customer.findFirst({
    where: {
      projectId: workspace.id,
      OR: [{ externalId: contactInfo.id }, { externalId: properties.email }],
    },
  });

  const trackLeadResult = await trackLead({
    clickId: properties.dub_id,
    eventName: `Contact ${properties.lifecyclestage}`,
    customerExternalId: customer?.externalId || properties.email,
    customerName: `${properties.firstname} ${properties.lastname}`,
    customerEmail: properties.email,
    mode: "async",
    workspace,
    source: "hubspot",
    commissionSource: CommissionSource.hubspot,
  });

  if (trackLeadResult) {
    await updateHubSpotContact({
      contact: contactInfo,
      trackLeadResult,
      hubSpotApi,
    });
  }

  return `Lead tracked for contact ${objectId}.`;
}
```
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:117-174](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts#L117-L174)

> [!WARNING]
> Deferred lead tracking strictly requires the contact to possess a valid `dub_id` property populated; if `dub_id` is absent, the event aborts without tracking a lead.
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:43-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts#L43-L45)

### Deal-Driven Lead and Sale Attribution

Deals (`objectTypeId === "0-3"`) drive both lead creation and revenue attribution depending on workspace settings and property change events. When `leadTriggerEvent` is configured to `"dealCreated"` upon deal creation, or `"dealStageReached"` when the `dealstage` property matches `settings.leadDealStageId`, `trackFinalLead` retrieves the deal object along with its associated contacts via `hubSpotApi.getDeal(dealId)`.

```typescript
// Track the final lead event
// Case 1: A deal is created for the contact
if (
  objectTypeId === "0-3" &&
  subscriptionType === "object.creation" &&
  settings.leadTriggerEvent === "dealCreated"
) {
  return trackFinalLead({
    dealId: objectId,
    workspace,
    hubSpotApi,
  });
}

// Track the final lead event
// Case 3: A deal reaches the configured lead deal stage
if (
  objectTypeId === "0-3" &&
  subscriptionType === "object.propertyChange" &&
  settings.leadTriggerEvent === "dealStageReached"
) {
  if (!settings.leadDealStageId) {
    return `leadDealStageId is not set.`;
  }

  if (propertyName !== "dealstage") {
    return `Unknown propertyName ${propertyName}. Expected dealstage.`;
  }

  if (
    propertyValue?.toLowerCase() !== settings.leadDealStageId.toLowerCase()
  ) {
    return `Unknown propertyValue ${propertyValue}. Expected ${settings.leadDealStageId}.`;
  }

  return trackFinalLead({
    dealId: objectId,
    workspace,
    hubSpotApi,
  });
}
```
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:74-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts#L74-L114)

```typescript
// Track the final lead for the first contact associated with a deal
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;

  // Find the contact associated with the deal
  const contact = associations?.contacts?.results?.[0];

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

  // HubSpot doesn't return the contact properties in the deal associations,
  // so we need to get it separately
  const contactInfo = await hubSpotApi.getContact(contact.id);

  if (!contactInfo) {
    return `No contact info found for contact ${contact.id}.`;
  }

  const customer = await prisma.customer.findFirst({
    where: {
      projectId: workspace.id,
      OR: [
        { email: contactInfo.properties.email },
        { externalId: contactInfo.id },
        { externalId: contactInfo.properties.email },
      ],
    },
  });

  if (!customer) {
    return `No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`;
  }

  const trackLeadResult = await trackLead({
    clickId: "",
    eventName: `Deal ${properties.dealstage}`,
    customerExternalId: customer.externalId!,
```
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:179-230](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts#L179-L230)

For revenue tracking, `trackHubSpotSaleEvent` listens for `object.propertyChange` events on the `dealstage` property where the new value matches `settings.closedWonDealStageId`. After retrieving the deal and its associated primary contact via `hubSpotApi.getDeal` and `hubSpotApi.getContact`, it validates that the deal amount property is set. It then queries Prisma for the corresponding customer record using either email or contact ID identifiers, and invokes `trackSale` with an amount scaled to cents (multiplied by 100), custom payment processor settings, and metadata containing both `hubspotDealId` and `hubspotContactId`.

```typescript
export const trackHubSpotSaleEvent = async ({
  payload,
  workspace,
  authToken,
  settings,
}: {
  payload: Record<string, any>;
  workspace: Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">;
  authToken: HubSpotAuthToken;
  settings: z.infer<typeof hubSpotSettingsSchema>;
}) => {
  const { objectId, subscriptionType, propertyName, propertyValue } =
    hubSpotSaleEventSchema.parse(payload);

  if (subscriptionType !== "object.propertyChange") {
    return `Unknown subscriptionType ${subscriptionType}.`;
  }

  if (propertyName !== "dealstage") {
    return `Unknown propertyName ${propertyName}. Expected dealstage.`;
  }

  if (
    propertyValue.toLowerCase() !== settings.closedWonDealStageId?.toLowerCase()
  ) {
    return `Unknown propertyValue ${propertyValue}. Expected ${settings.closedWonDealStageId}.`;
  }

  const hubSpotApi = new HubSpotApi({
    token: authToken.access_token,
  });

  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}`;
  }

  // Find the contact associated with the deal
  const contact = associations?.contacts?.results?.[0];

  if (!contact) {
    return `No contact associated with deal ${dealId}`;
  }

  // HubSpot doesn't return the contact properties in the deal associations,
  // so we need to get it separately
  const contactInfo = await hubSpotApi.getContact(contact.id);

  if (!contactInfo) {
    return `No contact info found for contact ${contact.id}`;
  }

  const customer = await prisma.customer.findFirst({
    where: {
      projectId: workspace.id,
      OR: [
        { email: contactInfo.properties.email },
        { externalId: contactInfo.id },
        { externalId: contactInfo.properties.email },
      ],
    },
  });

  if (!customer) {
    return `No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`;
  }

  await trackSale({
    customerExternalId: customer.externalId!,
    amount: Number(properties.amount) * 100,
    eventName: `${properties.dealname} ${properties.dealstage}`,
    paymentProcessor: "custom",
    invoiceId: dealId,
```
Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:10-100](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-sale.ts#L10-L100)

> [!NOTE]
> Deal-based lead and sale tracking inspects the first associated contact on the deal (`associations.contacts.results[0]`) and performs a secondary API fetch (`hubSpotApi.getContact`) because deal associations do not include contact property payloads.
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:197-206](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts#L197-L206), [apps/web/lib/integrations/hubspot/track-sale.ts:54-63](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-sale.ts#L54-L63)

## Intercom OAuth and Webhook Ingestion

### OAuth Installation Flow

The Intercom OAuth authorization flow is initiated via `GET /api/intercom/callback`. The endpoint first verifies the active user session and validates that the authenticated user holds an `owner` role within the target workspace. It also enforces plan capabilities by checking that `getPlanCapabilities(workspace.plan).canInstallAdvancedIntegrations` evaluates to true. After exchanging the authorization code for an access token via `intercomOAuthProvider.exchangeCodeForToken`, the handler queries the Intercom API for admin credentials to obtain the unique `appId` (`admin.app?.id_code`). The credentials are encrypted and stored via `installIntegration`, and the user is redirected to `/${workspace.slug}/settings/integrations/intercom`.

```typescript
try {
  const session = await getSession();

  if (!session?.user.id) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Unauthorized. Please login to continue.",
    });
  }

  const { token, contextId: workspaceId } =
    await intercomOAuthProvider.exchangeCodeForToken<string>(req);

  workspace = await prisma.project.findUniqueOrThrow({
    where: {
      id: workspaceId,
    },
    select: {
      id: true,
      slug: true,
      plan: true,
      users: {
        where: {
          userId: session.user.id,
        },
        select: {
          role: true,
          defaultFolderId: true,
        },
      },
    },
  });

  if (workspace.users.length === 0) {
    throw new DubApiError({
      code: "bad_request",
      message: "You are not a member of this workspace.",
    });
  }

  if (workspace.users[0].role !== "owner") {
    throw new DubApiError({
      code: "bad_request",
      message: "Only workspace owners can install integrations.",
    });
  }

  if (!getPlanCapabilities(workspace.plan).canInstallAdvancedIntegrations) {
    throw new DubApiError({
      code: "forbidden",
      message:
        "Intercom integration is only available on Advanced and Enterprise plans.",
    });
  }

  const integration = await prisma.integration.findUniqueOrThrow({
    where: {
      id: INTERCOM_INTEGRATION_ID,
    },
    select: {
      id: true,
    },
  });

  const intercom = new Intercom({
    token: token.access_token,
  });

  const admin = await intercom.getAdmin();

  if (!admin.app?.id_code) {
    throw new DubApiError({
      code: "internal_server_error",
      message: "Failed to retrieve Intercom workspace ID.",
    });
  }

  const credentials = intercomCredentialsSchema.parse({
    accessToken: encrypt(token.access_token),
    appId: admin.app?.id_code,
```
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:33-125](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/callback/route.ts#L33-L125)

> [!CAUTION]
> Only workspace members with an `owner` role can successfully install the Intercom integration; non-owner member requests throw a `bad_request` error and abort installation.
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:73-78](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/callback/route.ts#L73-L78)

### Webhook Verification and Event Dispatch

Intercom webhook events arriving at `POST /api/intercom/webhook` are processed by verifying the request signature, parsing the payload against `intercomWebhookSchema`, and evaluating the event topic. The webhook handler restricts processing to `relevantTopics`, which is defined as a set containing `conversation.admin.replied` and `ping`.

```typescript
const relevantTopics = new Set(["conversation.admin.replied", "ping"]);
```
Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:9-21](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/route.ts#L9-L21)

When a `ping` topic is received, the endpoint acknowledges the event without further action. For conversation reply events, the payload is enqueued into QStash using a batch job configuration pointing to `/api/intercom/webhook/process` with the queue name `process-intercom-webhook`.

Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:23-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/route.ts#L23-L41)

### Webhook Signature Verification

Intercom webhook requests authenticate payloads using cryptographic HMAC signatures transmitted via specific HTTP headers. The `verifyIntercomWebhookSignature` function inspects incoming headers to determine the expected signature scheme based on whether an `X-Hub-Signature` or `X-Body-Signature` header is present.

Sources: [apps/web/app/ee/api/intercom/webhook/verify-webhook-signature.ts:7-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts#L7-L27)

| Header | Hash Algorithm | Target Payload | Purpose |
| :--- | :--- | :--- | :--- |
| `X-Hub-Signature` | `sha1` (prefixed with `sha1=`) | Raw request body | Standard webhook event verification |
| `X-Body-Signature` | `sha256` (hex digest) | Raw request body | Uninstall and health-check verification |

Sources: [apps/web/app/ee/api/intercom/webhook/verify-webhook-signature.ts:32-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts#L32-L48)

The computed signature is compared against the provided header value using `timingSafeCompare` to prevent timing attacks. If the `INTERCOM_CLIENT_SECRET` environment variable is unset, or if neither signature header is supplied, the utility throws an API error.

Sources: [apps/web/app/ee/api/intercom/webhook/verify-webhook-signature.ts:14-60](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/verify-webhook-signature.ts#L14-L60)

### Health Check and Uninstall Workflows

Intercom monitors app health and lifecycle events through dedicated webhook endpoints. The health check endpoint (`POST /api/intercom/webhook/health-check`) parses the workspace identifier using `intercomHealthCheckWebhookSchema` and queries Prisma for an installed integration where `credentials.appId` matches the Intercom workspace ID. If no valid installation or credential schema match is found, it returns an `UNHEALTHY` status response with configuration call-to-action details; otherwise, it reports `OK`.

Sources: [apps/web/app/ee/api/intercom/webhook/health-check/route.ts:13-62](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/health-check/route.ts#L13-L62)

The uninstall endpoint (`POST /api/intercom/webhook/uninstall`) parses `app_id` via `intercomUninstallWebhookSchema`, locates the corresponding database record using the same JSON path query on credentials (`$.appId`), and deletes the `InstalledIntegration` entry to revoke access when an app is uninstalled or disconnected in Intercom.

Sources: [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts:10-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts#L10-L45)

## Intercom Bi-Directional Message Routing

### Forwarding Partner and Admin Messages

Dub bridges communication between partner portals and Intercom conversations by routing messages bi-directionally. When a partner sends a message, `forwardPartnerMessageToIntercom` looks up or creates a contact via `intercom.getOrCreateContact(partner)` and queries Redis for an existing thread key structured as `intercom:thread:${credentials.appId}:${program.id}:${partner.id}`. If no conversation ID is cached, a new conversation is initialized using `intercom.createConversationAsContact`.
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:43-64](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/forward-message.ts#L43-L64)

```mermaid
sequenceDiagram
    participant Partner as Partner Portal
    participant Dub as Dub API
    participant Redis as Upstash Redis
    participant Intercom as Intercom API

    Partner->>Dub: Send message with attachments
    Dub->>Intercom: Get or create contact by email
    Dub->>Redis: Check thread key intercom:thread:{appId}:{programId}:{partnerId}
    alt Conversation ID Exists
        Dub->>Intercom: Reply to existing conversation ID
    else No Conversation Exists
        Dub->>Intercom: Create new conversation
        Intercom-->>Dub: Return conversation_id
        Dub->>Redis: Store conversation_id in Redis
    end
```
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:47-79](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/forward-message.ts#L47-L79)

> [!WARNING]
> Intercom's API honors only one of `attachment_urls` or `attachment_files` per reply; supplying both causes Intercom to keep the URLs and silently drop inline files. To prevent data loss on mixed-content messages, Dub evaluates attachments before dispatch.
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:181-187](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/forward-message.ts#L181-L187)

The `buildIntercomAttachments` helper inspects message attachments stored in private R2 storage and applies distinct encoding strategies depending on whether all attached files are images.

| Attachment Condition | Transfer Method | Payload Parameter | Purpose |
| :--- | :--- | :--- | :--- |
| All attachments are images (`image/*`) | Signed download URLs (30-minute expiry) | `attachment_urls` | Lightweight delivery accepted by creation endpoints |
| Mixed content (e.g., image + PDF) | Inline base64-encoded buffers | `attachment_files` | Ensures non-image files are delivered without dropping data |

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:188-222](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/intercom/forward-message.ts#L188-L222)

### Ingesting Admin Replies and Attachment Sync

When an admin replies inside Intercom, a webhook event with topic `conversation.admin.replied` is dispatched to `POST /api/intercom/webhook/process`. The processor verifies the QStash signature, validates the body against `intercomWebhookSchema`, and queries the database for an `InstalledIntegration` matching the Intercom `app_id`.
Sources: [apps/web/app/ee/api/intercom/webhook/process/route.ts:21-42](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L21-L42)

Sources: [apps/web/app/ee/api/intercom/webhook/process/route.ts:87-93](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L87-L93)

The `handleConversationAdminReplied` function identifies target partners by inspecting `data.item.contacts.contacts` and fetching their corresponding email addresses from Intercom. Conversation parts created via the Intercom API (identified by a non-null `app_package_code`) are skipped to prevent echoing messages back.
Sources: [apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts:44-70](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts#L44-L70)

> [!NOTE]
> Workspace user attribution resolves in a strict three-tier fallback sequence: matching `author.email` to project users, falling back to the user ID who installed the integration (`installation.userId`), and finally defaulting to the first project owner.
Sources: [apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts:193-207](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts#L193-L207)

Incoming Intercom attachments are validated against `UPLOAD_POLICIES.programMessageAttachments` content types and size limits, downloaded with a 30-second timeout, and uploaded to the private R2 bucket under `messages/{programId}/{nanoid}/{filename}`. If an attachment fails storage, its external URL is appended to the message text as a fallback. Finally, partner notifications are scheduled via QStash with a 3-minute delay.
Sources: [apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts:92-157](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts#L92-L157)

## Related

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


## Sitemap

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