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.
supabase-billing ships two ingestion adapters: Stripe for the web and Adapty for mobile in-app subscriptions. Both write into the same canonical subscriptions / provider_subscriptions tables, so current_user.entitled?(:feature) returns the same answer regardless of where the user checked out.
The tabs below cover each provider end-to-end: the webhook endpoint the engine mounts, the env var the signing secret comes from, the supported event types, and the matching config/initializers/supabase_billing.rb block. You only need to configure the providers your app actually uses.
Webhooks must be HTTPS in production
Both Stripe and Adapty refuse to deliver webhooks to non-TLS endpoints in their production / live environments. Expose /supabase_billing/webhooks/stripe and /supabase_billing/webhooks/adapty over HTTPS — terminate TLS at your load balancer, never at the Rails process — and use plain HTTP only for local development behind a tunneling tool (e.g. ngrok, cloudflared) that re-presents the endpoint as HTTPS to the provider.
Stripe is the web ingestion adapter. It runs in one of two mutually exclusive modes — pick webhook (the default) for the typical setup, or sync_engine if you already operate a stripe/stripe-sync-engine instance that mirrors Stripe into a stripe.* Postgres schema.
Webhook endpoint
POST /supabase_billing/webhooks/stripe
Mounted by the engine when config.stripe_ingestion = :webhook (the default). The route is not registered in :sync_engine mode — by design, so the two modes can never both be live at once. Point the Stripe dashboard webhook at this path under your app's public origin, e.g.:
https://app.example.com/supabase_billing/webhooks/stripeSigning secret
The endpoint verifies every request against Stripe's HMAC signature using the value of config.stripe_webhook_secret, which the generated initializer pulls from ENV["STRIPE_WEBHOOK_SECRET"]. Set this to the whsec_... value Stripe shows you when you create the webhook.
STRIPE_WEBHOOK_SECRET=whsec_********************************Requests without a valid Stripe-Signature header are rejected with 400 Bad Request and never reach the event processor.
Ingestion mode: :webhook vs :sync_engine
supabase-billing supports two ways of getting subscription state from Stripe into the canonical tables. They are mutually exclusive — config.stripe_ingestion picks one:
:webhook(default). The engine mounts/supabase_billing/webhooks/stripe, verifies the signature, parses the event, and runs it throughSupabase::Billing::Adapters::Stripe::EventProcessor. This is the right choice for a typical Rails app — no extra infrastructure beyond Stripe itself.:sync_engine. No webhook route is mounted. Instead,stripe/stripe-sync-engineis responsible for keeping astripe.*schema in your Postgres up to date, and the gem'sSupabase::Billing::Adapters::Stripe::SyncEngineReflectorreads from those tables to populate the canonical rows. Use this if you already operate stripe-sync-engine for other reasons (analytics, finance reconciliation) and don't want a second consumer of Stripe events.
Switching between modes is a config-only change; the canonical subscriptions / entitlements data the rest of your app reads from is identical either way.
Supported event types
In :webhook mode, the Stripe event processor handles exactly four event types — anything else is logged and dropped (it will not 500 the endpoint, but Stripe quota is wasted sending it). Untick the rest in the Stripe dashboard:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeeded
The first three drive the canonical subscriptions.status transitions (active / trialing / past_due / cancelled / expired). invoice.payment_succeeded extends current_period_end and bumps a past_due subscription back to active.
Initializer snippet
# config/initializers/supabase_billing.rb
Supabase::Billing.configure do |config|
# :webhook (default) or :sync_engine — mutually exclusive.
config.stripe_ingestion = :webhook
config.stripe_webhook_secret = ENV.fetch("STRIPE_WEBHOOK_SECRET")
config.plan :pro, name: "Pro" do
entitlement :ai_requests, value: 10_000
entitlement :projects, value: 100
# Each Stripe price ID that should resolve to this plan.
stripe_prices %w[price_1Nabc... price_1Ndef...]
end
endIn :sync_engine mode the stripe_webhook_secret line can be omitted — there is no webhook to verify — but the stripe_prices mapping on each plan is still required so the reflector can resolve a Stripe price to a canonical plan.
Adapty is the mobile ingestion adapter. It covers iOS (StoreKit / Apple In-App Purchases) and Android (Google Play Billing) through a single server-to-server webhook — your app never parses StoreKit receipts or Play Billing tokens directly.
Webhook endpoint
POST /supabase_billing/webhooks/adapty
Always mounted (Adapty is webhook-only — there is no sync-engine equivalent). Register this path in the Adapty dashboard under your app's public origin:
https://app.example.com/supabase_billing/webhooks/adaptySigning secret
The endpoint verifies every request against Adapty's HMAC signature using config.adapty_webhook_secret, which the generated initializer pulls from ENV["ADAPTY_WEBHOOK_SECRET"]. Copy the signing secret Adapty shows you when you register the webhook in the dashboard.
ADAPTY_WEBHOOK_SECRET=********************************Requests without a valid signature are rejected with 400 Bad Request and never reach the event processor.
Mobile only — no StoreKit / Play Billing direct
Adapty is mobile-only — no direct StoreKit / Play Billing
The Adapty adapter exists for in-app subscriptions purchased on iOS or Android. There is no direct StoreKit integration — your iOS app should not be parsing receipts, calling Transaction.currentEntitlements, or hitting Apple's /verifyReceipt endpoint, and your Android app should not be calling Google Play Billing's BillingClient.queryPurchasesAsync for entitlement decisions. Adapty does that work and forwards the normalized result to this webhook. If you need web checkout, use the Stripe adapter — Adapty is not a web billing layer.
The only client-side wiring required is calling Adapty.identify(supabaseUserId) after Supabase sign-in so Adapty's customer_user_id matches auth.users.id. Once that's in place, the webhook adapter resolves every event to the right billing_customers.user_id without a server-side mapping table.
// iOS — immediately after Supabase auth completes
let session = try await supabase.auth.session
Adapty.identify(session.user.id.uuidString) { error in
if let error = error { /* report + retry */ }
}See Webhooks for the full Adapty dashboard registration walk-through.
Supported event types
The Adapty event processor handles exactly these seven event types — anything else is logged and dropped. Untick the rest in the Adapty dashboard to avoid burning quota on events the gem ignores:
subscription_startedsubscription_renewedsubscription_cancelledsubscription_expiredsubscription_refundednon_subscription_purchaseaccess_level_updated
These drive the canonical subscriptions.status transitions (active / trialing / cancelled / expired / refunded) the same way Stripe's four events do — your app code never has to know which provider opened the subscription.
Initializer snippet
# config/initializers/supabase_billing.rb
Supabase::Billing.configure do |config|
config.adapty_webhook_secret = ENV.fetch("ADAPTY_WEBHOOK_SECRET")
config.plan :pro, name: "Pro" do
entitlement :ai_requests, value: 10_000
entitlement :projects, value: 100
# Adapty product IDs (App Store / Play Store SKUs) that map to this plan.
adapty_products %w[com.example.pro.monthly com.example.pro.yearly]
end
endA plan can declare both stripe_prices and adapty_products — that's the supported way to sell the same canonical plan on web and mobile.
Both providers together
The most common configuration is "Stripe on web, Adapty on mobile, same plans on both." The initializer for that looks like:
# config/initializers/supabase_billing.rb
Supabase::Billing.configure do |config|
config.stripe_ingestion = :webhook
config.stripe_webhook_secret = ENV.fetch("STRIPE_WEBHOOK_SECRET")
config.adapty_webhook_secret = ENV.fetch("ADAPTY_WEBHOOK_SECRET")
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 com.example.pro.yearly]
end
endAfter editing the plan DSL, reconcile it into the database:
bin/rails supabase_billing:syncSee Getting Started for the install flow that scaffolds this initializer, and Webhooks for the per-provider signature verification and idempotency contract.
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.
Entitlements (the `entitled?` API)
Reference for the four-method entitlement surface on the User model — `entitled?`, `subscribed?`, `plan`, and `limit` — plus usage recording, the plan / entitlement / plan_entitlement / usage_limit data model, and controller and view gating examples.