supabase-rb-rb

Getting started with supabase-billing

Install supabase-billing on top of supabase-rails, run the generator, migrate, and make your first `user.entitled?(:feature)` call — from `bundle add` to a gated controller action in under ten minutes.

This is the quickstart for adding supabase-billing to a Rails app that already uses supabase-rails for Supabase Auth. Follow it top-to-bottom and you'll end with the canonical billing schema migrated, Stripe and Adapty webhook endpoints mounted, and a controller action gated by current_user.entitled?(:feature_key).

supabase-billing is not standalone — it expects Current.user, the shadow users table, and the Supabase request context that supabase-rails installs. The first step on this page is making sure those are in place.

1. Install supabase-rails first

Prerequisite: supabase-rails generators

supabase-billing builds on top of supabase-rails and assumes the supabase-rails install and user-model generators have already been run in the host app. Skipping this step leaves you without Current.user, without the users shadow table that billing_customers.user_id references, and with no Supabase request context for the webhook controllers to reuse. The billing install generator will fail or produce a broken schema if these aren't done first.

If your app doesn't already have supabase-rails installed, do that now:

bundle add supabase-rails
bin/rails generate supabase:install
bin/rails generate supabase:user_model
  • supabase:install writes the Authentication concern, the Devise-shape controllers, Current, the :web-mode initializer, and the supabase_authentication_routes line in config/routes.rb. See Getting started with Rails for the full walk-through.
  • supabase:user_model adds an app/models/user.rb backed by a host-app users table whose id matches auth.users.id. supabase-billing foreign-keys billing_customers.user_id against this table, so it must exist before the billing migration runs.

Confirm that you have:

  • A User model at app/models/user.rb.
  • A users table in the host database (run bin/rails db:migrate if the supabase-rails user-model migration is still pending).
  • A booting app where Current.user is populated for a signed-in request.

2. Add the gem

bundle add supabase-billing

This pulls supabase-billing into your Gemfile and runs bundle install. The gem is a mountable Rails engine — no extra require or initializer changes are needed at this step.

3. Run the install generator

bin/rails generate supabase_billing:install --provider=stripe,adapty

The --provider flag is required and selects which ingestion adapters to wire up. Pass one or both of stripe and adapty:

  • --provider=stripe — webhook-only, no mobile.
  • --provider=adapty — mobile-only, no Stripe.
  • --provider=stripe,adapty — both (most common).

Two optional flags:

  • --stripe-ingestion=webhook|sync_engine — default webhook. Pick sync_engine if you're already running stripe-sync-engine and want the gem to read from its stripe.* schema instead of consuming webhooks directly.
  • --skip-rls — omit the Supabase RLS policies from the generated migration. Use this only if you plan to author the policies by hand.

Files the generator creates

create  db/migrate/<timestamp>_create_supabase_billing_schema.rb
create  app/models/billing/application_record.rb
create  app/models/billing/billing_customer.rb
create  app/models/billing/plan.rb
create  app/models/billing/entitlement.rb
create  app/models/billing/plan_entitlement.rb
create  app/models/billing/subscription.rb
create  app/models/billing/subscription_item.rb
create  app/models/billing/usage_limit.rb
create  app/models/billing/usage_event.rb
create  app/models/billing/provider_customer.rb
create  app/models/billing/provider_subscription.rb
create  app/models/billing/provider_product.rb
create  app/models/billing/provider_event.rb
create  config/initializers/supabase_billing.rb
create  config/supabase_billing.yml
insert  config/routes.rb
insert  app/models/user.rb

What each group does:

  • Migration. A single create_supabase_billing_schema migration that creates eight canonical tables (billing_customers, plans, entitlements, plan_entitlements, subscriptions, subscription_items, usage_limits, usage_events) plus four provider-mapping tables (provider_customers, provider_subscriptions, provider_products, provider_events). All UUID-keyed and (unless --skip-rls) shipped with RLS policies scoped to auth.uid(). See Schema Reference for the full ER diagram.
  • Models under app/models/billing/. One ActiveRecord class per table, all inheriting from Billing::ApplicationRecord. These are the canonical objects you reach for in your app code — never the provider-mapping tables directly.
  • Initializer at config/initializers/supabase_billing.rb. Fully-commented DSL skeleton — uncomment the plan blocks and adapter secret lines you need. See Providers for the per-provider configuration.
  • YAML at config/supabase_billing.yml. Env-keyed lookups for the dev / test / prod webhook secrets, pre-filled with ENV.fetch(...) calls.
  • Routes. mount Supabase::Billing::Engine => "/supabase_billing" is appended to config/routes.rb. The engine exposes /supabase_billing/webhooks/stripe and /supabase_billing/webhooks/adapty — see Webhooks for the endpoint contract.
  • User model. include Acts::Billable is inserted idempotently into app/models/user.rb. This is what gives current_user.entitled?, subscribed?, plan, and limit their behaviour.

