---
title: "Workspace Subscriptions"
description: "Workspace Subscriptions manages enterprise billing integration, subscription lifecycles, and plan quotas through Stripe webhooks, REST API endpoints, and Tinybird metrics tracking. It automates wor..."
last_updated: "2026-10-12T07:00:10.100727+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/billing-and-payouts/workspace-subscriptions"
---

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

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

- [apps/web/lib/swr/use-webhooks.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/swr/use-webhooks.ts)
- [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/route.ts)
- [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts)
- [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts)
- [apps/web/app/api/callback/plain/workspace/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/plain/workspace/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/manage/route.ts)
- [apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/invoice-paid.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts)
- [apps/web/app/api/webhooks/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/webhooks/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/upgrade/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/ee/api/stripe/connect/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/payment-methods/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/invoices/route.ts)
- [apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/plan-usage.tsx)
- [apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/dub.ts)
- [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/webhooks/page-client.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/webhooks/page-client.tsx)
- [packages/stripe-app/src/utils/dub.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/stripe-app/src/utils/dub.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/activate-paid-plan/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/activate-paid-plan/route.ts)
- [apps/web/app/api/dub/webhook/lead-created.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/dub/webhook/lead-created.ts)
- [packages/stripe-app/src/hooks/use-workspace.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/stripe-app/src/hooks/use-workspace.ts)
- [apps/web/ui/workspaces/subscription-menu.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/workspaces/subscription-menu.tsx)
- [apps/web/lib/api/billing/recompute-workspace-usage.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/billing/recompute-workspace-usage.ts)
</details>

## Overview

