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:

Gemfile
gem "better_auth-stripe"

Then install dependencies:

Terminal
bundle install

The package depends on the official stripe gem.

Configure Environment Variables

.env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Add The Plugin

config/auth.rb
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.

config/auth.rb
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.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.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:

billing.rb
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.

config/auth.rb
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.

config/auth.rb
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 }
    }
  ]
}
config/auth.rb
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.

billing.rb
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:

OptionTypeDescription
planStringRequired. Name of the plan to subscribe to.
annualBooleanUse the annual discount price when configured.
referenceId / reference_idStringSubscription reference ID. Defaults to the user ID, or to active organization ID for organization billing.
subscriptionId / subscription_idStringStripe subscription ID to upgrade. Required when changing an existing active subscription.
metadataHashExtra metadata to pass to Stripe. Internal keys are protected.
customerType / customer_type"user" or "organization"Billing entity type. Defaults to "user".
seatsIntegerSeat quantity for user or manual seat billing. Organization plans with seat_price_id use member count instead.
localeStringStripe Checkout locale.
successUrl / success_urlStringRequired. URL to return to after successful checkout.
cancelUrl / cancel_urlStringRequired. URL to return to after canceled checkout.
returnUrl / return_urlStringReturn URL for Billing Portal flows and scheduled changes.
disableRedirect / disable_redirectBooleanAdds a redirect flag to the response for client behavior.
scheduleAtPeriodEnd / schedule_at_period_endBooleanSchedule 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.

billing.rb
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.

billing.rb
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 stripeScheduleId on the subscription.
  • The response URL is the returnUrl; no Stripe Checkout or Billing Portal redirect is required.
  • A later customer.subscription.updated webhook 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

billing.rb
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.

config/auth.rb
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

billing.rb
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:

FieldDescription
cancelAtPeriodEndWhether the subscription will cancel at the end of the current period.
cancelAtScheduled cancellation time when Stripe provides one.
canceledAtTime when cancellation was requested.
endedAtTime when the subscription actually ended.
statusChanges to canceled after the subscription has ended or Stripe sends a delete event.

Restoring A Subscription

billing.rb
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

billing.rb
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.

billing.rb
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:

billing.rb
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.

config/auth.rb
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.

config/auth.rb
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

config/auth.rb
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:

