Plugins

Stripe

Stripe checkout, subscriptions, catalog sync, entitlements, and usage tracking.

The Stripe plugin links Kernia users or organizations to Stripe customers and subscriptions. It exposes the Better Auth-compatible checkout, billing-portal, cancel, restore, and list surfaces, plus a Kernia billing layer for imported products, feature entitlements, usage events, check, and track.

Installation

Add the plugin to your server

Install the standalone package, then pass stripe() to your Kernia config with a StripeClient and your plans.

auth.py
from kernia import KerniaOptions
from kernia.auth import init
from kernia_stripe import StripeClient, StripeOptions, StripePlan, stripe

stripe_client = StripeClient(api_key=os.environ["STRIPE_SECRET_KEY"])

auth = init(KerniaOptions(
    database=adapter,
    secret=os.environ["KERNIA_SECRET"],
    base_url=os.environ["KERNIA_BASE_URL"],
    plugins=[
        stripe(StripeOptions(
            stripe_client=stripe_client,
            webhook_secret=os.environ["STRIPE_WEBHOOK_SECRET"],
            subscription_for="user",
            plans={
                "pro": StripePlan(
                    name="pro",
                    price_id="price_monthly_pro",
                    annual_price_id="price_annual_pro",
                ),
                "team": StripePlan(
                    name="team",
                    price_id="price_team_base",
                    seats=True,
                    seat_price_id="price_team_seat",
                ),
            },
        )),
    ],
))

Point your Stripe webhook at https://api.example.com/api/auth/stripe/webhook and enable at least: checkout.session.completed, customer.subscription.{created,updated,deleted}, invoice.paid, and invoice.payment_failed.

Add the client plugin

Add the stripe client plugin:

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { stripeClient } from "@better-auth/stripe/client";

export const authClient = createAuthClient({
  baseURL: "/api/auth",
  plugins: [stripeClient({ subscription: true })],
});

Usage

Start checkout / upgrade a plan

await authClient.subscription.upgrade({
  plan: "pro",
  successUrl: "/billing/success",
  cancelUrl: "/billing",
  annual: true,
  referenceId: "org_123",   // required when subscription_for="organization"
  seats: 5,                 // seat-based plans
});

Redirects to Stripe Checkout and persists the resulting subscription locally.

List subscriptions

const { data } = await authClient.subscription.list({
  query: { referenceId: "org_123" },
});

Cancel and restore

await authClient.subscription.cancel({ subscriptionId: "sub_123", returnUrl: "/billing" });
await authClient.subscription.restore({ subscriptionId: "sub_123" });

Open the billing portal

await authClient.subscription.billingPortal({ returnUrl: "/billing" });

Check and track entitlements

The Kernia billing layer adds metered/quota features on top of subscriptions, served at /api/auth/billing/*:

// Is the caller allowed one more "projects" unit?
await authClient.$fetch("/billing/check", {
  method: "POST",
  body: { feature: "projects", referenceId: "org_123", required: 1 },
});

// Record usage and decrement remaining balance.
await authClient.$fetch("/billing/track", {
  method: "POST",
  body: { feature: "projects", referenceId: "org_123", quantity: 1, properties: { project_id: "prj_123" } },
});

billing/check returns allowance, included quantity, used quantity, remaining balance, and overage state. billing/customer, billing/portal, and billing/usage round out the layer.

Import the Stripe catalog

await authClient.$fetch("/stripe/catalog/sync", { method: "POST" });

Imports Stripe products into billingProduct and prices into billingPrice, then writes a billingSyncState row. Read them back at /stripe/products and /stripe/prices.

Kernia imports Stripe products and prices into local billing tables first, so you can map imported prices to internal plans, features, and entitlements without treating Stripe as the only source of truth.

Options

StripeOptions:

OptionTypeDefaultDescription
stripe_clientStripeClientRequired. Configured Stripe client.
webhook_secretstrRequired. Stripe webhook signing secret.
plansMapping[str, StripePlan]{}Named plans referenced by checkout.
subscription_for"user" | "organization""user"Whether subscriptions attach to users or organizations.
create_customer_on_sign_upboolTrueCreate a Stripe customer when a user signs up.
require_email_verificationboolFalseBlock checkout until the email is verified.
authorize_referencecallableNoneAuthorize a referenceId for organization subscriptions.
on_event / on_subscription_*callableNoneLifecycle hooks for events and subscription transitions.

StripePlan highlights:

FieldTypeDescription
namestrPlan key.
price_idstrStripe price for the base subscription.
annual_price_idstrPrice used when annual: true.
seatsboolSeat-based plan; quantity tracks membership in org mode.
seat_price_idstrPer-seat price.
meteredboolBill by reported usage (omits quantity on the line item).
proration_behaviorstrcreate_prorations / always_invoice / none on upgrade.

Feature types

TypeModel behavior
BooleanEntitlement with unlimited=True or an included grant.
Metered consumablebillingUsageEvent rows increase billingEntitlement.used.
Quantityincluded grants a non-consumable allowance.
Seat-styleStripePlan(seats=True) lets organization seat-sync update Stripe quantity.
OverageoverageAllowed=True lets billing/check permit usage past the included balance.

Organization seat-sync

When the plugin runs with subscription_for="organization" and any plan declares seats=True, it subscribes to the in-process event bus on init. Every time the organization plugin emits organization.member.added or organization.member.removed, the Stripe subscription quantity is updated to match the new member count and the local subscription.seats field is rewritten — in the same request that mutated membership. No cron, no polling.

auth.py
auth = init(KerniaOptions(
    database=adapter,
    secret=os.environ["KERNIA_SECRET"],
    plugins=[
        organization(),
        stripe(StripeOptions(
            stripe_client=stripe_client,
            webhook_secret=os.environ["STRIPE_WEBHOOK_SECRET"],
            subscription_for="organization",
            plans={
                "team": StripePlan(name="team", price_id="price_team", seats=True),
            },
        )),
    ],
))

If no plan declares seats=True, or the plugin is in user-billing mode, the listener is not registered and there is zero per-request overhead.

Schema

The plugin contributes subscription, billingProduct, billingPrice, billingFeature, billingPlan, billingPlanFeature, billingEntitlement, billingUsageEvent, billingCustomer, and billingSyncState. It extends user (and organization in org mode) with stripeCustomerId.

PLAN_NOT_FOUND means the checkout plan is not in StripeOptions.plans; REFERENCE_REQUIRED means an organization subscription was requested without a referenceId; INVALID_SIGNATURE means the webhook secret does not match the Stripe endpoint.