Stripe
The Stripe plugin integrates Stripe payments and subscriptions with Better Auth Ruby. Since billing and authentication are often tied together, the plugin handles Stripe customer creation, subscription checkout, subscription state synchronization, webhook verification, billing portal sessions, and organization seat billing.
Ruby uses snake_case option and API method names. HTTP request bodies may keep upstream camelCase keys, and the plugin normalizes them internally.
Features
- Create Stripe Customers automatically when users sign up
- Manage subscription plans and prices
- Process subscription lifecycle events from Stripe webhooks
- Verify Stripe webhook signatures
- Expose active subscription data to your application
- Support trial periods, plan upgrades, scheduled plan changes, and restores
- Prevent trial abuse by allowing one trial per reference ID across all plans
- Associate subscriptions with users or organizations through reference IDs
- Support team subscriptions with Stripe seat items
Installation
Install The Gem
Add the Stripe plugin package to your application:
gem "better_auth-stripe"Then install dependencies:
bundle installThe package depends on the official stripe gem.
Configure Environment Variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Add The Plugin
require "better_auth"
require "better_auth/stripe"
auth = BetterAuth.auth(
secret: ENV.fetch("BETTER_AUTH_SECRET"),
base_url: ENV.fetch("BETTER_AUTH_URL", "http://localhost:3000"),
plugins: [
BetterAuth::Plugins.stripe(
stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
create_customer_on_sign_up: true
)
]
)You can pass stripe_client: instead of stripe_api_key: when you need a custom Stripe client, Stripe Connect behavior, or a test double.
Enable Subscriptions
Subscription endpoints are enabled only when subscription.enabled is true and plans are configured.
BetterAuth::Plugins.stripe(
stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
subscription: {
enabled: true,
plans: [
{
name: "basic",
price_id: "price_basic_monthly",
annual_discount_price_id: "price_basic_yearly",
limits: { projects: 5, storage: 10 }
},
{
name: "pro",
price_id: "price_pro_monthly",
annual_discount_price_id: "price_pro_yearly",
limits: { projects: 20, storage: 50 },
free_trial: { days: 14 }
}
]
}
)Migrate The Database
The plugin adds fields to user, and when subscriptions are enabled it adds a subscription table. Rails apps should regenerate and run Better Auth migrations after enabling the plugin. Rack, Sinatra, Hanami, and custom adapter apps should apply the schema fields listed in the Schema section.
Set Up Stripe Webhooks
Create a webhook endpoint in the Stripe Dashboard that points to your auth base path:
https://your-domain.com/api/auth/stripe/webhook/api/auth is the default Better Auth mount path.
Select at least these events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted
Save the webhook signing secret as STRIPE_WEBHOOK_SECRET.
Usage
Client Calls
The Ruby port does not provide a separate browser client plugin. From a frontend, call the HTTP endpoints below with the user's session cookies. From Ruby, you can call the generated API methods directly:
checkout = auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "pro",
successUrl: "https://app.example.com/dashboard",
cancelUrl: "https://app.example.com/pricing"
}
)
redirect_to checkout.fetch(:url)Customer Management
You can use the plugin only for Stripe customer management by omitting subscription.enabled. When create_customer_on_sign_up is true, Better Auth creates or reuses a Stripe Customer on signup and stores the customer ID on the user.
BetterAuth::Plugins.stripe(
stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
create_customer_on_sign_up: true,
get_customer_create_params: ->(user, _ctx) {
{
phone: user["phone"],
metadata: {
referralSource: user.dig("metadata", "referralSource")
}
}
},
on_customer_create: ->(payload, _ctx) {
stripe_customer = payload.fetch(:stripeCustomer)
user = payload.fetch(:user)
Rails.logger.info("Created Stripe customer #{stripe_customer.fetch('id')} for #{user.fetch('id')}")
}
)The plugin protects internal metadata keys like userId, organizationId, customerType, subscriptionId, and referenceId so request metadata cannot overwrite them.
Subscription Management
Defining Plans
Plans can be static or returned dynamically from a callable.
subscription: {
enabled: true,
plans: [
{
name: "basic",
price_id: "price_basic_monthly",
annual_discount_price_id: "price_basic_yearly",
limits: { projects: 5, storage: 10 }
},
{
name: "pro",
lookup_key: "pro_monthly",
annual_discount_lookup_key: "pro_yearly",
limits: { projects: 20, storage: 50 },
free_trial: { days: 14 }
}
]
}subscription: {
enabled: true,
plans: -> {
Plan.active.map do |plan|
{
name: plan.slug,
price_id: plan.stripe_monthly_price_id,
annual_discount_price_id: plan.stripe_yearly_price_id,
limits: plan.limits
}
end
}
}See Plan Configuration for all supported plan keys.
Creating A Subscription
Create a subscription with auth.api.upgrade_subscription or POST /subscription/upgrade.
checkout = auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "pro",
annual: true,
successUrl: "/dashboard",
cancelUrl: "/pricing",
metadata: { source: "pricing-page" },
seats: 1,
locale: "en"
}
)
checkout.fetch(:url)Request body:
| Option | Type | Description |
|---|---|---|
plan | String | Required. Name of the plan to subscribe to. |
annual | Boolean | Use the annual discount price when configured. |
referenceId / reference_id | String | Subscription reference ID. Defaults to the user ID, or to active organization ID for organization billing. |
subscriptionId / subscription_id | String | Stripe subscription ID to upgrade. Required when changing an existing active subscription. |
metadata | Hash | Extra metadata to pass to Stripe. Internal keys are protected. |
customerType / customer_type | "user" or "organization" | Billing entity type. Defaults to "user". |
seats | Integer | Seat quantity for user or manual seat billing. Organization plans with seat_price_id use member count instead. |
locale | String | Stripe Checkout locale. |
successUrl / success_url | String | Required. URL to return to after successful checkout. |
cancelUrl / cancel_url | String | Required. URL to return to after canceled checkout. |
returnUrl / return_url | String | Return URL for Billing Portal flows and scheduled changes. |
disableRedirect / disable_redirect | Boolean | Adds a redirect flag to the response for client behavior. |
scheduleAtPeriodEnd / schedule_at_period_end | Boolean | Schedule the plan change at the end of the current billing period. |
successUrl, cancelUrl, returnUrl, and callback URLs must be relative paths or absolute URLs whose origin is configured in trusted_origins.
The plugin supports one active or trialing subscription per reference ID at a time. If the reference already has an active subscription, pass subscriptionId when changing plans so the existing Stripe subscription is updated instead of creating a separate checkout.
The successUrl is wrapped through /subscription/success so Better Auth can reconcile checkout completion and webhook timing before redirecting the user to your URL.
Switching Plans
Use the same upgrade endpoint and include the existing Stripe subscription ID.
auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "pro",
subscriptionId: "sub_123",
successUrl: "/dashboard",
cancelUrl: "/pricing",
returnUrl: "/billing"
}
)Depending on the plan shape, the plugin either updates subscription items directly or opens a Stripe Billing Portal update confirmation flow.
Scheduling Plan Changes At Period End
Set scheduleAtPeriodEnd to defer an active subscription change until the current period ends.
auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "pro",
subscriptionId: "sub_123",
successUrl: "/dashboard",
cancelUrl: "/pricing",
returnUrl: "/billing",
scheduleAtPeriodEnd: true
}
)When this is enabled:
- Stripe Subscription Schedules are used to create the future phase.
- The current plan stays active until the period end.
- Better Auth stores
stripeScheduleIdon the subscription. - The response URL is the
returnUrl; no Stripe Checkout or Billing Portal redirect is required. - A later
customer.subscription.updatedwebhook updates the subscription after Stripe applies the change. - Calling upgrade again before the schedule runs releases the previous Better Auth schedule first.
Listing Active Subscriptions
subscriptions = auth.api.list_active_subscriptions(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
query: {
referenceId: "org_123",
customerType: "organization"
}
)
active_subscription = subscriptions.find do |subscription|
["active", "trialing"].include?(subscription.fetch("status"))
end
project_limit = active_subscription.dig("limits", :projects)For explicit custom reference IDs, provide subscription.authorize_reference so the plugin can verify the current session is allowed to list or manage that reference.
subscription: {
enabled: true,
plans: [...],
authorize_reference: ->(data, _ctx) {
user = data.fetch(:user)
reference_id = data.fetch(:reference_id)
action = data.fetch(:action)
return true if reference_id == user.fetch("id")
membership = OrganizationMember.find_by(
user_id: user.fetch("id"),
organization_id: reference_id
)
["owner", "admin"].include?(membership&.role)
}
}Canceling A Subscription
portal = auth.api.cancel_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
referenceId: "org_123",
customerType: "organization",
subscriptionId: "sub_123",
returnUrl: "/account"
}
)
portal.fetch(:url)This creates a Stripe Billing Portal session for subscription cancellation. Better Auth uses /subscription/cancel/callback after the portal flow to refresh pending cancellation state when needed.
Stripe cancellation fields tracked by the plugin:
| Field | Description |
|---|---|
cancelAtPeriodEnd | Whether the subscription will cancel at the end of the current period. |
cancelAt | Scheduled cancellation time when Stripe provides one. |
canceledAt | Time when cancellation was requested. |
endedAt | Time when the subscription actually ended. |
status | Changes to canceled after the subscription has ended or Stripe sends a delete event. |
Restoring A Subscription
auth.api.restore_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
referenceId: "org_123",
customerType: "organization",
subscriptionId: "sub_123"
}
)Restore handles two cases:
- Pending cancellation: clears the Stripe cancellation flag and local cancellation fields.
- Pending scheduled plan change: releases the Stripe subscription schedule and clears
stripeScheduleId.
It cannot restore subscriptions that have already ended.
Creating Billing Portal Sessions
portal = auth.api.create_billing_portal(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
referenceId: "org_123",
customerType: "organization",
returnUrl: "/settings/billing",
locale: "en"
}
)
portal.fetch(:url)Use the Billing Portal for payment method changes, invoice history, and subscription management.
Reference System
By default, user subscriptions use the user ID as referenceId. To associate billing with another entity, pass referenceId and authorize it with authorize_reference.
auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "pro",
referenceId: "workspace_123",
successUrl: "/dashboard",
cancelUrl: "/pricing"
}
)Team Subscriptions With Seats
For manual seat billing, pass seats during upgrade:
auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "team",
referenceId: "org_123",
seats: 10,
successUrl: "/org/billing/success",
cancelUrl: "/org/billing"
}
)For organization seat billing, set seat_price_id on the plan. The plugin sends the base plan item with quantity 1 and a separate seat item whose quantity is the current organization member count. Member add, remove, and invitation acceptance hooks sync the seat item quantity.
plugins: [
BetterAuth::Plugins.organization,
BetterAuth::Plugins.stripe(
stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
organization: { enabled: true },
subscription: {
enabled: true,
plans: [
{
name: "team",
price_id: "price_team_base",
seat_price_id: "price_team_seat",
line_items: [{ price: "price_metered_events" }],
proration_behavior: "create_prorations"
}
],
authorize_reference: ->(_data, _ctx) { true }
}
)
]Webhook Handling
The plugin automatically handles these Stripe events:
checkout.session.completed: Updates a subscription after Checkout succeeds.customer.subscription.created: Creates a local subscription for Stripe-side subscriptions.customer.subscription.updated: Updates local subscription state, plan, seats, cancellation fields, trial fields, and limits.customer.subscription.deleted: Marks a subscription canceled and clears pending schedules.
You can also handle any Stripe event with on_event.
The configured Stripe client must expose webhooks.construct_event_async or
webhooks.construct_event; Ruby rejects webhook requests instead of processing
unverified payloads when no verifier is available.
BetterAuth::Plugins.stripe(
stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
on_event: ->(event) {
case event[:type] || event["type"]
when "invoice.paid"
BillingEvents.invoice_paid(event)
when "payment_intent.succeeded"
BillingEvents.payment_succeeded(event)
end
}
)Subscription Lifecycle Hooks
subscription: {
enabled: true,
plans: [...],
on_subscription_complete: ->(data, _ctx) {
subscription = data.fetch(:subscription)
plan = data.fetch(:plan)
SubscriptionMailer.welcome(subscription.fetch("referenceId"), plan.fetch(:name)).deliver_later
},
on_subscription_created: ->(data) {
subscription = data.fetch(:subscription)
AuditLog.record("stripe.subscription.created", subscription.fetch("id"))
},
on_subscription_update: ->(data) {
subscription = data.fetch(:subscription)
AuditLog.record("stripe.subscription.updated", subscription.fetch("id"))
},
on_subscription_cancel: ->(data) {
subscription = data.fetch(:subscription)
cancellation_details = data[:cancellationDetails]
CancellationMailer.notice(subscription.fetch("referenceId"), cancellation_details).deliver_later
},
on_subscription_deleted: ->(data) {
subscription = data.fetch(:subscription)
AuditLog.record("stripe.subscription.deleted", subscription.fetch("id"))
}
}Trial Periods
Configure trials per plan:
{
name: "pro",
price_id: "price_pro_monthly",
free_trial: {
days: 14,
on_trial_start: ->(subscription) {
TrialMailer.started(subscription.fetch("referenceId")).deliver_later
},
on_trial_end: ->(payload, _ctx) {
TrialMailer.ended(payload.fetch(:subscription).fetch("referenceId")).deliver_later
},
on_trial_expired: ->(subscription, _ctx) {
TrialMailer.expired(subscription.fetch("referenceId")).deliver_later
}
}
}Better Auth automatically prevents multiple trials for the same reference ID. Once a reference has ever had a trial, later subscriptions do not receive another trial even if the new plan has free_trial.
Schema
The Stripe plugin adds these fields to the Better Auth schema.
User
Table name: user
| Field | Type | Description |
|---|---|---|
stripeCustomerId | string | Stripe customer ID for the user. |
Organization
Table name: organization, only when organization.enabled is true.
| Field | Type | Description |
|---|---|---|
stripeCustomerId | string | Stripe customer ID for the organization. |
Subscription
Table name: subscription, only when subscription.enabled is true.
| Field | Type | Description |
|---|---|---|
id | string | Unique subscription row ID. |
plan | string | Better Auth plan name. |
referenceId | string | User, organization, or custom reference this subscription belongs to. This should not be unique, so users can resubscribe after cancellation. |
stripeCustomerId | string | Stripe customer ID. |
stripeSubscriptionId | string | Stripe subscription ID. |
status | string | Stripe subscription status. Defaults to incomplete. |
periodStart | date | Start of the current billing period. |
periodEnd | date | End of the current billing period. |
trialStart | date | Trial start time. |
trialEnd | date | Trial end time. |
cancelAtPeriodEnd | boolean | Whether the subscription is scheduled to cancel at period end. Defaults to false. |
cancelAt | date | Scheduled cancellation time. |
canceledAt | date | Time when cancellation was requested. |
endedAt | date | Time when the subscription ended. |
seats | number | Seat quantity. |
billingInterval | string | Billing interval such as month or year. |
stripeScheduleId | string | Stripe Subscription Schedule ID for a pending scheduled plan change. |
limits | json | Plan limits copied from the active plan. |
Options
Plugin Options
| Option | Type | Description |
|---|---|---|
stripe_api_key | String | Stripe secret key. Defaults to ENV["STRIPE_SECRET_KEY"] when omitted. |
stripe_webhook_secret | String | Stripe webhook signing secret. Required for webhooks. |
stripe_client | Object | Custom Stripe-compatible client. |
client | Object | Alias for stripe_client. |
create_customer_on_sign_up | Boolean | Create or reuse a Stripe customer during signup. Defaults to false. |
get_customer_create_params | Proc | Customize user customer creation params. Receives user, ctx. |
on_customer_create | Proc | Called after a user customer is created or found. Receives payload, ctx. |
on_event | Proc | Called for every verified Stripe webhook event. |
subscription | Hash | Subscription configuration. |
organization | Hash | Organization customer configuration. |
Subscription Options
| Option | Type | Description |
|---|---|---|
enabled | Boolean | Enables subscription endpoints and schema. |
plans | Array<Hash> or Proc | Subscription plans. Required when enabled. |
require_email_verification | Boolean | Requires verified email before upgrade. Defaults to false. |
authorize_reference | Proc | Authorizes reference IDs. Required for organization subscriptions and custom user references. |
get_checkout_session_params | Proc | Customizes Stripe Checkout params and request options. Receives data, request, ctx. |
on_subscription_complete | Proc | Called after Checkout-created subscriptions complete. |
on_subscription_created | Proc | Called when a subscription is created outside Checkout and received by webhook. |
on_subscription_update | Proc | Called when a subscription is updated by webhook. |
on_subscription_cancel | Proc | Called when a pending cancellation is detected. |
on_subscription_deleted | Proc | Called when a subscription is deleted by webhook. |
Plan Configuration
| Option | Type | Description |
|---|---|---|
name | String | Required plan name. Stored lowercased. |
price_id | String | Stripe price ID. Required unless lookup_key is used. |
lookup_key | String | Stripe price lookup key. |
annual_discount_price_id | String | Stripe price ID for annual billing. |
annual_discount_lookup_key | String | Stripe lookup key for annual billing. |
limits | Hash | Plan limits returned with active subscriptions and stored on subscription updates. |
free_trial | Hash | Trial configuration. |
seat_price_id | String | Per-seat Stripe price ID for organization seat billing. |
line_items | Array<Hash> | Additional Stripe Checkout line items. |
proration_behavior | String | Stripe proration behavior for direct subscription updates. |
Stripe Checkout does not support mixed-interval subscription line items. Keep every line item on the same billing interval.
Free Trial Configuration
| Option | Type | Description |
|---|---|---|
days | Integer | Trial length in days. |
on_trial_start | Proc | Called when a trial starts. Receives subscription. |
on_trial_end | Proc | Called when a trialing subscription becomes active. Receives payload, ctx. |
on_trial_expired | Proc | Called when a trial expires without conversion. Receives subscription, ctx. |
Organization Options
| Option | Type | Description |
|---|---|---|
enabled | Boolean | Enables organization customers and organization subscription references. |
get_customer_create_params | Proc | Customize organization customer creation params. Receives organization, ctx. |
on_customer_create | Proc | Called after an organization Stripe customer is created. Receives payload, ctx. |
Advanced Usage
Using With Organizations
The Stripe plugin integrates with the organization plugin so organizations can be Stripe Customers. This is useful for B2B billing where the organization, not the user, owns the subscription.
When organization billing is enabled:
- A Stripe Customer is created the first time an organization subscribes.
- Organization name changes are synced to the Stripe Customer.
- Organizations with active Stripe subscriptions cannot be deleted.
authorize_referenceis required for organization subscription actions.
plugins: [
BetterAuth::Plugins.organization,
BetterAuth::Plugins.stripe(
stripe_api_key: ENV.fetch("STRIPE_SECRET_KEY"),
stripe_webhook_secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"),
organization: { enabled: true },
subscription: {
enabled: true,
plans: [
{ name: "team", price_id: "price_team", seat_price_id: "price_team_seat" }
],
authorize_reference: ->(data, _ctx) {
member = Member.find_by(
user_id: data.fetch(:user).fetch("id"),
organization_id: data.fetch(:reference_id)
)
["owner", "admin"].include?(member&.role)
}
}
)
]Create an organization subscription by passing customerType: "organization" and either a referenceId or an active organization in the session.
auth.api.upgrade_subscription(
headers: { "cookie" => request.env["HTTP_COOKIE"] },
body: {
plan: "team",
referenceId: active_org.id,
customerType: "organization",
successUrl: "/org/billing/success",
cancelUrl: "/org/billing"
}
)Organization Billing Email
Organization billing email is not automatically synced because organizations do not have a built-in unique email. Set it through Stripe Dashboard, or pass it when creating the organization customer:
organization: {
enabled: true,
get_customer_create_params: ->(organization, _ctx) {
{
email: organization["billing_email"],
metadata: { billingContact: organization["billing_contact_id"] }
}
}
}Custom Checkout Session Parameters
Use get_checkout_session_params to merge custom Stripe Checkout params and request options.
subscription: {
enabled: true,
plans: [...],
get_checkout_session_params: ->(data, _request, _ctx) {
user = data.fetch(:user)
plan = data.fetch(:plan)
{
params: {
allow_promotion_codes: true,
tax_id_collection: { enabled: true },
billing_address_collection: "required",
metadata: {
planType: plan.fetch(:name),
referralCode: user.dig("metadata", "referralCode")
}
},
options: {
idempotency_key: "sub_#{user.fetch('id')}_#{plan.fetch(:name)}"
}
}
}
}Tax Collection
subscription: {
enabled: true,
plans: [...],
get_checkout_session_params: ->(_data, _request, _ctx) {
{
params: {
tax_id_collection: { enabled: true }
}
}
}
}Automatic Tax Calculation
subscription: {
enabled: true,
plans: [...],
get_checkout_session_params: ->(_data, _request, _ctx) {
{
params: {
automatic_tax: { enabled: true }
}
}
}
}You must configure tax registrations in Stripe before enabling automatic tax.
Routes
| Method | Path | Ruby API method |
|---|---|---|
POST | /subscription/upgrade | auth.api.upgrade_subscription |
POST | /subscription/cancel | auth.api.cancel_subscription |
POST | /subscription/restore | auth.api.restore_subscription |
GET | /subscription/list | auth.api.list_active_subscriptions |
POST | /subscription/billing-portal | auth.api.create_billing_portal |
GET | /subscription/success | auth.api.subscription_success |
GET | /subscription/cancel/callback | auth.api.cancel_subscription_callback |
POST | /stripe/webhook | auth.api.stripe_webhook |
Troubleshooting
Webhook Issues
If webhooks are not processed:
- Confirm the webhook URL matches your Better Auth mount path.
- Confirm
STRIPE_WEBHOOK_SECRETmatches the webhook endpoint signing secret. - Confirm the required Stripe events are selected.
- Check server logs for signature verification and webhook handling errors.
Subscription Status Issues
If subscription statuses are not updating:
- Confirm Stripe webhook events are reaching
/api/auth/stripe/webhook. - Confirm
stripeCustomerIdandstripeSubscriptionIdare present in your database. - Confirm the plan price IDs or lookup keys match the Stripe subscription items.
- Confirm the reference ID matches the user or organization you expect.
Testing Webhooks Locally
Use the Stripe CLI to forward webhooks to your local auth server:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhookUse the signing secret printed by the CLI as STRIPE_WEBHOOK_SECRET.