Skip to content

Stripe Billing

Payment integration using Stripe with BetterAuth plugin.

Configuration

Environment Variables

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx

Stripe Client

lib/stripe.ts

import { loadStripe } from "@stripe/stripe-js";

export const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);

BetterAuth Stripe Plugin

In lib/auth-client.ts:

import { stripeClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  // ...
  plugins: [
    stripeClient({ subscription: true }),
    // ...
  ]
});

Payment Flow

1. Checkout Initiation

User navigates to /portal/[organizationSlug]/pay

// Create checkout session via BetterAuth
const { data: session } = await authClient.stripe.createCheckoutSession({
  organizationId,
  priceId: "price_xxx",
  successUrl: `${window.location.origin}/portal/${orgSlug}/pay/success`,
  cancelUrl: `${window.location.origin}/portal/${orgSlug}/pay`
});

// Redirect to Stripe Checkout
window.location.href = session.url;

2. Stripe Checkout

User completes payment on Stripe's hosted checkout page.

3. Success Redirect

After successful payment, user redirected to /portal/[slug]/pay/success

// pay/success/page.tsx
export default function PaymentSuccessPage() {
  const router = useRouter();
  const params = useParams();

  useEffect(() => {
    // Refresh organization data to get updated subscription
    queryClient.invalidateQueries(["organization"]);
  }, []);

  return (
    <div>
      <h1>Payment Successful!</h1>
      <p>Your subscription is now active.</p>
      <Button onClick={() => router.push(`/portal/${params.organizationSlug}`)}>
        Continue to Portal
      </Button>
    </div>
  );
}

Embedded Payment Form

For inline payments without redirect.

Stripe Elements Setup

import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe";

function PaymentPage() {
  const [clientSecret, setClientSecret] = useState("");

  useEffect(() => {
    // Create payment intent
    fetch("/api/create-payment-intent", {
      method: "POST",
      body: JSON.stringify({ amount: 1000 })
    })
      .then(res => res.json())
      .then(data => setClientSecret(data.clientSecret));
  }, []);

  const options = {
    clientSecret,
    appearance: {
      theme: "stripe" as const,
      variables: {
        colorPrimary: "#0570de"
      }
    }
  };

  return (
    <Elements stripe={stripePromise} options={options}>
      <StripePaymentForm />
    </Elements>
  );
}

Payment Form Component

components/forms/stripe-payment-form.tsx

import {
  PaymentElement,
  useStripe,
  useElements
} from "@stripe/react-stripe-js";

export function StripePaymentForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setIsLoading(true);
    setError(null);

    const { error: submitError } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/portal/${orgSlug}/pay/success`
      }
    });

    if (submitError) {
      setError(submitError.message || "Payment failed");
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      {error && <div className="text-red-500">{error}</div>}
      <Button type="submit" disabled={!stripe || isLoading}>
        {isLoading ? "Processing..." : "Pay Now"}
      </Button>
    </form>
  );
}

Billing Management

Billing Settings Page

/portal/[organizationSlug]/settings/billing

export default function BillingSettingsPage() {
  const { activeOrganization } = useAuth();

  const openBillingPortal = async () => {
    const { data } = await authClient.stripe.createBillingPortalSession({
      organizationId: activeOrganization.id
    });
    window.location.href = data.url;
  };

  return (
    <div>
      <h1>Billing Settings</h1>

      <Card>
        <CardHeader>
          <CardTitle>Current Plan</CardTitle>
        </CardHeader>
        <CardContent>
          <p>Plan: {activeOrganization.subscription?.plan || "Free"}</p>
          <p>Status: {activeOrganization.subscription?.status || "Inactive"}</p>
        </CardContent>
      </Card>

      <Button onClick={openBillingPortal}>
        Manage Subscription
      </Button>
    </div>
  );
}

Billing Portal

Stripe's customer portal handles: - Update payment method - View invoices - Cancel subscription - Change plan

Subscription Status

Access subscription data via organization:

const { activeOrganization } = useAuth();

// Subscription fields (via BetterAuth Stripe plugin)
activeOrganization?.subscription?.status    // active, canceled, past_due
activeOrganization?.subscription?.plan      // Plan name
activeOrganization?.subscription?.currentPeriodEnd  // Renewal date

Pricing Display

PricingCard Component

components/cards/pricing-card.tsx

interface PricingCardProps {
  name: string;
  price: number;
  features: string[];
  priceId: string;
  isPopular?: boolean;
}

export function PricingCard({ name, price, features, priceId, isPopular }) {
  const handleSelect = async () => {
    // Redirect to checkout
    const { data } = await authClient.stripe.createCheckoutSession({
      organizationId,
      priceId
    });
    window.location.href = data.url;
  };

  return (
    <Card className={isPopular ? "border-primary" : ""}>
      <CardHeader>
        <CardTitle>{name}</CardTitle>
        <div className="text-3xl font-bold">${price}/mo</div>
      </CardHeader>
      <CardContent>
        <ul>
          {features.map(f => <li key={f}> {f}</li>)}
        </ul>
        <Button onClick={handleSelect}>
          Get Started
        </Button>
      </CardContent>
    </Card>
  );
}

Error Handling

try {
  await stripe.confirmPayment({ ... });
} catch (error) {
  if (error.type === "card_error") {
    setError(error.message);
  } else if (error.type === "validation_error") {
    setError("Please check your card details");
  } else {
    setError("An unexpected error occurred");
    logger.error("Payment error", { error });
  }
}

Webhooks

Stripe webhooks are handled by the BetterAuth backend: - customer.subscription.created - customer.subscription.updated - customer.subscription.deleted - invoice.paid - invoice.payment_failed

These automatically update organization subscription status.