DEV Community

Cover image for How to Build Self-Service Billing: Let Users Upgrade Their Own Plans
Shola Jegede
Shola Jegede Subscriber

Posted on • Edited on

How to Build Self-Service Billing: Let Users Upgrade Their Own Plans

Every time a user has to email you to upgrade their plan, you lost revenue. Here is how to fix it — completely.

In this article, you will learn:

  • Why self-service billing is not optional for modern SaaS
  • How Kinde's billing model works and what it connects to
  • How to plan your pricing structure before touching a single setting
  • How to connect Stripe and configure billing policies
  • How to build and publish pricing plans
  • How to create a pricing table users can actually see
  • How to wire billing into your registration and sign-in flows using the SDK
  • How to enable the self-serve portal so users can upgrade, downgrade, and manage their own billing
  • How to react to billing events using webhooks
  • How to gate features in your app based on a user's active plan

Let's dive in!

The Hidden Cost of Manual Billing

Every SaaS product eventually builds the same broken workflow. A user decides they want more features. They click "Upgrade." They land on a pricing page with no checkout. There is a "Contact us" button or a Calendly link. They send an email. Someone from the team picks it up 6 hours later. A payment link is generated manually. The user pays. Someone remembers to update their plan in the database. Maybe.

This is not a fictional scenario. Thousands of early-stage SaaS products run on exactly this workflow, and it costs them real revenue every week. The user who wanted to upgrade at 11pm on a Friday does not come back on Monday.

Self-service billing solves this completely. The user sees the plans, picks one, pays through Kinde's hosted flow, and their access updates immediately. No human involved. No delay. No lost upgrades.

Kinde ships with a complete billing infrastructure built alongside its auth and access management. Plans, pricing tables, the Stripe connection, and the self-serve portal all live in the same platform you already use for authentication. No separate billing service to integrate. No separate SDK to maintain. One integration, one dashboard, everything in sync.

Here is how to build it end to end.

Two flows side by side —

Before You Start: Plan Your Pricing Structure

Kinde's billing setup has one important constraint: once you publish your first plan, you cannot change the billing currency. Start in a non-production Kinde environment, work through the setup, and only move to production when your plans are finalized.

Before opening the Kinde dashboard, map out your plans in a spreadsheet. This is genuinely worth doing. Kinde's plan builder is fast once you know what you are building — but making decisions mid-setup slows you down.

Your spreadsheet should capture for each plan:

  • Plan name (as it will appear to users)
  • Monthly price
  • Which features are included
  • Any usage limits (seats, API calls, projects)
  • The CTA button label for the pricing table ("Get started", "Start for free")

Example spreadsheet showing columns: Plan Name, Monthly Price, Features, Usage Limits, CTA Label. Sample rows: Free / Starter / Pro

Note: a pricing table in Kinde can display a maximum of four plans. If you have more than four, you will need multiple pricing tables or you will need to decide which four to surface.

The Kinde Billing Model: What You Need to Know

Kinde billing is built around a few key concepts worth understanding before you start.

Plans are the products you sell. Each plan has a price, a billing interval, and optional usage metering. Plans belong to a plan group — a logical container that groups related plans together. Your pricing table shows plans from one plan group.

Pricing tables are the visual representation of your plans — the card layout users see when choosing what to sign up for. You build them in Kinde using your published plans, add feature lists, highlight labels, and CTA button text. The pricing table is shown inside Kinde's hosted flow — either during registration or inside the self-serve portal when users upgrade or downgrade.

The self-serve portal is where existing subscribers manage their subscription. Kinde generates a one-time secure link for each user that opens the portal. Inside it, users can update payment details, view billing history, and change their plan. You add a "Manage billing" button anywhere in your app that generates and opens this portal. Users never interact with Stripe directly — Kinde handles everything.

Stripe powers the payment processing entirely behind the scenes. You connect Stripe to Kinde once from the dashboard — Kinde handles subscription creation, renewal billing, proration on plan changes, invoice generation, and dunning automatically. Your users never see Stripe. Your code never calls Stripe directly.

Webhooks let you react to billing events in your own application — plan changes, new subscriptions, cancellations, and payment failures.