config/auth.rb
{
  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

FieldTypeDescription
stripeCustomerIdstringStripe customer ID for the user.

Organization

Table name: organization, only when organization.enabled is true.

FieldTypeDescription
stripeCustomerIdstringStripe customer ID for the organization.

Subscription

Table name: subscription, only when subscription.enabled is true.

FieldTypeDescription
idstringUnique subscription row ID.
planstringBetter Auth plan name.
referenceIdstringUser, organization, or custom reference this subscription belongs to. This should not be unique, so users can resubscribe after cancellation.
stripeCustomerIdstringStripe customer ID.
stripeSubscriptionIdstringStripe subscription ID.
statusstringStripe subscription status. Defaults to incomplete.
periodStartdateStart of the current billing period.
periodEnddateEnd of the current billing period.
trialStartdateTrial start time.
trialEnddateTrial end time.
cancelAtPeriodEndbooleanWhether the subscription is scheduled to cancel at period end. Defaults to false.
cancelAtdateScheduled cancellation time.
canceledAtdateTime when cancellation was requested.
endedAtdateTime when the subscription ended.
seatsnumberSeat quantity.
billingIntervalstringBilling interval such as month or year.
stripeScheduleIdstringStripe Subscription Schedule ID for a pending scheduled plan change.
limitsjsonPlan limits copied from the active plan.

Options

Plugin Options

OptionTypeDescription
stripe_api_keyStringStripe secret key. Defaults to ENV["STRIPE_SECRET_KEY"] when omitted.
stripe_webhook_secretStringStripe webhook signing secret. Required for webhooks.
stripe_clientObjectCustom Stripe-compatible client.
clientObjectAlias for stripe_client.
create_customer_on_sign_upBooleanCreate or reuse a Stripe customer during signup. Defaults to false.
get_customer_create_paramsProcCustomize user customer creation params. Receives user, ctx.
on_customer_createProcCalled after a user customer is created or found. Receives payload, ctx.
on_eventProcCalled for every verified Stripe webhook event.
subscriptionHashSubscription configuration.
organizationHashOrganization customer configuration.

Subscription Options

OptionTypeDescription
enabledBooleanEnables subscription endpoints and schema.
plansArray<Hash> or ProcSubscription plans. Required when enabled.
require_email_verificationBooleanRequires verified email before upgrade. Defaults to false.
authorize_referenceProcAuthorizes reference IDs. Required for organization subscriptions and custom user references.
get_checkout_session_paramsProcCustomizes Stripe Checkout params and request options. Receives data, request, ctx.
on_subscription_completeProcCalled after Checkout-created subscriptions complete.
on_subscription_createdProcCalled when a subscription is created outside Checkout and received by webhook.
on_subscription_updateProcCalled when a subscription is updated by webhook.
on_subscription_cancelProcCalled when a pending cancellation is detected.
on_subscription_deletedProcCalled when a subscription is deleted by webhook.

Plan Configuration

OptionTypeDescription
nameStringRequired plan name. Stored lowercased.
price_idStringStripe price ID. Required unless lookup_key is used.
lookup_keyStringStripe price lookup key.
annual_discount_price_idStringStripe price ID for annual billing.
annual_discount_lookup_keyStringStripe lookup key for annual billing.
limitsHashPlan limits returned with active subscriptions and stored on subscription updates.
free_trialHashTrial configuration.
seat_price_idStringPer-seat Stripe price ID for organization seat billing.
line_itemsArray<Hash>Additional Stripe Checkout line items.
proration_behaviorStringStripe 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

OptionTypeDescription
daysIntegerTrial length in days.
on_trial_startProcCalled when a trial starts. Receives subscription.
on_trial_endProcCalled when a trialing subscription becomes active. Receives payload, ctx.
on_trial_expiredProcCalled when a trial expires without conversion. Receives subscription, ctx.

Organization Options

OptionTypeDescription
enabledBooleanEnables organization customers and organization subscription references.
get_customer_create_paramsProcCustomize organization customer creation params. Receives organization, ctx.
on_customer_createProcCalled 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_reference is required for organization subscription actions.
config/auth.rb
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.

billing.rb
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:

config/auth.rb
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.

config/auth.rb
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

config/auth.rb
subscription: {
  enabled: true,
  plans: [...],
  get_checkout_session_params: ->(_data, _request, _ctx) {
    {
      params: {
        tax_id_collection: { enabled: true }
      }
    }
  }
}

Automatic Tax Calculation

config/auth.rb
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

MethodPathRuby API method
POST/subscription/upgradeauth.api.upgrade_subscription
POST/subscription/cancelauth.api.cancel_subscription
POST/subscription/restoreauth.api.restore_subscription
GET/subscription/listauth.api.list_active_subscriptions
POST/subscription/billing-portalauth.api.create_billing_portal
GET/subscription/successauth.api.subscription_success
GET/subscription/cancel/callbackauth.api.cancel_subscription_callback
POST/stripe/webhookauth.api.stripe_webhook

Troubleshooting

Webhook Issues

If webhooks are not processed:

  1. Confirm the webhook URL matches your Better Auth mount path.
  2. Confirm STRIPE_WEBHOOK_SECRET matches the webhook endpoint signing secret.
  3. Confirm the required Stripe events are selected.
  4. Check server logs for signature verification and webhook handling errors.

Subscription Status Issues

If subscription statuses are not updating:

  1. Confirm Stripe webhook events are reaching /api/auth/stripe/webhook.
  2. Confirm stripeCustomerId and stripeSubscriptionId are present in your database.
  3. Confirm the plan price IDs or lookup keys match the Stripe subscription items.
  4. 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:

Terminal
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook

Use the signing secret printed by the CLI as STRIPE_WEBHOOK_SECRET.