Workspace Subscriptions manages enterprise billing integration, subscription lifecycles, and plan quotas through Stripe webhooks, [REST API](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/administration-and-api/rest-api) endpoints, and Tinybird metrics tracking. It automates workspace plan upgrades, trial enforcement, payment delinquency handling, and billing portal access while synchronizing customer subscription states with the Plain CRM dashboard.

Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:33-104](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L104), [apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts:8-35](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/manage/route.ts#L8-L35), [apps/web/lib/api/billing/recompute-workspace-usage.ts:8-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/billing/recompute-workspace-usage.ts#L8-L50)

## Workspace Billing Endpoints

### Portal Management and Plan Upgrades

Dub exposes workspace-authenticated REST API routes under `/api/workspaces/[idOrSlug]/billing/` to manage subscriptions, payment methods, invoices, and billing portal sessions via Stripe. These endpoints require appropriate permissions such as `billing.write` or `workspaces.read` through the `withWorkspace` middleware wrapper.

The billing management and upgrade routes interface directly with Stripe sessions. The portal management endpoint creates a Stripe billing portal session restricted by configuration `bpc_1UEvGVAlJJEpqkPVpP38ZUNj` for updating billing details, redirecting users back to `${APP_DOMAIN}/${workspace.slug}/settings/billing`. Enterprise workspaces are restricted from this endpoint, throwing a `forbidden` `DubApiError` because their billing is handled by the account team.

```typescript
if (workspace.plan === "enterprise") {
  throw new DubApiError({
    code: "forbidden",
    message:
      "Since you're on an Enterprise plan, billing is managed by your account team. Please reach out to them to manage your subscription.",
  });
}

try {
  const { url } = await stripe.billingPortal.sessions.create({
    customer: workspace.stripeId,
    configuration: "bpc_1UEvGVAlJJEpqkPVpP38ZUNj", // custom configuration for changing billing info only
    return_url: `${APP_DOMAIN}/${workspace.slug}/settings/billing`,
  });
```
Sources: [apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts:14-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/manage/route.ts#L14-L27)

The upgrade route parses requests against `upgradePlanSchema`, accepting `plan` (`pro`, `business`, `advanced`), `period` (`monthly`, `yearly`), optional `tier` (1 to 3), an `onboarding` boolean flag, and a `baseUrl` validated to start with `APP_DOMAIN`. If a workspace has an active trialing subscription, upgrades update the subscription directly with `proration_behavior: "none"` without issuing invoices or immediate charges. For active non-trialing subscriptions, the endpoint generates a Stripe billing portal session configured with `subscription_update_confirm`. If no subscription exists, it initiates a Stripe checkout session with automatic tax and tax ID collection enabled, setting trial periods via `DUB_TRIAL_PERIOD_DAYS` when eligible.

```typescript
const upgradePlanSchema = z.object({
  plan: z.enum(["pro", "business", "advanced"]),
  period: z.enum(["monthly", "yearly"]),
  tier: z.number().min(1).max(3).optional().default(1),
  baseUrl: z.string().refine((url) => url.startsWith(APP_DOMAIN), {
    message: "Invalid baseUrl.",
  }),
  onboarding: booleanQuerySchema.nullish(),
});
```
Sources: [apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:12-20](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/upgrade/route.ts#L12-L20), [apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:68-84](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/upgrade/route.ts#L68-L84), [apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:103-123](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/upgrade/route.ts#L103-L123), [apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:141-191](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/upgrade/route.ts#L141-L191)

> [!WARNING]
> Preview environments running on Vercel (`VERCEL === "1"` and `VERCEL_ENV === "preview"`) enforce an administrative role check on plan upgrades; unauthenticated requests or users without a Dub admin role receive an `unauthorized` error.

Sources: [apps/web/app/api/workspaces/idOrSlug/billing/upgrade/route.ts:57-65](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/upgrade/route.ts#L57-L65)

### Payment Methods and Invoices

Payment methods are retrieved, added, updated, and removed via `/api/workspaces/[idOrSlug]/billing/payment-methods`. The `GET` handler merges Stripe payment methods with pending microdeposits and reorders direct debit methods to the front of the list. The `POST` handler supports adding custom payment types or returning a billing portal session for payment method updates. 

```typescript
const [paymentMethods, customer] = await Promise.all([
  stripe.paymentMethods.list({
    customer: workspace.stripeId,
  }),
  stripe.customers.retrieve(workspace.stripeId),
]);

const pendingMicrodeposits = await listPendingMicrodeposits(
  workspace.stripeId,
).catch(() => []);

const defaultPaymentMethod =
  customer.deleted !== true
    ? customer.invoice_settings?.default_payment_method
    : null;

const defaultPaymentMethodId =
  typeof defaultPaymentMethod === "string"
    ? defaultPaymentMethod
    : defaultPaymentMethod?.id ?? null;

const methods = withMicrodepositStatus({
  paymentMethods: paymentMethods.data,
  pendingMicrodeposits,
});

// reorder to put direct debit first
const directDebit = methods.find((method) =>
  DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(method.type),
);

return NextResponse.json({
  paymentMethods: [
    ...(directDebit ? [directDebit] : []),
    ...methods.filter((method) => method.id !== directDebit?.id),
  ],
  defaultPaymentMethodId,
});
```
Sources: [apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts:62-99](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/payment-methods/route.ts#L62-L99)

| HTTP Method | Request Body / Schema | Action & Behavior |
| :--- | :--- | :--- |
| `GET` | None (Query params optional) | Retrieves customer payment methods, pending microdeposits, and invoice settings default payment method. |
| `POST` | `addPaymentMethodSchema` (`method?: string`) | Creates a Stripe setup checkout session for a specific payment method or opens a billing portal session. |
| `PATCH` | `updatePaymentMethodSchema` (`paymentMethodId: string`) | Sets the default payment method on Stripe customer invoice settings (direct debits disallowed). |
| `DELETE` | `deletePaymentMethodSchema` (`paymentMethodId: string`) | Detaches and removes the specified payment method from the Stripe customer. |

Sources: [apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts:20-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/payment-methods/route.ts#L20-L30), [apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts:51-236](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/payment-methods/route.ts#L51-L236)

Invoice listing is handled by `/api/workspaces/[idOrSlug]/billing/invoices`, which accepts query parameters validated against `querySchema`. It supports retrieving Stripe subscription invoices (`type: "subscription"`) limited to 100 entries, as well as internal Prisma-stored invoices for partner payouts (`partnerPayout`) and domain renewals (`domainRenewal`).

```typescript
const querySchema = z.object({
  type: z
    .enum(["subscription", "partnerPayout", "domainRenewal"])
    .optional()
    .default("subscription"),
  stripeStatus: z.enum(["open"]).optional(), // only for Stripe subscription invoices
});
```
Sources: [apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts:9-15](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/invoices/route.ts#L9-L15), [apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts:24-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/invoices/route.ts#L24-L34)

### Trial Activation

The `/api/workspaces/[idOrSlug]/billing/activate-paid-plan` endpoint immediately terminates any active trial for the workspace by setting `trial_end` to `"now"` and `cancel_at_period_end` to `false` on the Stripe subscription, triggering an immediate charge and transitioning the subscription to active status.

```typescript
const { data: trialingSubs } = await stripe.subscriptions.list({
  customer: workspace.stripeId,
  status: "trialing",
  limit: 1,
});
const trialingSubscription = trialingSubs[0];

if (trialingSubscription) {
  await stripe.subscriptions.update(trialingSubscription.id, {
    trial_end: "now",
    cancel_at_period_end: false,
  });
  return NextResponse.json({ ok: true });
}
```
Sources: [apps/web/app/api/workspaces/idOrSlug/billing/activate-paid-plan/route.ts:13-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/activate-paid-plan/route.ts#L13-L26)

## Stripe Webhook Dispatching

### Webhook Route and Signature Verification

Incoming Stripe webhooks are received at the `POST /api/stripe/webhook` route wrapped with `withAxiom`. The handler reads the raw request body as text and extracts the `Stripe-Signature` header alongside the `STRIPE_WEBHOOK_SECRET` environment variable. If either the signature or the secret is missing, it responds immediately with an `Invalid request` status of 400 via `logAndRespond`. Otherwise, `stripe.webhooks.constructEvent` verifies the cryptographic payload signature, returning a typed `Stripe.Event` object or catching parsing errors to log and return a 400 response.

```typescript
export const POST = withAxiom(async (req: Request) => {
  const buf = await req.text();
  const sig = req.headers.get("Stripe-Signature") as string;
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  let event: Stripe.Event;
  try {
    if (!sig || !webhookSecret) {
      return logAndRespond("Invalid request", { status: 400 });
    }
    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
  } catch (err: any) {
    console.log(`❌ Error message: ${err.message}`);
    return new Response(`Webhook Error: ${err.message}`, {
      status: 400,
    });
  }
```
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:33-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L48)

> [!WARNING]
> Requests lacking a valid `Stripe-Signature` header or evaluated against an unconfigured `STRIPE_WEBHOOK_SECRET` are short-circuited with a 400 error before any event parsing occurs.

Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:39-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/route.ts#L39-L41)

### Event Demultiplexing and Handler Routing

Once constructed, the event type is checked against a predefined `relevantEvents` set. Events not present in this set are ignored, returning a 200 status response with `Unsupported event, skipping...`. Relevant events pass through a `switch` statement that routes each payload to its dedicated handler function. Any unhandled execution error within a handler is caught, logged to Axiom with `log`, and returned as a 400 webhook error response.

| Event Type | Handler Function |
| :--- | :--- |
| `charge.succeeded` | `chargeSucceeded(event)` |
| `charge.failed` | `chargeFailed(event)` |
| `charge.refunded` | `chargeRefunded(event)` |
| `charge.dispute.created` | `chargeDisputeCreated(event)` |
| `checkout.session.completed` | `checkoutSessionCompleted(event)` |
| `customer.subscription.created` | `customerSubscriptionCreated(event)` |
| `customer.subscription.updated` | `customerSubscriptionUpdated(event)` |
| `customer.subscription.deleted` | `customerSubscriptionDeleted(event)` |
| `invoice.payment_failed` | `invoicePaymentFailed(event)` |
| `payment_intent.requires_action` | `paymentIntentRequiresAction(event)` |
| `transfer.reversed` | `transferReversed(event)` |

Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:18-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L30), [apps/web/app/ee/api/stripe/webhook/route.ts:50-93](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/route.ts#L50-L93)

### Subscription Update Processing

The `customerSubscriptionUpdated` function processes updates for subscriptions with status values of `"active"`, `"trialing"`, or `"past_due"`. Any other subscription status causes the handler to skip processing and return an invalid status message. Workspaces are queried via Prisma using the subscription's customer string mapped to `stripeId`, selecting workspace owner users (excluding machine users) and restricted tokens.

```typescript
export async function customerSubscriptionUpdated(
  event: Stripe.CustomerSubscriptionUpdatedEvent,
) {
  const updatedSubscription = event.data.object;
  const priceId = updatedSubscription.items.data[0].price.id;

  if (
    ![
      "active",
      "trialing",
      "past_due", // special case for handling past_due subscriptions (after a free trial)
    ].includes(updatedSubscription.status)
  ) {
    return `Invalid updated subscription status "${updatedSubscription.status}" for subscription ${updatedSubscription.id}, skipping...`;
  }

  const stripeId = updatedSubscription.customer.toString();

  const workspace = await prisma.project.findUnique({
    where: {
      stripeId,
    },
    include: {
      users: {
        select: {
          user: {
            select: {
              email: true,
              name: true,
            },
          },
        },
        where: {
          role: "owner",
          user: {
            isMachine: false,
          },
        },
      },
      restrictedTokens: {
        select: {
          hashedKey: true,
        },
      },
    },
  });
```
Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:8-53](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts#L8-L53)

> [!NOTE]
> When a subscription enters the `past_due` state, the handler checks if the workspace trial ended less than 2 hours ago. If so, it reverts the workspace plan limits back to `TRIAL_LIMITS` values across all tracked resource quotas.

Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:59-89](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts#L59-L89)

If the subscription status is active or trialing, `updateWorkspacePlan` is invoked with the workspace, price ID, and subscription object. If `cancel_at_period_end` is enabled, owner details and cancellation feedback are extracted to dispatch cancellation feedback notifications.

```typescript
await updateWorkspacePlan({
  workspace,
  priceId,
  subscription: updatedSubscription,
});

if (updatedSubscription.cancel_at_period_end) {
  const owners = workspace.users.map(({ user }) => user);
  const cancelReason = updatedSubscription.cancellation_details?.feedback;

  await sendCancellationFeedback({
    workspace,
    owners,
    reason: cancelReason,
  });
  return `Sent cancellation feedback to ${owners.length} workspace owners for workspace ${workspace.slug}.`;
}
```
Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:93-109](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts#L93-L109)

## Subscription Lifecycle and Plan Changes

### Checkout Session Fulfillment and Limits

When a Stripe checkout session completes successfully, `checkoutSessionCompleted` validates that the session mode is set to `"subscription"` and that the payment status is either `"paid"` or `"no_payment_required"`. Setup sessions or uncompleted payments are skipped. The system retrieves the associated Stripe subscription, extracts the price ID, and maps it to a plan and tier using `getPlanAndTierFromPriceId`. If `client_reference_id` or `customer` is missing, an error is logged and processing terminates.

```typescript
if (checkoutSession.mode === "setup") {
  return "Session is setup mode, skipping...";
}

if (checkoutSession.mode === "subscription") {
  if (
    checkoutSession.payment_status !== "paid" &&
    checkoutSession.payment_status !== "no_payment_required"
  ) {
    return "Subscription checkout session not completed (payment status), skipping...";
  }
} else {
  return `Session mode ${checkoutSession.mode} is not handled here, skipping...`;
}

if (
  checkoutSession.client_reference_id === null ||
  checkoutSession.customer === null
) {
  await log({
    message: "Missing items in Stripe webhook callback",
    type: "errors",
  });
  return "Missing client_reference_id or customer in checkout session.";
}
```
Sources: [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:24-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L24-L48)

Workspace limits are calculated through `getWorkspaceLimitsFromStripeSubscription`. When a subscription is in a trial state, restricted trial quotas from `TRIAL_LIMITS` are applied across all tracked metrics instead of standard plan limits. For active non-trial yearly subscriptions, limits for clicks, links, payouts, and AI capabilities are multiplied by 12 to reflect the annual billing period allocation.

```typescript
export function getWorkspaceLimitsFromStripeSubscription({
  planLimits,
  subscription,
}: {
  planLimits: PlanDetails["limits"];
  subscription: Stripe.Subscription;
}): PlanDetails["limits"] {
  if (subscription.status !== "trialing") {
    const subscriptionPeriod =
      getPlanPeriodFromStripeSubscription(subscription);
    return {
      ...planLimits,
      ...(subscriptionPeriod === "yearly"
        ? {
            clicks: planLimits.clicks * 12,
            links: planLimits.links * 12,
            payouts: planLimits.payouts * 12,
            ai: planLimits.ai * 12,
          }
        : {}),
    };
  }

  return {
    ...planLimits,
    links: TRIAL_LIMITS.links,
    clicks: TRIAL_LIMITS.clicks,
    payouts: TRIAL_LIMITS.payouts,
    users: TRIAL_LIMITS.users,
    domains: TRIAL_LIMITS.domains,
    tags: TRIAL_LIMITS.tags,
    partnerTags: TRIAL_LIMITS.partnerTags,
    folders: TRIAL_LIMITS.folders,
    groups: TRIAL_LIMITS.groups,
    networkInvites: TRIAL_LIMITS.networkInvites,
    partners: TRIAL_LIMITS.partners,
    ai: TRIAL_LIMITS.ai,
    api: TRIAL_LIMITS.api,
    analyticsApi: TRIAL_LIMITS.analyticsApi,
  };
```
Sources: [apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts:5-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts#L5-L44)

> [!NOTE]
> Trial limits strictly override standard plan limits regardless of the base plan tier selected during checkout, enforcing a uniform constraint on features like links, clicks, and API access until the trial period expires and converts to a paid subscription.

Sources: [apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts:28-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts#L28-L44)

### Plan Upgrades and Downgrade Mitigation

The `updateWorkspacePlan` utility manages transitions when a workspace modifies its subscription plan, tier, or billing period. If a workspace shifts from a yearly to a monthly billing period, `recomputeWorkspaceUsage` recalculates current usage metrics against the reduced limits. 

```typescript
const isYearlyToMonthly =
  workspace.planPeriod === "yearly" && planPeriod === "monthly";

const recomputedUsage = isYearlyToMonthly
  ? await recomputeWorkspaceUsage(workspace)
  : null;

```
Sources: [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:108-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L108-L114)

```mermaid
flowchart TD
    A["Subscription Event Received"] --> B{"Session Mode Valid?"}
    B -- No --> C["Skip Processing"]
    B -- Yes --> D["Retrieve Stripe Subscription"]
    D --> E["Extract Price ID & Map Plan"]
    E --> F{"Subscription Status"}
    F -- Trialing --> G["Apply TRIAL_LIMITS"]
    F -- Active/Other --> H["Calculate Plan Limits"]
    G --> I["Update Workspace Project Record"]
    H --> I
    I --> J["Complete Onboarding & Token Cache Expire"]
    J --> K["Dispatch Welcome or Trial Emails"]
```
Sources: [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:24-74](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L24-L74), [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:138-174](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L138-L174), [apps/web/app/ee/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts:5-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/get-workspace-limits-from-stripe-subscription.ts#L5-L44)

## Usage Recomputation and Quota Accounting

### Aggregating Metrics Across Billing Windows

When a workspace transitions from a yearly to a monthly plan period, usage metrics are explicitly recomputed via `recomputeWorkspaceUsage`. This function determines the current billing start date using `getBillingStartDate` based on the workspace's `billingCycleStart` field, setting the end date to the current timestamp.

```typescript
export async function recomputeWorkspaceUsage(
  workspace: Pick<Project, "id" | "billingCycleStart">,
) {
  const billingStart = getBillingStartDate(workspace.billingCycleStart);
  const billingEnd = new Date();

```
Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:8-13](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/billing/recompute-workspace-usage.ts#L8-L13)

Three concurrent data aggregation queries execute to assemble the updated usage state: click events and link counts fetched from Tinybird via `getWorkspaceUsage`, alongside completed partner payout invoice amounts aggregated from Prisma.

```typescript
const [clicksData, linksData, payoutsUsage] = await Promise.all([
  getWorkspaceUsage({
    workspaceId: workspace.id,
    resource: "events",
    start: billingStart,
    end: billingEnd,
  }),

  getWorkspaceUsage({
    workspaceId: workspace.id,
    resource: "links",
    start: billingStart,
    end: billingEnd,
  }),

  prisma.invoice.aggregate({
    where: {
      workspaceId: workspace.id,
      type: "partnerPayout",
      status: "completed",
      paidAt: {
        gte: billingStart,
        lte: billingEnd,
      },
    },
    _sum: {
      amount: true,
    },
  }),
]);
```
Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:14-43](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/billing/recompute-workspace-usage.ts#L14-L43)

### Recomputation Parameters and Operations

The recomputation routine processes event and resource streams across specific parameters before mapping them to workspace project columns.

| Metric Resource | Source Provider | Aggregation Logic | Target Project Field |
| :--- | :--- | :--- | :--- |
| `events` | Tinybird (`getWorkspaceUsage`) | Sum of array values (`d.value`) | `usage` |
| `links` | Tinybird (`getWorkspaceUsage`) | Sum of array values (`d.value`) | `linksUsage` |
| `partnerPayout` | Prisma Invoice model | Aggregate sum where status is `completed` | `payoutsUsage` |

Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:14-49](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/billing/recompute-workspace-usage.ts#L14-L49), [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:154-157](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L154-L157)

> [!NOTE]
> When `recomputedUsage` is generated during a yearly-to-monthly downgrade, the update payload also deletes existing usage limit notification emails (`firstUsageLimitEmail`, `secondUsageLimitEmail`, `firstLinksLimitEmail`, and `secondLinksLimitEmail`) from the `sentEmails` relation to reset warning thresholds for the new billing cycle.

Sources: [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:154-170](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L154-L170)

## Delinquency Handling and Dispute Enforcement

### Invoice Payment Failure Processing

When Stripe fires an `invoice.payment_failed` event, the system extracts the customer ID, attempt count, and amount due. It queries the `project` model in Prisma using the Stripe customer ID to identify the workspace and filters workspace users to exclude machine accounts where `isMachine` is true.

```tsx
export async function invoicePaymentFailed(
  event: Stripe.InvoicePaymentFailedEvent,
) {
  const {
    customer: stripeId,
    attempt_count: attemptCount,
    amount_due: amountDue,
  } = event.data.object;

  if (!stripeId) {
    return "No customer found in invoice.payment_failed event.";
  }

  const workspace = await prisma.project.findUnique({
    where: {
      stripeId: stripeId.toString(),
    },
    select: {
      id: true,
      name: true,
      slug: true,
      plan: true,
      defaultProgramId: true,
      users: {
        select: {
          user: {
            select: {
              name: true,
              email: true,
            },
          },
        },
        where: {
          user: {
            isMachine: false,
          },
        },
      },
    },
  });
```
Sources: [apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:6-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx#L6-L45)

The workspace record receives a timestamp update on its `paymentFailedAt` field, while notification emails are dispatched concurrently using `Promise.allSettled` to all eligible human users. The email subject dynamically adjusts based on the payment `attemptCount` value.

| Attempt Count | Email Subject Prefix |
| :--- | :--- |
| `1` | `Your payment for Dub.co failed` |
| `2` | `2nd notice: Your payment for Dub.co failed` |
| `3` | `3rd notice: Your payment for Dub.co failed` |
| `4+` (default) | `Final notice: Your payment for Dub.co failed` |

Sources: [apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:51-86](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx#L51-L86)

> [!WARNING]
> `invoicePaymentFailed` uses `Promise.allSettled` when updating the project timestamp and dispatching notification emails. If individual email deliveries fail for specific users, the workspace payment failure timestamp update still resolves successfully.

Sources: [apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:51-86](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx#L51-L86)

### Charge Dispute Enforcement

When a charge dispute is created via `charge_dispute.created`, the system retrieves the associated Stripe charge and expands its customer object to locate the corresponding workspace by `stripeId`. 

```typescript
export async function chargeDisputeCreated(
  event: Stripe.ChargeDisputeCreatedEvent,
) {
  const dispute = event.data.object;
  const chargeId =
    typeof dispute.charge === "string" ? dispute.charge : dispute.charge.id;

  const charge = await stripe.charges.retrieve(chargeId, {
    expand: ["customer"],
  });

  const { customer: customerObj, payment_method_details } = charge;
  const customer = customerObj as Stripe.Customer;

  const workspace = await prisma.project.findUnique({
    where: {
      stripeId: customer.id,
    },
  });

  // should never happen, but just in case
  if (!workspace) {
    return `Workspace with stripeId ${customer.id} not found.`;
  }
```
Sources: [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:9-32](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts#L9-L32)

Upon locating the workspace, the system executes a sequence of security and restriction measures:
1. Disables all workspace short links via `disableWorkspaceLinks`.
2. Demotes all workspace users to the `viewer` role, excluding the constant `LEGAL_USER_ID`.
3. Upserts or creates `LEGAL_USER_ID` as the workspace `owner`.
4. Timestamps the workspace `disabledAt` field.
5. Registers the customer ID, customer email, and payment method card fingerprint into Stripe fraud value lists via `addToStripeFraudValueLists`.
6. Cancels the active Stripe subscription with the reason `"Workspace banned due to charge dispute"`.

```typescript
await disableWorkspaceLinks(workspace.id);

// Update all users to viewer role except for LEGAL_USER_ID
const updatedUsers = await prisma.projectUsers.updateMany({
  where: {
    projectId: workspace.id,
    userId: {
      not: LEGAL_USER_ID,
    },
  },
  data: {
    role: "viewer",
  },
});

console.log(`Updated ${updatedUsers.count} users to viewer role`);

// Add LEGAL_USER_ID as owner
await prisma.projectUsers.upsert({
  where: {
    userId_projectId: {
      projectId: workspace.id,
      userId: LEGAL_USER_ID,
    },
  },
  update: {
    role: "owner",
  },
  create: {
    projectId: workspace.id,
    userId: LEGAL_USER_ID,
    role: "owner",
  },
});

console.log(
  `Added legal user ${LEGAL_USER_ID} as owner to workspace ${workspace.id}`,
);

await prisma.project.update({
  where: {
    id: workspace.id,
  },
  data: {
    disabledAt: new Date(),
  },
});

await addToStripeFraudValueLists({
  customerId: customer.id,
  customerEmail: customer.email,
  cardFingerprint: payment_method_details?.card?.fingerprint,
});

await cancelSubscription({
  customerId: customer.id,
  reason: "Workspace banned due to charge dispute",
});

```
Sources: [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:34-92](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts#L34-L92)

> [!CAUTION]
> A charge dispute event immediately revokes owner privileges from all standard workspace members by forcing their project roles to `viewer`, elevates `LEGAL_USER_ID` to sole owner, disables all links, and terminates the underlying subscription.

Sources: [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:34-92](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts#L34-L92)

## Billing Dashboard and Plan Management UI

### Client-Side Billing Views and Usage Metrics

The workspace billing interface renders detailed plan usage PlanUsage, consumption totals, and active subscription status WorkspaceBilling. The `PlanUsage` component retrieves workspace metadata from `useWorkspace()` to evaluate real-time usage metrics across tracked events, links created, and payouts sent. 

```tsx
export default function PlanUsage() {
  const {
    id: workspaceId,
    slug,
    plan,
    role,
    stripeId,
    usage,
    usageLimit,
    linksUsage,
    linksLimit,
    totalLinks,
    payoutsUsage,
    payoutsLimit,
    payoutFee,
    payoutFeeWaiverLimit,
    payoutFeeWaiverUsage,
    domains,
    domainsLimit,
    foldersUsage,
    foldersLimit,
    partnersUsage,
    partnersLimit,
    groupsLimit,
    tagsLimit,
    usersLimit,
    billingCycleStart,
    planPeriod,
    trialEndsAt,
    subscriptionCanceledAt,
    billingCycleEndsAt,
    defaultProgramId,
    loading,
    mutate,
  } = useWorkspace();
```
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx:56-90](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/plan-usage.tsx#L56-L90)

The interface dynamically builds resource utilization tabs using `useMemo`, conditionally prepending the links tab if total usage exceeds `MEGA_WORKSPACE_LINKS_LIMIT` or appending the payouts tab when a default program identifier is present.

| Resource Tab | Icon Component | Associated Limit Constant / Field |
| :--- | :--- | :--- |
| `events` | `CursorRays` | `usageLimit` |
| `links` | `Hyperlink` | `linksLimit` |
| `payouts` | `MoneyBills2` | `payoutsLimit` |

Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx:132-179](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/plan-usage.tsx#L132-L179)

> [!NOTE]
> The payouts resource tab is reserved during initial loading states to prevent layout shifts while asynchronous SWR hooks resolve workspace properties.

Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/plan-usage.tsx:157-167](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/plan-usage.tsx#L157-L167)

### Subscription Modals and Plan Management

The `SubscriptionMenu` component exposes administrative controls for enterprise and non-enterprise workspaces SubscriptionMenu. Non-enterprise workspaces render a popover menu containing actions to open the Stripe billing portal or cancel the active subscription.

```tsx
export default function SubscriptionMenu() {
  const {
    id: workspaceId,
    role,
    plan,
    defaultProgramId,
    billingCycleEndsAt,
    subscriptionCanceledAt,
    mutate,
  } = useWorkspace();
  const router = useRouter();

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

  const [isOpen, setIsOpen] = useState(false);
  const [clicked, setClicked] = useState(false);

  const openBillingPortal = async () => {
    setIsOpen(false);
    setClicked(true);
    return fetch(`/api/workspaces/${workspaceId}/billing/manage`, {
      method: "POST",
    })
      .then(async (res) => {
        if (res.ok) {
          const { url } = await res.json();
          router.push(url);
        } else {
          const { error } = await res.json();
          toast.error(error.message);
        }
      })
      .finally(() => {
        setClicked(false);
      });
  };
```
Sources: [apps/web/ui/workspaces/subscription-menu.tsx:24-62](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/workspaces/subscription-menu.tsx#L24-L62)

When canceling a subscription, the client verifies whether the workspace would lose partner access by evaluating `wouldLosePartnerAccess`. If partner access is affected, selecting cancellation triggers a plan change confirmation modal before opening the cancellation dialog.

```tsx
// Check if canceling would lose partner access
const losesPartnerAccess =
  plan &&
  defaultProgramId &&
  wouldLosePartnerAccess({ currentPlan: plan, newPlan: null });

const { setShowCancelSubscriptionModal, CancelSubscriptionModal } =
  useCancelSubscriptionModal({
    billingCycleEndsAt,
    onConfirm: handleCancellation,
  });

const { setShowPlanChangeConfirmationModal, PlanChangeConfirmationModal } =
  usePlanChangeConfirmationModal({
    newPlan: "free",
    newPeriod: "monthly",
    newTier: 1,
    onConfirm: async () => {
      setShowPlanChangeConfirmationModal(false);
      setShowCancelSubscriptionModal(true);
    },
  });

const handleCancelSubscription = () => {
  setIsOpen(false);
  if (losesPartnerAccess) {
    setShowPlanChangeConfirmationModal(true);
  } else {
    setShowCancelSubscriptionModal(true);
  }
};
```
Sources: [apps/web/ui/workspaces/subscription-menu.tsx:97-127](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/workspaces/subscription-menu.tsx#L97-L127)

### Plain CRM Plan Synchronization

Customer support workflows integrate with Plain CRM via webhook callbacks that authenticate requests using the `X-Plain-Webhook-Secret` header POST.

```typescript
export async function POST(req: NextRequest) {
  // authenticate webhook X-Plain-Webhook-Secret
  const token = req.headers.get("X-Plain-Webhook-Secret");
  if (token !== process.env.PLAIN_WEBHOOK_SECRET) {
    return new Response("Unauthorized", { status: 401 });
  }
```
Sources: [apps/web/app/api/callback/plain/workspace/route.ts:20-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/plain/workspace/route.ts#L20-L25)

Upon receiving a valid callback for a customer, the system resolves the associated user, verifies email blacklist status, upserts customer metadata, and queries the primary workspace ordered by usage limits POST.

```typescript
let { customer } = plainCallbackSchema.parse(await req.json());

const user = await prisma.user.findUnique({
  where: customer.externalId
    ? { id: customer.externalId }
    : { email: customer.email },
});

if (!user || !user.email) {
  const isBannedUser = await isBlacklistedEmail(customer.email);
  if (isBannedUser) {
    await plain.addCustomerToCustomerGroups({
      customerId: customer.id,
      customerGroupIdentifiers: [
        {
          customerGroupKey: "banned_users",
        },
      ],
    });
  }

  return NextResponse.json({
    cards: [
      {
        key: "workspace",
        components: [plainEmptyContainer("No user found.")],
      },
    ],
  });
}

if (!customer.externalId) {
  customer.externalId = user.id;
  await upsertPlainCustomer({
    id: user.id,
    name: user.name,
    email: user.email,
  });
}

waitUntil(syncUserPlanToPlain(user));

const topWorkspace = await prisma.project.findFirst({
  where: {
    users: {
      some: {
        userId: customer.externalId,
      },
    },
  },
  include: {
    _count: {
      select: {
        domains: true,
        users: true,
      },
    },
  },
  orderBy: {
    usageLimit: "desc",
  },
  take: 1,
});
```
Sources: [apps/web/app/api/callback/plain/workspace/route.ts:27-89](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/plain/workspace/route.ts#L27-L89)

The endpoint returns dynamic Plain CRM card components that format plan badges with specific colors corresponding to the workspace subscription tier.

| Plan Tier / Identifier | Plain Badge Color |
| :--- | :--- |
| `enterprise` | `RED` |
| `advanced` | `YELLOW` |
| `business*` (starts with) | `GREEN` |
| `pro` | `BLUE` |
| Other / Free | `GREY` |

Sources: [apps/web/app/api/callback/plain/workspace/route.ts:157-166](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/plain/workspace/route.ts#L157-L166)

## Related

- [Payout Settlement](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/billing-and-payouts/payout-settlement)


## Sitemap

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