Kinde billing architecture — Kinde dashboard (Plans, Pricing tables, Self-serve portal with one-time secure links, Feature flags attached to plans) auto-connected to Stripe behind the scenes (Payment processing, Subscription management, Invoice generation) — developer sets up once, users never see Stripe. Kinde also connects to Your App via PortalLink component, getBooleanFlag for feature gating, and Webhooks for reacting to plan changes

Step #1: Connect Stripe

In your Kinde dashboard, navigate to BillingPayment processor.

Kinde Billing > Payment processor page showing the Stripe connection interface

Kinde Billing > Payment processor page showing the Kinde and Stripe integration

Select Connect Stripe. Kinde creates and connects a Stripe account for you automatically. If you already have an existing Stripe account you want to use, you can connect that instead. Either way, you do not need to configure anything inside Stripe directly — Kinde handles the full sync.

Once connected, configure your billing policies:

  • Upgrade behavior: whether upgrades take effect immediately or at end of billing period
  • Downgrade behavior: whether downgrades take effect immediately or at end of billing period
  • Cancellation behavior: whether cancellations are immediate or at period end

Note: set your billing currency now. Navigate to SettingsBillingCurrency and select the currency your plans will be priced in. You cannot change this once your first plan is published.

When you use a Kinde non-production environment, Stripe automatically creates a corresponding test environment. Test the full flow using Stripe's test card numbers without charging real money before going live.

Step #2: Build Your Plans

Navigate to BillingPlans and select Add plan.

Kinde Billing > Plans page showing the plan list and the

For each plan, configure:

Basic details:

  • Plan name ("Starter", "Pro", "Scale")
  • Plan description
  • Whether the plan is for Organizations or Users — this cannot be changed after saving
  • The plan group it belongs to (create one if this is your first plan)
  • A plan key for referencing in your code — cannot be changed after publishing

Adding a charge: Every plan — including free plans — needs a charge. For a free plan, create a $0.00 fixed charge. This ensures the plan syncs to Stripe and appears on your pricing table. For paid plans, add the recurring monthly charge here.

Feature flags (optional but powerful): Attach Kinde feature flags to plans. When a user subscribes to a plan, the feature flags associated with it automatically become active for them. This is the cleanest way to gate features by plan without custom permission logic in your code.

After configuring all your plans, publish them. Navigate to BillingPlans, select a plan, and select Publish. Published plans sync to Stripe automatically. A plan must be published before it can appear on a pricing table. Published plans cannot be edited — plan versioning is coming but not yet available.

Step #3: Build Your Pricing Table

Navigate to BillingPricing tables and select Add pricing table.

Kinde Billing > Pricing tables page showing the

Generate the pricing table from a plan group (Kinde pre-populates the basics from your plans) or start from a blank slate. Generating is faster for most cases.

Once the pricing table is created, customize each plan card:

Add a feature list. This is a marketing-oriented bullet list of what the plan includes. Open a plan card's settings, add a Features list heading ("Everything in Starter, plus:"), and enter the feature list line by line.

Highlight a plan. Add a Highlight label to call out your recommended plan — this shows as a badge on the card ("Most popular", "Best value").

Add a CTA button. Each plan card needs a button label. Common options: "Get started", "Start free trial", "Upgrade".

Multilingual support. Add translated content for each plan card if your product serves multiple languages.

Pricing table builder showing three plan cards (Starter, Pro, Enterprise) with the Most popular badge on Pro

Once your pricing table looks right, select Make live. You can set it as the default pricing table — shown automatically during registration. You can also configure Kinde to hide it from the sign-up flow and only show it inside the self-serve portal if you have designed your own pricing page.

Step #4: Wire Billing Into Your Registration Flow

With plans published and a pricing table live, connect them to your registration flow using the Kinde React SDK.

import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components";

