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:
Customer Support Integrations connect Dub's platform workflows directly into external support tools like Plain, Intercom, and Slack. These integrations streamline troubleshooting by synchronizing customer profiles, workspace metrics, and user plan tiers across support channels while automating ticket escalation and priority communication routing.
Sources: apps/web/app/api/callback/plain/partner/route.ts:21-272, apps/web/app/ee/api/intercom/webhook/route.ts:1-45, apps/web/lib/slack/support-invite.ts:1-200
Dub integrates with Plain to supply support agents with dynamic customer context cards and keep user subscription plans synchronized across customer support threads. Webhook endpoints authenticate incoming Plain callback requests using the X-Plain-Webhook-Secret header, parse customer payloads, resolve user records, and construct rich UI cards containing workspace limits, billing tiers, and partner network statistics.
Sources: apps/web/app/api/callback/plain/partner/route.ts:21-272, apps/web/app/api/callback/plain/workspace/route.ts:20-297, apps/web/lib/plain/sync-user-plan.ts:6-92
The syncUserPlanToPlain utility handles user plan propagation whenever a workspace callback executes. It verifies email existence, upserts the Plain customer profile, extracts domain identifiers for non-generic email addresses, and queries the user's top workspace by usageLimit.
export const syncUserPlanToPlain = async (user: PlainUser) => {
if (!user.email) {
console.log(`User ${user.id} has no email, skipping sync...`);
return;
}
const { data } = await upsertPlainCustomer({
id: user.id,
name: user.name,
email: user.email,
});
if (!data) {
console.log(
`Failed to upsert plain customer for user ${user.id}, skipping sync...`,
);
return;
}
const plainCustomer = data.customer;
let companyDomainName: string | undefined;
if (!isGenericEmail(user.email)) {
companyDomainName = user.email.split("@")[1];
}
...Once the top workspace is retrieved, the service assigns the customer to the app.dub.co customer group and updates the company tier in Plain using the workspace's plan name.
Note
For users with generic email domains (such as gmail.com), workspace association falls back to matching the specific userId rather than extracting a company domain name.
When Plain requests context cards for a workspace support thread, the webhook processes the payload through a strict validation and lookup sequence.
req.headers.get("X-Plain-Webhook-Secret") matches process.env.PLAIN_WEBHOOK_SECRET, returning 401 Unauthorized if invalid.plainCallbackSchema.prisma.user.findUnique using customer.externalId if present, or falls back to customer.email.isBlacklistedEmail(customer.email) and adds the customer to the banned_users group if true, returning an empty container card.waitUntil(syncUserPlanToPlain(user)) asynchronously via Vercel functions.prisma.project.findFirst ordered by usageLimit descending to locate the user's highest-tier workspace.The workspace context endpoint dynamically assigns badge colors based on the user's plan configuration within the UI component renderer.
The partner webhook route (/api/callback/plain/partner) follows a parallel validation structure to render partner network statistics in Plain support sidebars. If a user lacks an externalId, the route searches by email, links the user ID, and upserts the Plain customer profile.
The route queries prisma.partner linked to the user and retrieves up to 5 active programs where totalCommissions exceeds zero, sorted in descending order of commissions.
const partnerProfile = await prisma.partner.findFirst({
where: {
users: {
some: {
userId: customer.externalId,
},
},
},
include: {
programs: {
select: {
program: {
select: {
name: true,
},
},
createdAt: true,
totalCommissions: true,
},
where: {
totalCommissions: {
gt: 0,
},
},
orderBy: {
totalCommissions: "desc",
},
take: 5,
},
},
});Upon successful retrieval, the customer is added to the partners.dub.co customer group in Plain, and a card containing partner ID, name, email, country badge, payout status, Stripe recipient accounts, crypto wallet addresses, and program commission breakdowns is returned.
The AI support ticket creation subsystem automates customer escalation routing from conversational support prompts by instantiating Plain threads through a specialized AI tool. When users request human assistance or encounter complex issues like billing disputes, account access problems, or confirmed bugs, the createSupportTicketTool function builds structured thread payloads and provisions tickets directly within Plain.
The ticket creation lifecycle follows an explicit sequence of data gathering, priority computation, component assembly, and API dispatch:
accountType, selectedWorkspace, selectedProgram, and chatLocation from globalContext.(X image attached)) for file parts and prefixing roles as User: or Dub Support:.getPriorityAndMetadata() to query workspace plans or partner lifetime payouts and determine ticket priority levels and custom metadata rows.ComponentDividerSpacingSize.M and ComponentDividerSpacingSize.L), truncated chat histories (up to 5,000 characters), chat locations, and additional metadata key-value pairs.createPlainThread() passing user identification details, computed priority, component structures, and optional attachment IDs.Note
If workspace queries or partner payout aggregations fail inside getPriorityAndMetadata(), the catch block gracefully defaults ticket priority to 3 and leaves additional metadata empty rather than crashing the escalation flow.
Ticket priority and metadata values are determined dynamically based on the user's active workspace plan or lifetime partner payouts.
The chat interface exposes client actions for ticket escalation via handleEscalateViaForm, which dispatches an automated user message ("Please create my support ticket now.") alongside request body metadata containing selected workspace/partner contexts, incoming Slack thread timestamps, and optional attachment IDs or ticket details.
Warning
The chat interface sets canEscalate to true only when chat is enabled, message count is at least 2, status is ready, the ticket has not already been submitted, and the model has not previously requested a support ticket tool call.
Support chat backend routes map tool execution names to human-readable Slack tool labels using the SLACK_TOOL_LABELS lookup record:
const SLACK_TOOL_LABELS: Record<string, string> = {
requestSupportTicket: "Showed support ticket form",
createSupportTicket: "Created support ticket",
findRelevantDocs: "Searched documentation",
getWorkspaceDetails: "Looked up workspace details",
getProgramPerformance: "Looked up program performance",
getPlanComparison: "Compared plans",
};The Intercom integration installation workflow is governed by the OAuth callback handler located at apps/web/app/ee/api/intercom/callback/route.ts. This endpoint processes incoming authorization codes, verifies workspace permissions and plan tier capabilities, validates the connected Intercom admin profile, encrypts sensitive access tokens, and registers the installation.
When Intercom redirects back to the application, the callback route executes a precise sequence of validation and persistence checks:
getSession(): Retrieves the authenticated user session; throws a DubApiError with code "unauthorized" if no user ID is present.intercomOAuthProvider.exchangeCodeForToken<string>(req): Exchanges the authorization code for an access token and extracts the target workspace context ID (workspaceId).prisma.project.findUniqueOrThrow(): Queries the target workspace, verifying user membership and fetching the user role and workspace plan.workspace.users.length > 0), that their role is strictly "owner", and that getPlanCapabilities(workspace.plan).canInstallAdvancedIntegrations evaluates to true — otherwise throwing "bad_request" or "forbidden" errors.prisma.integration.findUniqueOrThrow(): Fetches the unique integration record matching INTERCOM_INTEGRATION_ID.new Intercom({ token: token.access_token }) & intercom.getAdmin(): Instantiates the Intercom client and retrieves admin details to verify the Intercom workspace app.id_code.intercomCredentialsSchema.parse(): Encrypts the raw access token using encrypt() and bundles it with the verified appId according to the schema.installIntegration(): Persists the integration installation with the encrypted credentials, followed by redirecting the user to /${workspace.slug}/settings/integrations/intercom.Caution
Only workspace members with the "owner" role are permitted to install advanced integrations like Intercom; non-owner member requests immediately trigger a "bad_request" API error response.
Warning
In development environments (process.NODE_ENV === "development"), requests whose host headers do not include "localhost" are automatically redirected to http://localhost:8888/api/intercom/callback preserving all query search parameters.
The credential payload validated before installation maps access tokens through cryptographic encryption and associates them with the Intercom application identifier.
Note
If the Intercom admin response lacks an app.id_code, the callback aborts execution and throws an internal server error stating "Failed to retrieve Intercom workspace ID."
Intercom event webhooks are ingested through dedicated Next.js API route handlers that handle cryptographic signature verification, background queue dispatch via QStash, health checks, and uninstallation notifications. The webhook ingestion architecture decouples immediate HTTP response delivery from asynchronous event processing by enqueueing jobs for supported topics such as conversation admin replies.
Sources: apps/web/app/ee/api/intercom/webhook/route.ts:1-45, apps/web/app/ee/api/intercom/webhook/process/route.ts:1-127
When an incoming POST request arrives at /api/intercom/webhook, the runtime executes a strict verification and dispatch sequence:
verifyIntercomWebhookSignature(req): Validates the request signature using the raw request headers and body.JSON.parse(rawBody): Parses the verified raw payload into a JavaScript object.intercomWebhookSchema.parse(body): Validates the payload structure against the Zod schema to extract the topic.relevantTopics.has(topic): Evaluates whether the event topic is recognized. Supported topics are restricted to conversation.admin.replied and ping.enqueueBatchJobs([...]): Dispatches a background processing job to QStash targeting /api/intercom/webhook/process when the topic requires processing.Warning
If an incoming webhook event carries an unsupported topic outside of conversation.admin.replied or ping, the endpoint returns a logged response without dispatching any background jobs.
Asynchronous event processing and companion management routes validate QStash signatures or intercom signatures, inspect database installation records, and handle application uninstalls or health checks.
Sources: apps/web/app/ee/api/intercom/webhook/route.ts:14-34, apps/web/app/ee/api/intercom/webhook/process/route.ts:19-93, apps/web/app/ee/api/intercom/webhook/health-check/route.ts:16-62, apps/web/app/ee/api/intercom/webhook/uninstall/route.ts:12-41
Tip
During webhook processing at /api/intercom/webhook/process, execution verifies that the associated program is active by checking that program.deactivatedAt is null and that at least one partner program exists before invoking handleConversationAdminReplied.
The processing route captures execution duration, request bodies, and response statuses via captureWebhookLog() for both successful partner discoveries and runtime errors when a workspaceId is available.
if (result?.partnersFound) {
await captureWebhookLog({
workspaceId,
method: "POST",
path: "/intercom/webhook",
statusCode: 200,
duration: Date.now() - startTime,
requestBody: body,
responseBody: result.message,
userAgent: req.headers.get("user-agent"),
});
}The Intercom message forwarding architecture handles bi-directional message dispatch between Dub partners, internal users, and Intercom conversations. Message dispatch is handled by two core functions: forwardPartnerMessageToIntercom and forwardProgramMessageToIntercom. Both functions parse installation credentials, decrypt the access token using decrypt(), instantiate the Intercom client, resolve conversation threads via Redis keys, and prepare attachments before creating or replying to conversations.
The message forwarding process executes through distinct sequences depending on whether the sender is a partner or an internal program admin.
For partner message forwarding, the execution follows:
forwardPartnerMessageToIntercom() → intercomCredentialsSchema.parse() → new Intercom() → intercom.getOrCreateContact() → redis.get() → buildIntercomAttachments() → (intercom.createConversationAsContact() or intercom.replyAsContact()).
For program message forwarding, the execution follows:
forwardProgramMessageToIntercom() → intercomCredentialsSchema.parse() → new Intercom() → intercom.findAdminByEmail() → intercom.getOrCreateContact() → redis.get() → buildIntercomAttachments() → (intercom.createConversationAsAdmin() or intercom.replyAsAdmin()).
Warning
If a partner email or a sender user email is missing during message forwarding, execution immediately returns early without making external API calls or throwing an error.
Sources: apps/web/lib/integrations/intercom/forward-message.ts:28-30, apps/web/lib/integrations/intercom/forward-message.ts:109-111
Attachments stored in private R2 storage are prepared via buildIntercomAttachments(). Intercom only honors one attachment method per request: when both URLs and files are present, Intercom keeps the URLs and silently drops inline files. To prevent dropped payloads, the system evaluates whether every attachment is an image type (attachment.type.startsWith("image/")).
Tip
When creating a brand-new conversation via forwardPartnerMessageToIntercom where non-image files are present, the initial creation call sends only attachmentUrls, followed immediately by intercom.replyAsContact to deliver the remaining base64-encoded attachmentFiles.
Automated Slack Connect channel provisioning handles the creation of dedicated customer support channels, internal support team invitations, and rate-limit enforcement for workspace support requests. The backend orchestration validates plan capabilities, ensures trial restrictions are respected, sanitizes workspace slugs for channel naming, and dispatches API calls to Slack.
Sources: apps/web/lib/slack/support-invite.ts:1-200, apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:1-98
The execution of a Slack support invite request flows through multiple validation, channel creation, and invitation steps across the API endpoint and library modules.
POST /api/workspaces/[idOrSlug]/support/slack-invite → getPlanCapabilities() → isWorkspaceBillingTrialActive() → assertRateLimit() (workspace policy) → assertRateLimit() (user policy) → requestSlackConnectSupportInvite() → createSharedCustomerChannel() → sharedSupportChannelName() → slack.conversations.create() → sendSlackConnectInvite() → inviteInternalSupportMembersToChannel() → slack.usergroups.users.list() → slack.conversations.invite() → slack.conversations.inviteShared()
Sources: apps/web/lib/slack/support-invite.ts:27-156, apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:26-90
Warning
Priority Slack support is restricted exclusively to active Enterprise plans. Both free trial periods and non-enterprise plans throw a 403 forbidden DubApiError before any Slack API interactions occur.
The channel naming utility normalizes workspace slugs into valid Slack public channel names, enforcing length constraints and character replacements.
export function sharedSupportChannelName({
workspaceSlug,
}: {
workspaceSlug: string;
}): string {
const base = workspaceSlug
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const safeBase = base.length > 0 ? base : "workspace";
const prefixed = `shared-${safeBase}`;
return prefixed.length <= 80 ? prefixed : prefixed.slice(0, 80);
}Note
If slack.conversations.create encounters an existing channel name (name_taken), the function returns { nameTaken: true }, which triggers a 409 conflict error guiding the user to contact their Slack administrator.
Workspace support invite requests are subject to strict email validation, deduplication, and dual-layer rate limiting through Upstash policies.
const slackSupportInviteBodySchema = z.object({
emails: z.array(z.email()).min(1).max(SLACK_SUPPORT_INVITE_MAX_EMAILS),
});
function dedupeEmails(emails: string[]): string[] {
const seen = new Set<string>();
return emails.filter((e) => {
if (seen.has(e)) return false;
seen.add(e);
return true;
});
}Dedicated Slack support is exposed in the Dub web application via client-side components that enforce plan capability checks, active billing trial exclusions, and workspace permission validation.
In the onboarding success page client component (SuccessPageClient), the visibility of the Slack invite widget is controlled by evaluating plan capabilities and ensuring active trial periods are absent:
const showSlackInvite =
getPlanCapabilities(workspace.plan).canRequestSlackSupportInvite &&
!isWorkspaceBillingTrialActive(trialEndsAt);Similarly, the workspace settings card component (SlackSupportSettingsCard) queries workspace hooks for the current plan, user role, trial end date, and synchronization state. It evaluates permissions via clientAccessCheck before deciding to render:
const permissionsError = clientAccessCheck({
action: "workspaces.write",
role,
}).error;
if (
loading ||
!slug ||
dismissed ||
permissionsError ||
!getPlanCapabilities(plan).canRequestSlackSupportInvite ||
isWorkspaceBillingTrialActive(trialEndsAt)
) {
return null;
}Note
Dismissal states for the Slack support card are persisted locally through useSyncedLocalStorage, bound to the specific workspace slug key slack-support-dismissed:${slug}.
Internal administrators can manage and dispatch Slack support invites directly through the admin dashboard component (SlackSupportInvite). The component maintains local state for handling channel name conflicts (needsChannelId) when a pre-existing channel name collision occurs:
export function SlackSupportInvite() {
const [needsChannelId, setNeedsChannelId] = useState(false);
return (
<div className="flex flex-col space-y-5">
<form
action={async (data) => {
try {
const res = await fetch("/api/admin/slack-support-invite", {
method: "POST",
body: JSON.stringify({
email: data.get("email"),
workspaceSlug: data.get("workspaceSlug"),
channelId: data.get("channelId") || undefined,
}),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (json.nameTaken) {
setNeedsChannelId(true);
}
toast.error(
json.error ?? "Something went wrong. Please try again.",
);
return;
}
setNeedsChannelId(false);
toast.success(`Slack invite sent (ID: ${json.inviteId})`);
} catch {
toast.error(
"Network error. Please check your connection and try again.",
);
}
}}
>
<Form needsChannelId={needsChannelId} />
</form>
</div>
);
}Warning
The channel ID input element enforces a strict regular expression pattern (^[CG][A-Z0-9]{8,}$), ensuring that administrators provide valid Slack public channel or group identifiers starting with C or G.