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_modelsupabase:installwrites theAuthenticationconcern, the Devise-shape controllers,Current, the:web-mode initializer, and thesupabase_authentication_routesline inconfig/routes.rb. See Getting started with Rails for the full walk-through.supabase:user_modeladds anapp/models/user.rbbacked by a host-appuserstable whoseidmatchesauth.users.id.supabase-billingforeign-keysbilling_customers.user_idagainst this table, so it must exist before the billing migration runs.
Confirm that you have:
- A
Usermodel atapp/models/user.rb. - A
userstable in the host database (runbin/rails db:migrateif the supabase-rails user-model migration is still pending). - A booting app where
Current.useris populated for a signed-in request.
2. Add the gem
bundle add supabase-billingThis 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,adaptyThe --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— defaultwebhook. Picksync_engineif you're already runningstripe-sync-engineand want the gem to read from itsstripe.*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.rbWhat each group does:
- Migration. A single
create_supabase_billing_schemamigration 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 toauth.uid(). See Schema Reference for the full ER diagram. - Models under
app/models/billing/. One ActiveRecord class per table, all inheriting fromBilling::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 theplanblocks 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 withENV.fetch(...)calls. - Routes.
mount Supabase::Billing::Engine => "/supabase_billing"is appended toconfig/routes.rb. The engine exposes/supabase_billing/webhooks/stripeand/supabase_billing/webhooks/adapty— see Webhooks for the endpoint contract. Usermodel.include Acts::Billableis inserted idempotently intoapp/models/user.rb. This is what givescurrent_user.entitled?,subscribed?,plan, andlimittheir 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:migrateThis 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
endThen reconcile the DSL into the database:
bin/rails supabase_billing:syncsync 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
endIn 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_000What 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::Engineis mounted at/supabase_billing, exposing/supabase_billing/webhooks/stripeand/supabase_billing/webhooks/adapty. - Your
Usermodel has the fourentitled?/subscribed?/plan/limitmethods throughActs::Billable. - A plan DSL in
config/initializers/supabase_billing.rband abin/rails supabase_billing:syncrake 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
Providers
Configure Stripe (webhook or sync-engine) and Adapty — endpoints, signing secrets, and event types.
Entitlements
The full entitled? / subscribed? / plan / limit API, plus the plan DSL reference.
Schema Reference
ER diagram and per-table reference for the twelve canonical and provider-mapping tables.
Webhooks
Signature verification, idempotency, and replay behaviour for each provider endpoint.
Supabase Billing (supabase-billing)
supabase-billing is a provider-agnostic subscription and entitlement layer for Rails apps on Supabase — Stripe (web) and Adapty (mobile) ingestion adapters write into one canonical schema and a single `user.entitled?` API.
Providers (Stripe + Adapty)
Side-by-side Stripe (web) and Adapty (mobile) provider setup — webhook endpoints, signing secret env vars, ingestion modes, supported event types, and the `config/initializers/supabase_billing.rb` block for each.