The generator is idempotent — re-running it skips files that already exist and only re-applies the include Acts::Billable and route mount if they're missing.

4. Migrate the database

bin/rails db:migrate

This runs the create_supabase_billing_schema migration against your Supabase Postgres database. You should see twelve new tables and (unless you passed --skip-rls) *_select_own policies attached to billing_customers, subscriptions, and subscription_items.

Use the direct connection for migrations

Run migrations against Supabase's direct connection (port 5432), not the pooler. The pooler doesn't support the CREATE POLICY statements the RLS-enabled migration emits. The supabase-rails README has the connection string details.

5. Configure at least one plan

Open config/initializers/supabase_billing.rb, uncomment the example config.plan blocks, and define the plans and entitlements your app needs. A minimal example:

# config/initializers/supabase_billing.rb
SupabaseBilling.configure do |config|
  config.stripe_webhook_secret = ENV.fetch("STRIPE_WEBHOOK_SECRET")
  config.adapty_webhook_secret = ENV.fetch("ADAPTY_WEBHOOK_SECRET")

  config.plan :free, name: "Free" do
    entitlement :ai_requests, value: 100
    entitlement :projects,    value: 1
  end

  config.plan :pro, name: "Pro" do
    entitlement :ai_requests, value: 10_000
    entitlement :projects,    value: 100

    stripe_prices   %w[price_1Nabc...]
    adapty_products %w[com.example.pro.monthly]
  end
end

Then reconcile the DSL into the database:

bin/rails supabase_billing:sync

sync upserts plans, entitlements, and provider_products rows. Plans removed from the DSL get archived_at stamped rather than destroyed, so historic subscriptions stay resolvable. Re-run it after every change to the plan DSL — see Entitlements for the full DSL surface.

6. Make your first entitled? call

With at least one plan synced, you can gate a controller action on it. The Current.user you already get from supabase-rails now has the four billing methods on it:

class ExportsController < ApplicationController
  before_action :require_authentication

  def create
    unless Current.user.entitled?(:ai_requests)
      return render json: { error: "quota exceeded" }, status: :payment_required
    end

    response = AiClient.complete(params[:prompt])
    Current.user.record_usage(:ai_requests, amount: 1)
    render json: response
  end
end

In a brand-new app with no provider webhook delivered yet, current_user.subscribed? returns false and current_user.entitled?(:any_key) returns false — exactly the behaviour you want for a user who hasn't checked out yet. The first subscription_started event from Stripe or Adapty flips them to true.

To exercise the path end-to-end before wiring real provider webhooks, you can hand-create a Billing::Subscription row in a Rails console:

user = User.find_by(email: "you@example.com")
customer = Billing::BillingCustomer.create!(user: user)
Billing::Subscription.create!(
  billing_customer: customer,
  plan: Billing::Plan.find_by!(key: "pro"),
  status: "active"
)

user.reload.entitled?(:ai_requests) # => true
user.subscribed?                    # => true
user.plan.key                       # => "pro"
user.limit(:ai_requests)            # => 10_000

What you just got

After this guide:

  • Twelve canonical and provider-mapping tables exist in your Supabase Postgres database, all UUID-keyed and (by default) RLS-scoped to auth.uid().
  • The Supabase::Billing::Engine is mounted at /supabase_billing, exposing /supabase_billing/webhooks/stripe and /supabase_billing/webhooks/adapty.
  • Your User model has the four entitled? / subscribed? / plan / limit methods through Acts::Billable.
  • A plan DSL in config/initializers/supabase_billing.rb and a bin/rails supabase_billing:sync rake task to reconcile it into the database.

The webhook endpoints are mounted but not yet receiving traffic. To take real money, point Stripe and/or Adapty at those URLs and copy their signing secrets into your environment — see Providers for the per-provider setup.

Next steps

On this page