Getting Started
Core Architecture
Link Engine
Analytics & Attribution
Partners & Affiliates
Third-Party Integrations
Identity & Security
Automation & Messaging
Developer Tools
The following files were used as context for generating this wiki page:
The HubSpot integration bridges customer relationship management with link attribution, enabling automated conversion tracking and partner commission generation. By synchronizing CRM contacts and deals with click metadata, workspaces can attribute leads and revenue directly to referral sources.
The HubSpot integration initiates its connection workflow through an OAuth 2.0 authorization process configured via the hubSpotOAuthProvider instance. During local development, incoming requests outside of localhost automatically redirect through http://localhost:8888/api/hubspot/callback.
Sources: apps/web/app/ee/api/hubspot/callback/route.ts:20-28, apps/web/lib/integrations/hubspot/oauth.ts:100-118
The provider establishes connection parameters using specific endpoints and required scopes defined in both the server configuration and the application metadata file.
Sources: apps/web/lib/integrations/hubspot/oauth.ts:100-118, packages/hubspot-app/src/app/app-hsmeta.json:14-20
When HubSpot redirects back to Dub, the callback route processes the authorization grant and establishes the workspace integration through a strict sequence of validation and persistence steps:
getSession() verifies the active user session; if no valid user ID is present, a DubApiError with code unauthorized is thrown.hubSpotOAuthProvider.exchangeCodeForToken<string>(req) exchanges the authorization code for an OAuth token and workspace context ID (workspaceId).prisma.project.findUniqueOrThrow() queries the workspace and validates that the current user is a member (workspace.users.length === 0 throws bad_request) and holds an owner role (workspace.users[0].role !== "owner" throws bad_request).prisma.integration.findUniqueOrThrow() fetches the integration record for the hubspot slug.encrypt() secures both the access_token and refresh_token fields before storing them alongside created_at: Date.now().installIntegration() saves the installed integration configuration to the database using the encrypted credentials.waitUntil() dispatches an asynchronous batch creation of contact properties (HUBSPOT_DUB_CONTACT_PROPERTIES) using a new HubSpotApi instance initialized with the raw access token.redirect() sends the user to /${workspace.slug}/settings/integrations/hubspot.Warning
Only workspace members possessing the owner role are permitted to install the HubSpot integration. Attempts to install by non-owners result in a bad_request API error.
The HubSpotOAuthProvider class manages token longevity and validity checks through dedicated helper methods. A token is evaluated using isTokenValid(), which incorporates a 60-second early buffer (60 * 1000 ms) prior to actual expiration.
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;
}When an installation requires a refreshed token via refreshTokenForInstallation(), the provider parses existing credentials, decrypts them with decryptOrPassthrough(), and if validation fails, fetches a new token, re-encrypts the resulting access and refresh tokens, and updates the database record via prisma.installedIntegration.update().
Following a successful OAuth installation, Dub initializes tracking properties in HubSpot so that click IDs, short links, and partner attribution emails flow seamlessly into CRM contact records. The HubSpotApi client handles communication with HubSpot's v3 CRM API (https://api.hubapi.com/crm/v3), managing batch custom property creations, individual contact retrievals, and partial contact updates via HTTP PATCH requests.
Sources: apps/web/app/ee/api/hubspot/callback/route.ts:101-112, apps/web/lib/integrations/hubspot/api.ts:8-14
When the OAuth callback completes, waitUntil() triggers createPropertiesBatch() to register Dub-specific tracking fields under contact object type 0-1. The definitions are supplied by HUBSPOT_DUB_CONTACT_PROPERTIES, which configures three custom properties (dub_id, dub_link, and dub_partner_email) within the standard contactinformation group.
Sources: apps/web/app/ee/api/hubspot/callback/route.ts:106-111, apps/web/lib/integrations/hubspot/constants.ts:17-40
Note
The dub_id property explicitly enables formField: true, permitting it to be rendered directly inside HubSpot forms for automated tracking capture.
The HubSpotApi class provides core methods for inspecting and updating CRM records. Requests include a Bearer token in the Authorization header and automatically apply Content-Type: application/json when a request body is present. Responses are parsed and validated against Zod schemas before being returned to callers.
async updateContact({
contactId,
properties,
}: {
contactId: number | string;
properties: Record<string, unknown>;
}) {
try {
const result = await this.fetch(`/objects/contacts/${contactId}`, {
method: "PATCH",
body: {
properties,
},
});
return result;
} catch (error) {
console.error(
`[HubSpot] Failed to update contact ${contactId}: ${error}`,
);
return null;
}
}Sources: apps/web/lib/integrations/hubspot/api.ts:16-53, apps/web/lib/integrations/hubspot/api.ts:86-108
Contact data retrieval via getContact() specifically requests properties for email, names, lifecyclestage, and all three Dub tracking fields:
async getContact(contactId: number | string) {
try {
const contact = await this.fetch(
`/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id,dub_link,dub_partner_email,lifecyclestage`,
);
return hubSpotContactSchema.parse(contact);
} catch (error) {
console.error(
`[HubSpot] Failed to retrieve contact ${contactId}: ${error}`,
);
return null;
}
}HubSpot webhook events are managed through metadata definitions specifying target endpoints, concurrency limits, and active CRM object subscriptions. The integration targets https://app.dub.co/api/hubspot/webhook with a maximum concurrency of 10 requests. Subscriptions monitor CRM objects (contact and deal) for creations and property changes.
Incoming webhooks at /api/hubspot/webhook are received as raw text. The endpoint validates the X-HubSpot-Signature header against the HUBSPOT_CLIENT_SECRET environment variable by generating a SHA-256 hash of the concatenated secret and raw request body, comparing them using timingSafeCompare.
const rawBody = await req.text();
const signature = req.headers.get("X-HubSpot-Signature");
if (!signature) {
throw new DubApiError({
code: "bad_request",
message: "Missing X-HubSpot-Signature header.",
});
}
if (!HUBSPOT_CLIENT_SECRET) {
throw new DubApiError({
code: "internal_server_error",
message: "Missing HUBSPOT_CLIENT_SECRET environment variable.",
});
}
const sourceString = HUBSPOT_CLIENT_SECRET + rawBody;
const expectedHash = crypto
.createHash("sha256")
.update(sourceString)
.digest("hex");
if (!timingSafeCompare(signature, expectedHash)) {
throw new DubApiError({
code: "unauthorized",
message: "Invalid webhook signature.",
});
}Warning
Requests lacking the X-HubSpot-Signature header or failing constant-time comparison are rejected immediately with bad_request or unauthorized error codes before any JSON parsing occurs.
Because HubSpot can dispatch multiple events within a single HTTP payload, the endpoint normalizes the body into an array and fans out each event independently using enqueueBatchJobs via QStash. This ensures slow or failing events do not block processing for the rest of the batch.
const events = JSON.parse(rawBody) as any[];
const finalEvents = Array.isArray(events) ? events : [events];
const qstashResponse = await enqueueBatchJobs(
finalEvents.map((event) => ({
queueName: "process-hubspot-webhook",
url: `${APP_DOMAIN_WITH_NGROK}/api/hubspot/webhook/process`,
deduplicationId: event.eventId,
method: "POST",
body: event,
})),
);Individual webhook events dispatched by QStash arrive at /api/hubspot/webhook/process. The processing flow executes the following sequence:
req.text() → verifyQstashSignature() → hubSpotWebhookSchema.parse() → prisma.installedIntegration.findFirst() → hubSpotOAuthProvider.refreshTokenForInstallation() → getHubSpotEventAction() → event routing (trackHubSpotLeadEvent / trackHubSpotSaleEvent) → captureWebhookLog().
try {
const rawBody = await req.text();
await verifyQstashSignature({
req,
});
body = JSON.parse(rawBody);
const { objectTypeId, portalId, subscriptionType } =
hubSpotWebhookSchema.parse(body);
const installation = await prisma.installedIntegration.findFirst({
where: {
integration: {
slug: "hubspot",
},
credentials: {
path: "$.hub_id",
equals: portalId,
},
},
include: {
project: {
select: {
id: true,
stripeConnectId: true,
webhookEnabled: true,
},
},
},
});Sources: apps/web/app/ee/api/hubspot/webhook/route.ts:50-62, apps/web/app/ee/api/hubspot/webhook/route.ts:40-45, apps/web/app/ee/api/hubspot/webhook/process/route.ts:40-49
Lead and conversion tracking in Dub parses incoming HubSpot contact and deal webhook payloads, extracts associated click metadata (dub_id), and records conversion events against workspace projects using the core trackLead API. Incoming events are routed based on objectTypeId, subscriptionType, and the configured leadTriggerEvent settings.
Sources: apps/web/lib/integrations/hubspot/track-lead.ts:10-31, apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:5-16
The function getHubSpotEventAction inspects incoming webhook properties to determine whether an event should trigger lead tracking, sale tracking, or be skipped entirely.
export function getHubSpotEventAction({
event,
settings,
}: {
event: Pick<
z.infer<typeof hubSpotLeadEventSchema>,
"objectTypeId" | "subscriptionType" | "propertyName" | "propertyValue"
>;
settings: z.infer<typeof hubSpotSettingsSchema>;
}): "trackLead" | "trackSale" | "skip" {
const { objectTypeId, subscriptionType, propertyName, propertyValue } = event;
const { leadTriggerEvent } = settings;
const isCreated = subscriptionType === "object.creation";
const isPropertyChanged = subscriptionType === "object.propertyChange";When processing a HubSpot webhook event for lead creation or stage progression, trackHubSpotLeadEvent coordinates validation, CRM fetching, customer lookups, and tracking API calls.
The execution walkthrough follows this path:
trackHubSpotLeadEvent() → hubSpotLeadEventSchema.parse() → hubSpotApi.getContact() / hubSpotApi.getDeal() → prisma.customer.findFirst() → trackLead() → updateHubSpotContact().
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,
});Warning
Deferred lead tracking on contact creation (objectTypeId === "0-1" and subscriptionType === "object.creation") requires the contact property dub_id to be present. If dub_id is missing from the retrieved contact properties, the tracking execution terminates early with a descriptive string response.
For deal-triggered lead events (objectTypeId === "0-3"), trackFinalLead retrieves the deal object, extracts associated contacts from deal.associations.contacts.results, and fetches contact details to associate the conversion with an existing customer record in Prisma.
const trackFinalLead = async ({
dealId,
workspace,
hubSpotApi,
}: {
dealId: number;
workspace: Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">;
hubSpotApi: HubSpotApi;
}) => {
const deal = await hubSpotApi.getDeal(dealId);
if (!deal) {
return `No deal found for deal ${dealId}.`;
}
const { properties, associations } = deal;
const contact = associations?.contacts?.results?.[0];
if (!contact) {
return `No contact found for deal ${dealId}.`;
}
const contactInfo = await hubSpotApi.getContact(contact.id);When a deal webhook event matches the configured closed-won criteria, HubSpot deal and contact properties are parsed to attribute and record revenue conversion sales through Dub's internal tracking services.
The execution path for processing a closed-won sale event coordinates webhook verification, deal retrieval, contact association lookups, customer identification, and sale recording:
POST (route handler) → trackHubSpotSaleEvent() → hubSpotSaleEventSchema.parse() → hubSpotApi.getDeal() → hubSpotApi.getContact() → prisma.customer.findFirst() → trackSale().
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}`;
}HubSpot deals and associated contacts are validated against strict Zod schemas before revenue amounts and metadata are extracted for attribution.
Sources: apps/web/lib/integrations/hubspot/schema.ts:62-81, apps/web/lib/integrations/hubspot/schema.ts:98-103
Warning
Sale attribution via trackHubSpotSaleEvent enforces strict filter validation: the incoming event must have subscriptionType === "object.propertyChange", propertyName === "dealstage", and a propertyValue matching the workspace's configured closedWonDealStageId (defaulting to "closedwon" case-insensitively).
Once the deal and contact info are fetched, trackHubSpotSaleEvent queries Prisma to locate the corresponding customer by matching email or external IDs against contact properties, and then invokes trackSale with converted cent amounts and HubSpot metadata.
await trackSale({
customerExternalId: customer.externalId!,
amount: Number(properties.amount) * 100,
eventName: `${properties.dealname} ${properties.dealstage}`,
paymentProcessor: "custom",
invoiceId: dealId,
workspace,
metadata: {
hubspotDealId: dealId,
hubspotContactId: contactInfo.id,
},
source: "hubspot",
commissionSource: CommissionSource.hubspot,
});Workspace administrators configure and manage the HubSpot integration preferences directly through the Dub workspace UI. The HubSpotSettings component allows users to choose lead attribution trigger events, configure stage identifiers, and save preferences via server actions, while token lifecycle operations handle token validity checks, secure decryption, and portal uninstallation.
Sources: apps/web/lib/integrations/hubspot/ui/settings.tsx:17-43, apps/web/lib/integrations/hubspot/oauth.ts:16-52
The integration settings are validated and typed using Zod schemas, defining default behaviors and constraints for event triggers and stage identifiers.
Updating integration settings follows a secure server action pipeline from client interaction through validation and database persistence:
HubSpotSettings (UI form submit) → updateHubSpotSettingsAction (auth action client) → schema extension & rule validation → prisma.installedIntegration.findFirst() → prisma.installedIntegration.update() → revalidatePath().
export const updateHubSpotSettingsAction = authActionClient
.inputSchema(schema)
.action(async ({ parsedInput, ctx }) => {
const { workspace } = ctx;
const {
leadTriggerEvent,
leadLifecycleStageId,
leadDealStageId,
closedWonDealStageId,
} = parsedInput;
if (leadTriggerEvent === "dealStageReached") {
if (!leadDealStageId) {
throw new Error("Lead deal stage ID is required.");
}
if (
leadDealStageId.toLowerCase() === closedWonDealStageId?.toLowerCase()
) {
throw new Error(
"Lead deal stage ID must be different from the closed won deal stage ID.",
);
}
}Warning
When leadTriggerEvent is set to "dealStageReached", the server action strictly enforces that leadDealStageId is provided and that it differs case-insensitively from closedWonDealStageId.
The HubSpotOAuthProvider manages authentication token validity, decryption of credentials retrieved from the database, automatic refreshing of expired tokens with a 60-second buffer, and uninstallation requests sent to HubSpot's external install API.
async refreshTokenForInstallation(
installation: InstalledIntegration,
): Promise<HubSpotAuthToken> {
let token = hubSpotAuthTokenSchema.parse(installation.credentials);
token = {
...token,
access_token: decryptOrPassthrough(token.access_token),
refresh_token: decryptOrPassthrough(token.refresh_token),
};
if (this.isTokenValid(token)) {
return token;
}
const newToken = await this.refreshToken(token.refresh_token);
const credentials = {
...newToken,
created_at: Date.now(),
};
await prisma.installedIntegration.update({
where: {
id: installation.id,
},
data: {
credentials: {
...credentials,
access_token: encrypt(credentials.access_token),
refresh_token: encrypt(credentials.refresh_token),
},
},
});
return credentials;
}Tip
isTokenValid evaluates token expiration using a 60-second safety buffer (const buffer = 60 * 1000) before the actual expires_in timestamp elapses, refreshing tokens proactively to prevent mid-request expiration errors.