---
title: "Customer Support Integrations"
description: "Customer Support Integrations connect Dub's platform workflows directly into external support tools like Plain, Intercom, and Slack. These integrations streamline troubleshooting by synchronizing c..."
last_updated: "2026-10-05T05:07:35.154101+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/customer-support-integrations"
---

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

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

- [apps/web/lib/slack/support-invite.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/app/ee/api/intercom/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts)
- [apps/web/app/ee/api/intercom/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts)
- [apps/web/app/ee/api/intercom/webhook/process/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts)
- [apps/web/app/api/ai/support-chat/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/ai/support-chat/route.ts)
- [apps/web/app/ee/api/intercom/webhook/health-check/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/health-check/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/api/callback/plain/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/api/slack/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/slack/callback/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/ui/guides/integrations.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/guides/integrations.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/lib/integrations/intercom/forward-message.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts)
- [apps/web/lib/integrations/slack/transform.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/slack/transform.ts)
- [apps/web/ui/support/chat-interface.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/chat-interface.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx)
- [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts)
- [apps/web/app/api/dub/webhook/lead-created.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/lead-created.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page-client.tsx)
- [apps/web/lib/integrations/slack/commands.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/slack/commands.ts)
- [apps/web/lib/ai/create-support-ticket.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts)
- [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts)
- [apps/web/lib/plain/sync-user-plan.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts)
- [apps/web/ui/workspaces/slack-support-settings-card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/workspaces/slack-support-settings-card.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/integrations/integrations-layout-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/integrations-layout-client.tsx)
</details>

## Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L272), [apps/web/app/ee/api/intercom/webhook/route.ts:1-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L1-L45), [apps/web/lib/slack/support-invite.ts:1-200](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L1-L200)

