Skip to main content
doc0blade47/dubdocumentation home
Sign in

HubSpot & Intercom

Last updated Oct 12, 2026100% grounded
Relevant source files

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

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, apps/web/app/ee/api/hubspot/webhook/route.ts:12-69, apps/web/app/ee/api/intercom/callback/route.ts:16-125, apps/web/app/ee/api/intercom/webhook/route.ts:12-41, apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts:33-133, apps/web/lib/integrations/hubspot/api.ts:111-134

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.

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

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

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.

  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

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

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 LabelProperty NameTypeField TypeGroup NameForm Field
Dub Click IDdub_idstringtextcontactinformationtrue
Dub Linkdub_linkstringtextcontactinformationundefined
Dub Partner Emaildub_partner_emailstringtextcontactinformationundefined

Sources: apps/web/app/ee/api/hubspot/callback/route.ts:101-112, apps/web/lib/integrations/hubspot/constants.ts:17-40

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.

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

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.

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

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

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

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

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.

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

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 IDSubscription TypeProperty NameConditionAction Result
0-1 (Contact)object.creationanyContact createdtrackLead
0-1 (Contact)object.propertyChangeanyleadTriggerEvent === "lifecycleStageReached"trackLead
0-3 (Deal)object.creationanyleadTriggerEvent === "dealCreated"trackLead
0-3 (Deal)object.propertyChangedealstageleadTriggerEvent === "dealStageReached" and newDealStage === leadDealStageIdtrackLead
0-3 (Deal)object.propertyChangedealstagenewDealStage === closedWonDealStageIdtrackSale
otheranyanyDoes not match configured trigger criteriaskip

Sources: apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:22-69

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.

// 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

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

// 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

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

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

// 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

// 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

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.

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

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, apps/web/lib/integrations/hubspot/track-sale.ts:54-63

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.

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

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

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.

const relevantTopics = new Set(["conversation.admin.replied", "ping"]);

Sources: apps/web/app/ee/api/intercom/webhook/route.ts:9-21

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

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

HeaderHash AlgorithmTarget PayloadPurpose
X-Hub-Signaturesha1 (prefixed with sha1=)Raw request bodyStandard webhook event verification
X-Body-Signaturesha256 (hex digest)Raw request bodyUninstall and health-check verification

Sources: apps/web/app/ee/api/intercom/webhook/verify-webhook-signature.ts:32-48

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

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

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

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

Sources: apps/web/lib/integrations/intercom/forward-message.ts:47-79

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

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 ConditionTransfer MethodPayload ParameterPurpose
All attachments are images (image/*)Signed download URLs (30-minute expiry)attachment_urlsLightweight delivery accepted by creation endpoints
Mixed content (e.g., image + PDF)Inline base64-encoded buffersattachment_filesEnsures non-image files are delivered without dropping data

Sources: apps/web/lib/integrations/intercom/forward-message.ts:188-222

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

Sources: apps/web/app/ee/api/intercom/webhook/process/route.ts:87-93

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

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

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

Was this page helpful?