Webhooks
The two webhook endpoints mounted by the engine — Stripe and Adapty — plus the signature verification scheme, the `provider_events` idempotency model, replay / retry behavior, and the production security checklist.
supabase-billing ships exactly two HTTP endpoints, both POST, both mounted under the engine's /supabase_billing namespace:
POST /supabase_billing/webhooks/stripe— mounted in Stripe:webhookmode (default).POST /supabase_billing/webhooks/adapty— always mounted; Adapty is webhook-only.
Each endpoint verifies the provider's signature, stores the raw payload in provider_events for replay and debugging, hands the parsed event to a per-provider EventProcessor, and responds 200 with { "received": true }. Everything you need to know to operate, observe, and debug those two endpoints in production is below.
Webhook security checklist
Before pointing a real Stripe or Adapty webhook at a deployed app, confirm all four:
- HTTPS only. Stripe and Adapty refuse to deliver webhooks to non-TLS endpoints in their production environments. Expose
/supabase_billing/webhooks/stripeand/supabase_billing/webhooks/adaptyover HTTPS — terminate TLS at your load balancer, never at the Rails process — and use plain HTTP only for local development behind a tunneling tool that re-presents the endpoint over HTTPS. - Signing secrets in env, not source.
STRIPE_WEBHOOK_SECRETandADAPTY_WEBHOOK_SECRETbelong in your deployment's secret store, not inconfig/supabase_billing.ymlor any committed file. The generated initializer reads them viaENV.fetch, which fails loudly if a secret is missing rather than silently disabling verification. - Never log raw webhook payloads. Webhook bodies contain provider IDs, prices, and (on Adapty) the Supabase
auth.users.idUUID. Don't putrequest.bodyorparamsinto your Rails logger; the gem writes the parsed payload to theprovider_events.payloadjsonb column, which is the supported audit trail. If you need to inspect a specific event during an incident, query the column directly — don't grep production logs. - One secret per environment. Use a different signing secret for staging vs. production, and rotate immediately if a secret is ever printed to a log line, posted in chat, or shipped in a deploy artifact. Both providers let you rotate without downtime — register the new secret, deploy, then revoke the old one.
Endpoints
POST /supabase_billing/webhooks/stripe
Mounted by Supabase::Billing::Engine when config.stripe_ingestion = :webhook (the default). The route is not registered when config.stripe_ingestion = :sync_engine — config/routes.rb wraps the route declaration in a conditional, so the two modes are mutually exclusive by construction. Pointing a Stripe webhook at an app running in :sync_engine mode returns 404; switch the app back to :webhook mode or repoint the webhook.
Status codes:
200 OK+{ "received": true }— signature verified, payload parsed, event handed to the processor (including for event types the processor doesn't handle, which are logged and dropped).400 Bad Request— signature verified but the body isn't valid JSON.401 Unauthorized— signature missing, malformed, or doesn't match the configured secret.
The path is rooted at whatever mount point you chose in config/routes.rb (the generator picks /supabase_billing). With the default mount the full URL Stripe should call is:
https://app.example.com/supabase_billing/webhooks/stripePOST /supabase_billing/webhooks/adapty
Always mounted — there is no sync-engine equivalent for Adapty. Adapty's server-to-server webhook is the only supported ingestion path for mobile in-app subscriptions. With the default mount the URL Adapty should call is:
https://app.example.com/supabase_billing/webhooks/adaptyStatus codes are the same as Stripe: 200 on success / ignored event types, 400 on invalid JSON, 401 when the Authorization header is missing or doesn't match the configured secret.
Signature verification
Both adapters verify in-gem rather than depending on the official stripe / adapty Ruby gems — that keeps supabase-billing dependency-free at runtime. The verification code lives in lib/supabase/billing/adapters/<provider>/signature_verifier.rb and is invoked from the controller before any JSON parsing.
Stripe sends a Stripe-Signature header on every webhook request, formatted as:
Stripe-Signature: t=1700000000,v1=<hex_hmac_sha256>The adapter (see Supabase::Billing::Adapters::Stripe::SignatureVerifier) does the following per request:
- Parse
t=and one or morev1=entries from the header. - Reject (
401) iftis older than 5 minutes (DEFAULT_TOLERANCE = 300), matching Stripe's documented replay window. - Compute
HMAC-SHA256(secret, "#{t}.#{raw_body}")and compare against eachv1value in constant time viaOpenSSL.fixed_length_secure_compare. - Raise
SignatureVerificationErrorif nov1matches.
The signing secret comes from config.stripe_webhook_secret, which the generated initializer reads from ENV["STRIPE_WEBHOOK_SECRET"] (the whsec_... value Stripe shows you when you create the webhook). A blank or missing secret is itself a SignatureVerificationError — the endpoint never silently degrades to "accept everything."
Common reasons for a 401:
STRIPE_WEBHOOK_SECRETset to the wrong env's value (staging secret deployed to prod).- A proxy or middleware that mutates the request body before it reaches Rails (HMAC is computed over the exact bytes Stripe signed — re-serializing JSON anywhere upstream will invalidate the signature).
- Clock skew on the Rails host. If
tdiffers fromTime.nowby more than 5 minutes, verification fails even for an otherwise-correct signature. NTP must be healthy on production hosts.
Adapty's server-to-server webhook scheme is a shared secret passed verbatim in the Authorization header — there is no HMAC over the body. The adapter (see Supabase::Billing::Adapters::Adapty::SignatureVerifier) does a single fixed-length constant-time comparison via OpenSSL.fixed_length_secure_compare:
Authorization: <adapty_webhook_secret>A blank or missing config.adapty_webhook_secret is a SignatureVerificationError — the endpoint never accepts unsigned requests. The signing secret comes from ENV["ADAPTY_WEBHOOK_SECRET"] via the generated initializer.
Because Adapty does not HMAC the payload, the secret is the only thing standing between an attacker and a forged event. Treat it with the same care you would a database password: rotate immediately if it leaks, never commit it, and don't print it anywhere your logging infrastructure retains.
Common reasons for a 401:
ADAPTY_WEBHOOK_SECRETset to the wrong env's value, or to the display value rather than the secret value from the Adapty dashboard.- An upstream proxy stripping or rewriting the
Authorizationheader. Some CDNs stripAuthorizationby default on cached paths — verify your routing layer passes it through unmodified.
The provider_events table and idempotency model
Every signature-verified webhook — handled or ignored — is recorded in provider_events before the event processor inspects the event type. That gives you a complete audit trail of what each provider sent, decoupled from whether the gem currently knows how to act on it.
Schema
The migration generated by supabase_billing:install creates the table as:
create_table :provider_events, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :provider, null: false # "stripe" or "adapty"
t.string :provider_event_id, null: false # the provider's own event id
t.string :event_type, null: false # e.g. "customer.subscription.created"
t.jsonb :payload, null: false, default: {}
t.datetime :received_at, null: false
t.datetime :processed_at # set when the processor finishes
t.timestamps
end
add_index :provider_events, %i[provider provider_event_id], unique: true
add_index :provider_events, %i[provider processed_at]Key things to know:
(provider, provider_event_id)is unique. That index is the gem's idempotency primitive — Stripe and Adapty both retry on non-2xx responses, and the unique constraint guarantees that no matter how many duplicate deliveries hit the endpoint, you end up with exactly one row.payloadisjsonb(defaults to{}) and stores the parsed event Hash as the provider sent it. This is the supported audit trail — query this column, don't grep production logs.received_atis set the first time a delivery for a givenprovider_event_idis seen. It is not reset on subsequent duplicate deliveries.processed_atis set once the event handler runs to completion (including for ignored event types). A row withprocessed_at IS NULLis one that was stored but never finished processing — useful for diagnosing crashes mid-handler.- The
(provider, processed_at)index supports the "what's stuck?" query:Billing::ProviderEvent.where(provider: "stripe", processed_at: nil).
Adapty: when no event_id is supplied
Stripe always sends an id field on its top-level event payload. Adapty supplies an event_id on its server-to-server webhooks in normal operation — but the adapter defensively handles the case where it's absent by synthesizing a deterministic id:
adapty_synth_<sha256(event_type | event_datetime | profile_id | transaction_id)[0,32]>Same event in → same synthetic id out, so the unique index still gives you idempotency even on payloads where Adapty omits event_id.
Idempotency in practice
Because storage happens via find_or_initialize_by(provider:, provider_event_id:), replaying the same delivery (or having Stripe / Adapty retry after a transient outage) is safe end-to-end:
- Duplicate delivery →
provider_eventsalready has the row → no INSERT happens, onlyevent_type/payload/processed_atare re-written. - Canonical-table writes (
subscriptions,provider_subscriptions,billing_customers) are alsofind_or_initialize_byupserts keyed off provider IDs, so the second delivery is a no-op on the canonical side too.
You do not need an idempotency key, a Redis lock, or a dedupe job in your host app. The unique index plus the upsert pattern is the entire mechanism.
Replay and retry behavior
What the providers do
- Stripe retries any non-2xx response on an exponential backoff over up to 3 days (per Stripe's documented retry policy). The retries carry the same
idfield, so they hit the same(provider, provider_event_id)unique constraint and dedupe cleanly. - Adapty retries non-2xx responses with its own backoff; the retried delivery carries the same
event_id(or, when absent, the same payload, which the synthetic-id scheme above hashes to the same value).
The contract on your side is simple: return 2xx only if you're willing to mark the event as handled. The controllers return 401 for bad signatures and 400 for un-parseable JSON specifically so the provider keeps retrying until you fix the misconfiguration — silently 200-ing those would lose events.
Replaying an event by hand
Operationally, every signature-verified delivery is in provider_events.payload as parsed JSON. To re-run a single event through the processor — e.g. after fixing a bug in a custom subclass, or after recovering from a host-app exception that aborted the original handler — hand the payload back to the processor:
event = Billing::ProviderEvent.find_by!(
provider: "stripe",
provider_event_id: "evt_1Nabc..."
)
Supabase::Billing::Adapters::Stripe::EventProcessor.new.call(event.payload)The processor is idempotent across the board — upserting provider_subscriptions and the canonical subscription is a no-op when state already matches, so replaying a successful event won't double-charge, double-cancel, or otherwise corrupt state. The same shape works for Adapty:
event = Billing::ProviderEvent.find_by!(
provider: "adapty",
provider_event_id: "<event_id>"
)
Supabase::Billing::Adapters::Adapty::EventProcessor.new.call(event.payload)Manual retry on the provider side
Both Stripe and Adapty's dashboards expose a "resend webhook" button for individual events — that's the right tool when you want the provider to re-send a specific event (e.g. after rotating a webhook secret and missing a delivery during the rotation window). The retry hits the live endpoint, goes through signature verification, and dedupes against provider_events the same as any other delivery.
Replaying a range during recovery
If your endpoint was down or returning 5xx for an extended window, query provider_events to identify which deliveries you actually received:
Billing::ProviderEvent
.where(provider: "stripe", processed_at: nil)
.where(received_at: 1.hour.ago..)
.find_each do |evt|
Supabase::Billing::Adapters::Stripe::EventProcessor.new.call(evt.payload)
endFor events Stripe / Adapty attempted to deliver but you never received (full outage, DNS failure), the provider's dashboard is the source of truth — use its bulk-resend feature or its event-list API, and rely on provider_events to dedupe whatever comes back through the live endpoint.
Debugging checklist
When a webhook isn't doing what you expect, walk down the list:
- Did the delivery reach Rails? Check your load balancer / Rails access log for a
POST /supabase_billing/webhooks/<provider>. If it's not there, the failure is upstream — DNS, firewall, or the provider didn't fire the event. - Did signature verification pass? A
401in the response log means the controller rejected the delivery before any DB write. Re-check the env var, the dashboard secret, and (for Stripe) host clock skew. - Did it land in
provider_events?Billing::ProviderEvent.where(provider: "stripe").order(received_at: :desc).firstshould be the most recent delivery. Ifprocessed_at IS NULL, the handler ran into a host-app exception — your Rails error reporter (Sentry / Honeybadger / etc.) should have the backtrace. - Was the event type one the processor handles? See Providers for the supported event lists (four for Stripe, seven for Adapty). Unsupported types are stored in
provider_eventsand silently dropped — that's not a bug, but it's not the same as "handled." - Did the canonical row update? If the event type is supported and the processor ran, but
subscriptions/billing_customersdidn't move, the usual cause is a missing mapping — an unmapped Stripe price ID, an Adaptycustomer_user_idthat doesn't match anyauth.users.id. Both are logged atinfowith the specific ID that didn't resolve.
See Providers for the per-adapter event-type lists and dashboard registration steps, Schema Reference for the rest of the tables that webhooks write to, and Entitlements for how the canonical subscription state turns into the entitled? answer your controllers actually call.
Schema Reference
The twelve tables created by `rails g supabase_billing:install` — eight canonical tables for the entitlement engine plus four provider-mapping tables — with an ER diagram and the RLS model.
API Reference
Auto-generated reference for the supabase-billing gem — every public module, class, method, constant, and attribute extracted from the source.