supabase-rb-rb

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/stripe

Signing 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 through Supabase::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-engine is responsible for keeping a stripe.* schema in your Postgres up to date, and the gem's Supabase::Billing::Adapters::Stripe::SyncEngineReflector reads 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.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.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
end

In :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/adapty

Signing 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_started
  • subscription_renewed
  • subscription_cancelled
  • subscription_expired
  • subscription_refunded
  • non_subscription_purchase
  • access_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
end

A 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
end

After editing the plan DSL, reconcile it into the database:

bin/rails supabase_billing:sync

See Getting Started for the install flow that scaffolds this initializer, and Webhooks for the per-provider signature verification and idempotency contract.

On this page