Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/app/ee/api/intercom/webhook/route.ts
- apps/web/app/ee/api/intercom/callback/route.ts
- apps/web/lib/integrations/hubspot/track-lead.ts
- apps/web/app/ee/api/intercom/webhook/process/route.ts
- apps/web/app/ee/api/hubspot/webhook/process/route.ts
- apps/web/app/ee/api/hubspot/callback/route.ts
- apps/web/lib/integrations/intercom/forward-message.ts
- apps/web/app/ee/api/stripe/integration/webhook/route.ts
- apps/web/app/ee/api/cron/import/partnerstack/route.ts
- apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts
- apps/web/app/api/ai/support-chat/route.ts
- apps/web/app/ee/api/intercom/webhook/health-check/route.ts
- apps/web/app/ee/api/cron/import/tolt/route.ts
- apps/web/app/ee/api/hubspot/webhook/route.ts
- apps/web/app/ee/api/cron/import/firstpromoter/route.ts
- apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts
- apps/web/lib/integrations/intercom/client.ts
- apps/web/app/ee/api/intercom/webhook/process/conversation-admin-replied.ts
- apps/web/app/ee/api/intercom/webhook/uninstall/route.ts
- apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts
- apps/web/lib/integrations/hubspot/track-sale.ts
- apps/web/lib/integrations/hubspot/constants.ts
- apps/web/lib/integrations/hubspot/update-hubspot-settings.ts
- apps/web/lib/integrations/intercom/schema.ts
- apps/web/lib/integrations/hubspot/oauth.ts
- apps/web/lib/integrations/hubspot/ui/settings.tsx
- apps/web/app/ee/api/intercom/webhook/verify-webhook-signature.ts
- apps/web/lib/integrations/hubspot/schema.ts
- apps/web/lib/integrations/hubspot/api.ts
- apps/web/scripts/dev/seed-integration.ts
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 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, 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 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
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
| 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
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 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
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
Related
On This Page
OverviewHubSpot OAuth and Schema ConfigurationOAuth Flow and InstallationToken Refresh LifecycleCustom Property ProvisioningHubSpot Webhook Ingestion and DispatchSignature Validation and Payload Fan-OutEvent Processing and Action RoutingHubSpot Contact and Deal TrackingDeferred Lead Tracking and Lifecycle TriggersDeal-Driven Lead and Sale AttributionIntercom OAuth and Webhook IngestionOAuth Installation FlowWebhook Verification and Event DispatchWebhook Signature VerificationHealth Check and Uninstall WorkflowsIntercom Bi-Directional Message RoutingForwarding Partner and Admin MessagesIngesting Admin Replies and Attachment SyncRelated