## Plain Customer and Workspace Context

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L272), [apps/web/app/api/callback/plain/workspace/route.ts:20-297](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts#L20-L297), [apps/web/lib/plain/sync-user-plan.ts:6-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L6-L92)

### Plan Synchronization and Customer Upsertion

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

```typescript
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];
  }
...
```

Sources: [apps/web/lib/plain/sync-user-plan.ts:6-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L6-L29)

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.

Sources: [apps/web/lib/plain/sync-user-plan.ts:57-91](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L57-L91)

> [!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.

Sources: [apps/web/lib/plain/sync-user-plan.ts:27-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L27-L46)

### Workspace Context Cards Execution Flow

When Plain requests context cards for a workspace support thread, the webhook processes the payload through a strict validation and lookup sequence.

1. **Authentication Check**: Verifies that `req.headers.get("X-Plain-Webhook-Secret")` matches `process.env.PLAIN_WEBHOOK_SECRET`, returning `401 Unauthorized` if invalid.
2. **Payload Parsing**: Validates the incoming body against `plainCallbackSchema`.
3. **User Resolution**: Queries `prisma.user.findUnique` using `customer.externalId` if present, or falls back to `customer.email`.
4. **Banned User Check**: If no user is found, checks `isBlacklistedEmail(customer.email)` and adds the customer to the `banned_users` group if true, returning an empty container card.
5. **Background Plan Sync**: Dispatches `waitUntil(syncUserPlanToPlain(user))` asynchronously via Vercel functions.
6. **Top Workspace Query**: Queries `prisma.project.findFirst` ordered by `usageLimit` descending to locate the user's highest-tier workspace.

Sources: [apps/web/app/api/callback/plain/workspace/route.ts:21-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts#L21-L89)

### Plan Badge Color Mapping

The workspace context endpoint dynamically assigns badge colors based on the user's plan configuration within the UI component renderer.

| Plan Pattern | Badge Color | Condition |
| :--- | :--- | :--- |
| `enterprise` | `RED` | `plan === "enterprise"` |
| `advanced` | `YELLOW` | `plan === "advanced"` |
| `business*` | `GREEN` | `plan.startsWith("business")` |
| `pro` | `BLUE` | `plan === "pro"` |
| Other / Free | `GREY` | Default fallback |

Sources: [apps/web/app/api/callback/plain/workspace/route.ts:157-167](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts#L157-L167)

### Partner Network Integration Cards

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.

Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L55)

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.

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

Sources: [apps/web/app/api/callback/plain/partner/route.ts:57-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L57-L87)

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.

Sources: [apps/web/app/api/callback/plain/partner/route.ts:112-272](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L112-L272)

## Plain AI Support Ticket Creation

### Overview

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.

Sources: [apps/web/lib/ai/create-support-ticket.ts:21-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L21-L28)

### Escalation Execution Flow

The ticket creation lifecycle follows an explicit sequence of data gathering, priority computation, component assembly, and API dispatch:

1. **Context Extraction**: The execute handler unpacks `accountType`, `selectedWorkspace`, `selectedProgram`, and `chatLocation` from `globalContext`.
2. **Chat History Formatting**: Maps incoming conversation messages into text blocks, appending image count notes (`(X image attached)`) for file parts and prefixing roles as `User:` or `Dub Support:`.
3. **Priority & Metadata Resolution**: Calls `getPriorityAndMetadata()` to query workspace plans or partner lifetime payouts and determine ticket priority levels and custom metadata rows.
4. **Component Construction**: Builds Plain component arrays containing trimmed user descriptions, divider sizes (`ComponentDividerSpacingSize.M` and `ComponentDividerSpacingSize.L`), truncated chat histories (up to 5,000 characters), chat locations, and additional metadata key-value pairs.
5. **Plain Thread Dispatch**: Invokes `createPlainThread()` passing user identification details, computed priority, component structures, and optional attachment IDs.

Sources: [apps/web/lib/ai/create-support-ticket.ts:29-114](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L29-L114)

> [!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.

Sources: [apps/web/lib/ai/create-support-ticket.ts:234-237](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L234-L237)

### Priority Mapping Reference

Ticket priority and metadata values are determined dynamically based on the user's active workspace plan or lifetime partner payouts.

| Account Type / Tier | Condition | Priority Level | Metadata Fields Assigned |
| :--- | :--- | :--- | :--- |
| Workspace (Enterprise / Advanced) | `workspace.plan` is `"enterprise"` or `"advanced"` | `0` | Workspace Name, Slug, Plan |
| Workspace (Business) | `workspace.plan` is `"business"` | `1` | Workspace Name, Slug, Plan |
| Workspace (Pro) | `workspace.plan` is `"pro"` | `2` | Workspace Name, Slug, Plan |
| Workspace (Default) | Other plans or unlinked workspace | `3` | None |
| Partner (Top Tier) | `partnerLifetimePayouts > 10_000_00` | `0` | Program Name, Slug, Support Email, Holding Period, Min Payout, Lifetime Payouts |
| Partner (Mid Tier) | `partnerLifetimePayouts > 1_000_00` | `1` | Program Name, Slug, Support Email, Holding Period, Min Payout, Lifetime Payouts |
| Partner (Standard Tier) | `partnerLifetimePayouts > 100_00` | `2` | Program Name, Slug, Support Email, Holding Period, Min Payout, Lifetime Payouts |
| Partner (Default) | Lifetime payouts $\le$ 100.00 or unlinked | `3` | None |

Sources: [apps/web/lib/ai/create-support-ticket.ts:141-232](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L141-L232)

### Ticket Escalation UI Integration

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.

Sources: [apps/web/ui/support/chat-interface.tsx:370-391](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/chat-interface.tsx#L370-L391)

> [!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.

Sources: [apps/web/ui/support/chat-interface.tsx:444-450](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/chat-interface.tsx#L444-L450)

Support chat backend routes map tool execution names to human-readable Slack tool labels using the `SLACK_TOOL_LABELS` lookup record:

```typescript
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",
};
```

Sources: [apps/web/app/api/ai/support-chat/route.ts:294-301](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/ai/support-chat/route.ts#L294-L301)

## Intercom Integration Setup and OAuth

### Overview

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.

Sources: [apps/web/app/ee/api/intercom/callback/route.ts:16-126](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L16-L126)

### OAuth Callback Execution Walkthrough

When Intercom redirects back to the application, the callback route executes a precise sequence of validation and persistence checks:

1. `getSession()`: Retrieves the authenticated user session; throws a `DubApiError` with code `"unauthorized"` if no user ID is present.
2. `intercomOAuthProvider.exchangeCodeForToken<string>(req)`: Exchanges the authorization code for an access token and extracts the target workspace context ID (`workspaceId`).
3. `prisma.project.findUniqueOrThrow()`: Queries the target workspace, verifying user membership and fetching the user role and workspace plan.
4. **Role & Plan Validation**: Checks that the user is a workspace member (`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.
5. `prisma.integration.findUniqueOrThrow()`: Fetches the unique integration record matching `INTERCOM_INTEGRATION_ID`.
6. `new Intercom({ token: token.access_token })` & `intercom.getAdmin()`: Instantiates the Intercom client and retrieves admin details to verify the Intercom workspace `app.id_code`.
7. `intercomCredentialsSchema.parse()`: Encrypts the raw access token using `encrypt()` and bundles it with the verified `appId` according to the schema.
8. `installIntegration()`: Persists the integration installation with the encrypted credentials, followed by redirecting the user to `/${workspace.slug}/settings/integrations/intercom`.

Sources: [apps/web/app/ee/api/intercom/callback/route.ts:34-125](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L34-L125)

> [!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.

Sources: [apps/web/app/ee/api/intercom/callback/route.ts:73-78](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L73-L78)

> [!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.

Sources: [apps/web/app/ee/api/intercom/callback/route.ts:20-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L20-L27)

### Integration Credential Schema and Encryption

The credential payload validated before installation maps access tokens through cryptographic encryption and associates them with the Intercom application identifier.

| Credential Field | Transformation / Source | Purpose |
| :--- | :--- | :--- |
| `accessToken` | `encrypt(token.access_token)` | Securely encrypts the OAuth access token before database storage |
| `appId` | `admin.app?.id_code` | Stores the validated Intercom workspace identifier |

Sources: [apps/web/app/ee/api/intercom/callback/route.ts:110-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L110-L113)

> [!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."`

Sources: [apps/web/app/ee/api/intercom/callback/route.ts:103-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L103-L108)

## Intercom Webhook Ingestion and Processing

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L1-L45), [apps/web/app/ee/api/intercom/webhook/process/route.ts:1-127](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L1-L127)

### Webhook Event Ingestion Call Chain

When an incoming POST request arrives at `/api/intercom/webhook`, the runtime executes a strict verification and dispatch sequence:

1. `verifyIntercomWebhookSignature(req)`: Validates the request signature using the raw request headers and body.
2. `JSON.parse(rawBody)`: Parses the verified raw payload into a JavaScript object.
3. `intercomWebhookSchema.parse(body)`: Validates the payload structure against the Zod schema to extract the `topic`.
4. `relevantTopics.has(topic)`: Evaluates whether the event topic is recognized. Supported topics are restricted to `conversation.admin.replied` and `ping`.
5. `enqueueBatchJobs([...])`: Dispatches a background processing job to QStash targeting `/api/intercom/webhook/process` when the topic requires processing.

Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:9-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L9-L34)

> [!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.

Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:19-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L19-L21)

### Webhook Processing and Lifecycle Endpoints

Asynchronous event processing and companion management routes validate QStash signatures or intercom signatures, inspect database installation records, and handle application uninstalls or health checks.

| Endpoint Route | Verification Method | Action / Purpose |
| :--- | :--- | :--- |
| `POST /api/intercom/webhook` | `verifyIntercomWebhookSignature(req)` | Ingests raw webhooks, checks topics, and enqueues batch jobs via QStash. |
| `POST /api/intercom/webhook/process` | `verifyQstashSignature(...)` | Verifies QStash signature, looks up project installations, checks program status, and dispatches `conversation.admin.replied` handlers. |
| `POST /api/intercom/webhook/health-check` | `verifyIntercomWebhookSignature(req)` | Validates installation existence and credential schema validity, returning `OK` or `UNHEALTHY` states. |
| `POST /api/intercom/webhook/uninstall` | `verifyIntercomWebhookSignature(req)` | Parses uninstallation webhooks and deletes the matching `InstalledIntegration` record from Prisma. |

Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:14-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L14-L34), [apps/web/app/ee/api/intercom/webhook/process/route.ts:19-93](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L19-L93), [apps/web/app/ee/api/intercom/webhook/health-check/route.ts:16-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/health-check/route.ts#L16-L62), [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts:12-41](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts#L12-L41)

> [!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`.

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

### Message Logging and Error Handling

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.

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

Sources: [apps/web/app/ee/api/intercom/webhook/process/route.ts:95-106](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L95-L106)

## Intercom Message Forwarding Architecture

### Overview

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.

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:15-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L15-L179)

### Message Forwarding Call Chains

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

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:15-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L15-L93)

For program message forwarding, the execution follows:
`forwardProgramMessageToIntercom()` → `intercomCredentialsSchema.parse()` → `new Intercom()` → `intercom.findAdminByEmail()` → `intercom.getOrCreateContact()` → `redis.get()` → `buildIntercomAttachments()` → (`intercom.createConversationAsAdmin()` or `intercom.replyAsAdmin()`).

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:95-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L95-L179)

> [!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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L28-L30), [apps/web/lib/integrations/intercom/forward-message.ts:109-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L109-L111)

### Attachment Handling and Routing Strategy

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/")`).

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:181-200](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L181-L200)

```mermaid
flowchart TD
    A["buildIntercomAttachments(attachments)"] --> B{"attachments.every(isImage)"}
    B -- Yes --> C["storage.getSignedDownloadUrl()"]
    C --> D["Push to attachmentUrls"]
    B -- No --> E["fetch(signedUrl)"]
    E --> F["Buffer.toString('base64')"]
    F --> G["Push to attachmentFiles"]
```

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:188-234](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L188-L234)

| Condition | Attachment Processing Action | Delivery Method |
| :--- | :--- | :--- |
| All attachments are images (`allImages === true`) | Retrieves signed download URLs expiring in 30 minutes (`expiresIn: 30 * 60`). | Pushed to `attachmentUrls` for Intercom to fetch directly. |
| Mixed attachments or non-images | Fetches signed URL content, converts to buffer, and encodes to base64. | Pushed to `attachmentFiles` containing `content_type`, `data`, and `name`. |

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:197-222](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L197-L222)

> [!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`.

Sources: [apps/web/lib/integrations/intercom/forward-message.ts:68-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L68-L78)

## Automated Slack Connect Channel Provisioning

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L1-L200), [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:1-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L1-L98)

### Support Invite Call-Chain Execution

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L27-L156), [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:26-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L26-L90)

> [!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.

Sources: [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:28-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L28-L42)

### Channel Naming and Creation Constants

The channel naming utility normalizes workspace slugs into valid Slack public channel names, enforcing length constraints and character replacements.

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

Sources: [apps/web/lib/slack/support-invite.ts:58-71](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L58-L71)

| Parameter / Constant | Value / Behavior | Purpose |
| :--- | :--- | :--- |
| `INTERNAL_SUPPORT_USERGROUP_ID` | `"S0AJUBR8Y1Y"` | Slack user group identifier for internal support team members. |
| Max user invite slice | `100` | Slices the usergroup list to a maximum of 100 members per invite call. |
| Max channel name length | `80` characters | Truncates prefixed channel names that exceed 80 characters. |
| Default fallback base | `"workspace"` | Used when a workspace slug results in an empty base after sanitization. |

Sources: [apps/web/lib/slack/support-invite.ts:19-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L19-L37), [apps/web/lib/slack/support-invite.ts:68-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L68-L70)

> [!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.

Sources: [apps/web/lib/slack/support-invite.ts:94-99](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L94-L99), [apps/web/lib/slack/support-invite.ts:136-142](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L136-L142)

### Rate Limiting and Email Validation

Workspace support invite requests are subject to strict email validation, deduplication, and dual-layer rate limiting through Upstash policies.

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

Sources: [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L12-L23)

| Rate Limit Policy Key | Identifier Scope | Target |
| :--- | :--- | :--- |
| `RATELIMIT_POLICIES.slackSupportInviteWorkspace` | Workspace ID (`workspace.id`) | Prevents excessive invites generated per workspace. |
| `RATELIMIT_POLICIES.slackSupportInviteUser` | Combined Workspace and User ID (`[workspace.id, session.user.id]`) | Limits requests per user within a given workspace. |

Sources: [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:76-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L76-L84)

## Slack Support UI and Lifecycle

### Client Components and Eligibility Gates

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:

```typescript
  const showSlackInvite =
    getPlanCapabilities(workspace.plan).canRequestSlackSupportInvite &&
    !isWorkspaceBillingTrialActive(trialEndsAt);
```

Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx:53-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page-client.tsx#L53-L56)

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:

```typescript
  const permissionsError = clientAccessCheck({
    action: "workspaces.write",
    role,
  }).error;

  if (
    loading ||
    !slug ||
    dismissed ||
    permissionsError ||
    !getPlanCapabilities(plan).canRequestSlackSupportInvite ||
    isWorkspaceBillingTrialActive(trialEndsAt)
  ) {
    return null;
  }
```

Sources: [apps/web/ui/workspaces/slack-support-settings-card.tsx:24-38](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/workspaces/slack-support-settings-card.tsx#L24-L38)

> [!NOTE]
> Dismissal states for the Slack support card are persisted locally through `useSyncedLocalStorage`, bound to the specific workspace slug key `slack-support-dismissed:${slug}`.

Sources: [apps/web/ui/workspaces/slack-support-settings-card.tsx:19-22](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/workspaces/slack-support-settings-card.tsx#L19-L22)

### Admin UI Support Invite Form

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:

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

Sources: [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx:9-51](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx#L9-L51)

| Input Field Name | Element Type | Validation / Pattern | Purpose |
| :--- | :--- | :--- | :--- |
| `email` | `email` | `required`, `autoComplete="off"` | Recipient email address for the support invite. |
| `workspaceSlug` | `text` | `required`, `autoComplete="off"` | Target workspace slug prefix (`app.dub.co` domain context). |
| `channelId` | `text` | `pattern="^[CG][A-Z0-9]{8,}$"` | Optional explicit Slack channel ID requested when `nameTaken` returns true. |

Sources: [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx:64-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx#L64-L117)

> [!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`.

Sources: [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx:112-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx#L112-L112)

## Related

- [Partner Portal and Onboarding](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-portal-and-onboarding)


## Sitemap

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