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.
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:
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:
| Option | Type | Default | Description |
|---|---|---|---|
stripe_client | StripeClient | — | Required. Configured Stripe client. |
webhook_secret | str | — | Required. Stripe webhook signing secret. |
plans | Mapping[str, StripePlan] | {} | Named plans referenced by checkout. |
subscription_for | "user" | "organization" | "user" | Whether subscriptions attach to users or organizations. |
create_customer_on_sign_up | bool | True | Create a Stripe customer when a user signs up. |
require_email_verification | bool | False | Block checkout until the email is verified. |
authorize_reference | callable | None | Authorize a referenceId for organization subscriptions. |
on_event / on_subscription_* | callable | None | Lifecycle hooks for events and subscription transitions. |
StripePlan highlights:
| Field | Type | Description |
|---|---|---|
name | str | Plan key. |
price_id | str | Stripe price for the base subscription. |
annual_price_id | str | Price used when annual: true. |
seats | bool | Seat-based plan; quantity tracks membership in org mode. |
seat_price_id | str | Per-seat price. |
metered | bool | Bill by reported usage (omits quantity on the line item). |
proration_behavior | str | create_prorations / always_invoice / none on upgrade. |
Feature types
| Type | Model behavior |
|---|---|
| Boolean | Entitlement with unlimited=True or an included grant. |
| Metered consumable | billingUsageEvent rows increase billingEntitlement.used. |
| Quantity | included grants a non-consumable allowance. |
| Seat-style | StripePlan(seats=True) lets organization seat-sync update Stripe quantity. |
| Overage | overageAllowed=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 = 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.