export default function PricingPage() {
  return (
    <div className="pricing-page">
      <h1>Choose your plan</h1>

      <div className="plan-cards">
        {/* Pre-select the Starter plan — user goes through Kinde-hosted payment */}
        <div className="plan-card">
          <h2>Starter</h2>
          <p>$9/month</p>
          <RegisterLink planInterest="starter_monthly">
            Get started
          </RegisterLink>
        </div>

        {/* Pre-select the Pro plan */}
        <div className="plan-card">
          <h2>Pro</h2>
          <p>$29/month</p>
          <RegisterLink planInterest="pro_monthly">
            Get started
          </RegisterLink>
        </div>

        {/* Show the full pricing table for users who want to compare */}
        <div className="compare-link">
          <RegisterLink pricingTableKey="main_pricing">
            Compare all plans
          </RegisterLink>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

For B2B products where the subscription is attached to an organization:

import { RegisterLink } from "@kinde-oss/kinde-auth-nextjs/components";

export default function SignUpPage() {
  return (
    <RegisterLink
      isCreateOrg
      pricingTableKey="b2b_pricing"
    >
      Sign up your company
    </RegisterLink>
  );
}
Enter fullscreen mode Exit fullscreen mode

The planInterest prop pre-selects a specific plan. The pricingTableKey prop shows a pricing table where the user can compare and choose. In both cases, the entire payment experience is Kinde-hosted — the user never leaves your product flow to an external page.

Step #5: Enable the Self-Serve Portal

The self-serve portal is where existing subscribers manage their subscription. Kinde generates a one-time secure link for each user that opens the portal. You generate this link from the SDK or Management API and attach it to a "Manage billing" button anywhere in your app.

Navigate to SettingsSelf-serve portal and enable the portal for organizations (B2B) or users (B2C). Configure which functions you want users to self-manage.

Kinde Settings > Self-serve portal page showing the toggle settings for organization self-management — update business details, update payment details, manage members

Each function in the portal is governed by a system permission. The org:write:billing permission allows users to update billing details. Assign these permissions to roles and those roles to org members. Not every member can manage billing by default — you control who can do what.

To add a "Manage billing" button in your app using the React SDK:

// components/billing-button.tsx
import { PortalLink } from "@kinde-oss/kinde-auth-nextjs/components";

export function BillingButton() {
  return (
    <PortalLink>
      Manage billing
    </PortalLink>
  );
}
Enter fullscreen mode Exit fullscreen mode

PortalLink generates a one-time secure link automatically and opens the Kinde-hosted portal. The portal shows the user their current plan, billing history, and payment details. When they select to change their plan, the pricing table appears so they can pick a new one.

Note: cancellation is not currently available through the self-serve portal. To implement cancellation in your app, see the Kinde cancel plans docs.

Wonderful! Your users can now manage their own subscriptions entirely without contacting you.

Step #6: React to Billing Events with Webhooks

When a user's plan changes, you need to know about it in your application. Webhooks let you react to these events in real time.

In your Kinde dashboard, navigate to SettingsWebhooks and select Add webhook.

Kinde Settings > Webhooks page showing the

Kinde Settings > Webhooks page showing the list of available billing events

The key billing events are:

  • customer.plan_assigned — a plan has been assigned to a customer for the first time
  • customer.plan_changed — a customer has upgraded or downgraded their plan
  • customer.agreement_cancelled — a customer's subscription has been cancelled
  • customer.payment_failed — a payment attempt has failed
  • customer.payment_succeeded — a payment has succeeded
  • subscriber.created — a new subscriber record has been created

Install the Kinde webhook decoder package:

npm install @kinde/webhooks
Enter fullscreen mode Exit fullscreen mode

Here is a Next.js webhook handler:

// app/api/webhooks/kinde/route.ts
import { NextRequest, NextResponse } from "next/server";
import { decodeWebhook } from "@kinde/webhooks";

export async function POST(request: NextRequest) {
  let decodedToken;

  try {
    const token = await request.text();
    decodedToken = await decodeWebhook(token, process.env.KINDE_ISSUER_URL!);
  } catch {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (!decodedToken) {
    return NextResponse.json({ error: "Invalid token" }, { status: 400 });
  }

  const { type, data } = decodedToken;

  switch (type) {
    case "customer.plan_assigned": {
      await provisionSubscription(data);
      console.log("Plan assigned:", data);
      break;
    }

    case "customer.plan_changed": {
      await updateUserPlan(data);
      console.log("Plan changed:", data);
      break;
    }

    case "customer.agreement_cancelled": {
      await revokeSubscription(data);
      console.log("Subscription cancelled:", data);
      break;
    }

    case "customer.payment_failed": {
      await handlePaymentFailure(data);
      console.log("Payment failed:", data);
      break;
    }

    case "customer.payment_succeeded": {
      await handlePaymentSuccess(data);
      console.log("Payment succeeded:", data);
      break;
    }

    default:
      console.log(`Unhandled billing event: ${type}`);
  }

  return NextResponse.json({ received: true });
}

async function provisionSubscription(data: unknown) {
  // Provision access, send welcome email
}

async function updateUserPlan(data: unknown) {
  // Update your database with the new plan
}

async function revokeSubscription(data: unknown) {
  // Revoke premium access
}

async function handlePaymentFailure(data: unknown) {
  // Send dunning email, flag account
}

async function handlePaymentSuccess(data: unknown) {
  // Extend subscription, reset failure counters
}
Enter fullscreen mode Exit fullscreen mode

Always verify the webhook signature using decodeWebhook from the @kinde/webhooks package before processing any event. If it throws, the token is invalid or did not originate from Kinde.

Step #7: Gate Features by Plan

The whole point of billing is to unlock different levels of access based on what the user is paying for.

Using Feature Flags Attached to Plans

Attach feature flags to your plans in Kinde. When a user is on a specific plan, the flags associated with that plan are automatically active for them. In your code, you check the flag — not the plan name.

Create a feature flag in Kinde: navigate to Feature flagsAdd flag. Create flags like advanced_analytics, api_access, priority_support.

Then in each plan's settings, attach the relevant flags. In your Next.js app:

// app/analytics/page.tsx
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";
import { redirect } from "next/navigation";

export default async function AnalyticsPage() {
  const { isAuthenticated, getBooleanFlag } = getKindeServerSession();

  if (!(await isAuthenticated())) {
    redirect("/api/auth/login");
  }

  const hasAdvancedAnalytics = await getBooleanFlag("advanced_analytics", false);

  if (!hasAdvancedAnalytics) {
    return <UpgradePrompt feature="Advanced Analytics" />;
  }

  return <AdvancedAnalyticsDashboard />;
}

function UpgradePrompt({ feature }: { feature: string }) {
  return (
    <div className="upgrade-prompt">
      <h2>{feature} is available on the Pro plan</h2>
      <p>Upgrade to unlock this and other premium features.</p>
      <a href="/account/billing" className="btn btn-primary">
        Upgrade plan
      </a>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When you want to move a feature from Pro to Starter, update the plan configuration in Kinde — no code changes needed.

Checking Plan Directly from the Token

If you prefer to check the plan directly, Kinde includes plan information in the user's access token:

import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";

export default async function Dashboard() {
  const { getClaim } = getKindeServerSession();

  const planKey = await getClaim("plan_key", "access_token");

  const isPro = planKey?.value === "pro_monthly";
  const isEnterprise = planKey?.value?.startsWith("enterprise");

  return (
    <div>
      {isPro && <ProFeatures />}
      {isEnterprise && <EnterpriseFeatures />}
      <BaseFeatures />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The feature flag approach is generally cleaner. If you ever rename a plan or restructure pricing, you do not need to update gating logic throughout your codebase.

The Complete Setup Checklist

In Kinde:

  • Currency set and confirmed — cannot be changed after first plan is published
  • Stripe connected
  • All plans created, charges added, and published
  • Pricing table built, live, and set as default
  • Self-serve portal enabled with correct permissions assigned to roles
  • Webhook endpoint configured for billing events
  • Feature flags created and attached to the appropriate plans

In your application:

  • RegisterLink components use planInterest or pricingTableKey props
  • Billing page uses PortalLink component or generates a portal URL
  • Webhook handler deployed and processes subscription events
  • Feature gating uses getBooleanFlag checks
  • Cancellation flow implemented separately
  • All flows tested using Stripe test card numbers in a non-production environment

Conclusion

In this article, you built a complete self-service billing system: plans, pricing tables, Stripe connected and managed entirely by Kinde, a self-serve portal accessed via one-time secure links, webhook-powered reactions to subscription events, and feature gating tied to plan flags.

Users can now sign up, choose a plan, pay through Kinde's hosted flow, upgrade when they are ready, and manage their own billing — without your team getting involved. Every manual touchpoint in a billing flow is friction you are forcing on users who have already decided to give you money. Remove the friction.

Kinde billing sits in the same platform as your authentication and access management. One integration, one dashboard, and plan data available directly in the user's token the moment they log in.

Create a free Kinde account today and ship billing in an afternoon.

Top comments (0)