Subscriptions

Redirect to Stripe Checkout, handle subscription lifecycle states, and manage trial periods.

Checkout flow

Call createCheckoutSession from @nebutra/billing to generate a Stripe-hosted checkout URL, then redirect the user to it.

import { createCheckoutSession } from "@nebutra/billing";
import { redirect } from "next/navigation";
import { getCurrentTenant } from "@nebutra/tenant";

export async function GET() {
  const tenant = getCurrentTenant();

  const session = await createCheckoutSession({
    orgId: tenant.tenantId,
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing/success`,
    cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
    trialDays: 14,
  });

  redirect(session.url);
}

Stripe handles the payment form, SCA/3DS, and error states. You do not need to build a payment form.

On a successful payment, Stripe redirects the user to your successUrl. Show a confirmation UI — but do not update the plan here. Plan activation is confirmed by the webhook.

export default function BillingSuccessPage() {
  return (
    <div>
      <h1>You're on PRO!</h1>
      <p>
        Your plan is being activated. This usually takes a few seconds.
        Refresh the page if your new limits aren't reflected immediately.
      </p>
    </div>
  );
}

Stripe fires checkout.session.completed. The webhook handler in @nebutra/billing updates the tenant plan in the database. Quota limits update automatically.

Do not update the plan based on a successful redirect URL alone. A redirect can be faked. Always wait for the checkout.session.completed webhook to confirm the payment.

Checking subscription status

Use getSubscription to fetch the current subscription state for an organization:

import { getSubscription } from "@nebutra/billing";

const subscription = await getSubscription("org_123");
// → {
//     orgId: "org_123",
//     plan: "pro",
//     status: "active",
//     currentPeriodEnd: "2026-04-30T00:00:00.000Z",
//     cancelAtPeriodEnd: false,
//     trialEnd: null,
//   }

Subscription lifecycle states

StateMeaningUser impact
trialingWithin the 14-day trial periodFull PRO access, no charge yet
activeSubscription is current and paidFull plan access
past_dueLatest invoice payment failedPlan remains active; user is prompted to update payment
canceledSubscription was canceledDowngraded to FREE at period end
unpaidMultiple failed attempts; Stripe paused subscriptionAccess revoked; user must update payment method

Displaying status in the UI

import { getSubscription } from "@nebutra/billing";
import { getCurrentTenant } from "@nebutra/tenant";

export default async function BillingSettingsPage() {
  const tenant = getCurrentTenant();
  const subscription = await getSubscription(tenant.tenantId);

  return (
    <div>
      <p>Current plan: <strong>{subscription.plan.toUpperCase()}</strong></p>
      <p>Status: {subscription.status}</p>
      {subscription.status === "past_due" && (
        <a href="/billing/portal">Update payment method</a>
      )}
    </div>
  );
}

Trial periods

PRO subscriptions include a 14-day free trial. No credit card is charged until the trial ends.

  • Trial state is reflected as status: "trialing" in getSubscription.
  • When the trial expires, Stripe charges the card on file and moves the status to active.
  • If no payment method is provided before trial end, the subscription moves to canceled and the tenant is downgraded to FREE.
// Pass trialDays when creating the checkout session to activate a trial
const session = await createCheckoutSession({
  orgId: "org_123",
  priceId: process.env.STRIPE_PRO_PRICE_ID!,
  successUrl: "https://app.nebutra.com/billing/success",
  cancelUrl: "https://app.nebutra.com/billing",
  trialDays: 14,
});

You can set trialDays: 0 to skip the trial for specific users (e.g., users migrating from a legacy plan or those using promo codes).

Cancellation and self-service

Customers can cancel, upgrade, or update their payment method via the Stripe Customer Portal — no custom UI required:

import { createPortalSession } from "@nebutra/billing";
import { redirect } from "next/navigation";
import { getCurrentTenant } from "@nebutra/tenant";

export async function GET() {
  const tenant = getCurrentTenant();

  const session = await createPortalSession({
    orgId: tenant.tenantId,
    returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
  });

  redirect(session.url);
}

This route is already wired to the Settings → Billing → Manage Subscription button in the dashboard.

How is this guide?

Edit on GitHub

Last updated on

On this page