In-app purchase webhooks for Rails. Receive normalized Apple and Google subscription events with a simple callback interface.
Native app (iOS/Android)
↓ StoreKit/Play Billing
App Store / Play Store
↓ Server-to-server notifications
PurchaseKit SaaS (normalizes Apple/Google data)
↓ Webhooks
Your Rails app (via this gem)
↓ Callbacks or Pay::Subscription
Your business logic
PurchaseKit handles the complexity of Apple and Google's different webhook formats, delivering you a consistent event payload regardless of which store the purchase came from.
Add to your Gemfile:
gem "purchasekit"Create an initializer:
# config/initializers/purchasekit.rb
PurchaseKit.configure do |config|
config.api_key = Rails.application.credentials.dig(:purchasekit, :api_key)
config.app_id = Rails.application.credentials.dig(:purchasekit, :app_id)
config.webhook_secret = Rails.application.credentials.dig(:purchasekit, :webhook_secret)
endMount the engine in your routes:
# config/routes.rb
mount PurchaseKit::Engine, at: "/purchasekit"Import the JavaScript:
// app/javascript/application.js
import "purchasekit/turbo_actions"
// app/javascript/controllers/index.js
eagerLoadControllersFrom("purchasekit", application)If you use the Pay gem, PurchaseKit automatically detects it and handles everything:
gem "pay"
gem "purchasekit"When Pay is detected, webhooks automatically create and update Pay::Subscription records and broadcast Turbo Stream redirects. No event callbacks needed.
If you're not using Pay, register callbacks to handle subscription events:
# config/initializers/purchasekit.rb
PurchaseKit.configure do |config|
# ... credentials ...
config.on(:subscription_created) do |event|
user = User.find(event.customer_id)
user.subscriptions.create!(
processor_id: event.subscription_id,
store: event.store,
status: event.status
)
end
config.on(:subscription_canceled) do |event|
subscription = Subscription.find_by(processor_id: event.subscription_id)
subscription&.update!(status: "canceled")
end
config.on(:subscription_expired) do |event|
subscription = Subscription.find_by(processor_id: event.subscription_id)
subscription&.update!(status: "expired")
end
end| Event | Description |
|---|---|
:subscription_created |
New subscription started |
:subscription_updated |
Subscription renewed or plan changed |
:subscription_canceled |
User canceled (still active until ends_at) |
:subscription_expired |
Subscription ended |
| Method | Description |
|---|---|
event.event_id |
Unique event identifier (for idempotency) |
event.customer_id |
Your user ID |
event.subscription_id |
Store's subscription ID |
event.store |
"apple" or "google" |
event.store_product_id |
e.g., "com.example.pro.annual" |
event.google_base_plan_id |
Google Play base plan ID (e.g., "annual") when using umbrella subscriptions, nil otherwise |
event.status |
"active", "canceled", "expired" |
event.current_period_start |
Start of billing period |
event.current_period_end |
End of billing period |
event.ends_at |
When subscription will end |
event.success_path |
Redirect path after purchase |
Webhooks may be delivered more than once. Write idempotent callbacks using find_or_create_by or check event.event_id to avoid duplicate side effects.
Build a paywall using the included helper. Subscribe to a Turbo Stream channel for real-time redirects after purchase. The customer_id you pass flows through the store and back to your webhook handler, so it must match what the handler expects.
Pass the Pay::Customer.id (not your user ID), and subscribe to the Pay customer's dom_id channel:
<% pay_customer = current_user.set_payment_processor(:purchasekit) %>
<%= turbo_stream_from dom_id(pay_customer) %>
<%= purchasekit_paywall customer_id: pay_customer.id, success_path: dashboard_path do |paywall| %>
<%= paywall.plan_option product: @annual, selected: true do %>
Annual - <%= paywall.price %>/year
<% end %>
<%= paywall.plan_option product: @monthly do %>
Monthly - <%= paywall.price %>/month
<% end %>
<%= paywall.submit "Subscribe" %>
<%= paywall.restore url: restore_purchases_path, class: "btn btn-link" %>
<% end %>set_payment_processor(:purchasekit) finds or creates the Pay::Customer row. Calling it on every paywall render guarantees the row exists before the webhook tries to look it up.
Use your own user ID for both the customer_id and the Turbo Stream channel:
<%= turbo_stream_from "purchasekit_customer_#{current_user.id}" %>
<%= purchasekit_paywall customer_id: current_user.id, success_path: dashboard_path do |paywall| %>
<%= paywall.plan_option product: @annual, selected: true do %>
Annual - <%= paywall.price %>/year
<% end %>
<%= paywall.plan_option product: @monthly do %>
Monthly - <%= paywall.price %>/month
<% end %>
<%= paywall.submit "Subscribe" %>
<%= paywall.restore url: restore_purchases_path, class: "btn btn-link" %>
<% end %>Apple requires apps with in-app purchases to include a "Restore purchases" button. This handles users who switch devices or reinstall the app.
The paywall.restore helper renders a button that reads active subscriptions directly from StoreKit (iOS) or Play Billing (Android) via the native bridge. Pass a url: to automatically POST the subscription IDs to your server:
<%= paywall.restore url: restore_purchases_path, class: "btn btn-link" %>When the user taps restore, the JS controller sends a bridge message to the native app, receives the active subscription IDs, and POSTs them as JSON to your URL. If the server responds with a redirect, the page navigates automatically.
On the server, match the IDs against your stored subscriptions. The subscription_ids match the subscription_id field in PurchaseKit webhook payloads (Apple's originalTransactionId, Google's order ID):
# routes.rb
post "restore_purchases", to: "subscriptions#restore"
# subscriptions_controller.rb
def restore
ids = params[:subscription_ids] || []
if ids.any? && current_user.subscriptions.where(processor_id: ids).active.any?
redirect_to dashboard_path, notice: "Your subscription is active."
else
redirect_to paywall_path, alert: "No active subscription found."
end
endIf you need custom behavior, omit the url: and listen for the DOM event instead:
document.addEventListener("purchasekit--paywall:restore", (event) => {
const { subscriptionIds, error } = event.detail
// Handle as needed
})Products are fetched from the PurchaseKit API:
@annual = PurchaseKit::Product.find("prod_XXXXXXXX")
@monthly = PurchaseKit::Product.find("prod_YYYYYYYY")When a user switches base plans within one umbrella subscription on Google Play (for example monthly to annual), the Android bridge passes the existing purchase token so the swap goes through instead of being rejected. Set the proration policy with proration_mode: (defaults to charge_prorated_price):
<%= purchasekit_paywall customer_id: ..., success_path: ..., proration_mode: "with_time_proration" do |paywall| %>
...
<% end %>Accepted values: charge_prorated_price, with_time_proration, charge_full_price, without_proration, deferred. Apple handles intra-group upgrades automatically and ignores the option.
The paywall dispatches a DOM event at each step of a purchase, so you can update your own copy. Same pattern as the restore event above:
| Event | Fires when |
|---|---|
purchasekit--paywall:initiated |
The intent is created and the native purchase starts. |
purchasekit--paywall:store-confirmed |
The store confirms the purchase. |
purchasekit--paywall:awaiting-webhook |
Waiting for the webhook to land and redirect. |
purchasekit--paywall:complete |
The redirect fires (Turbo Stream broadcast or 30 second fallback). |
document.addEventListener("purchasekit--paywall:awaiting-webhook", () => {
// update your copy
})For local development without a PurchaseKit account:
PurchaseKit.configure do |config|
config.demo_mode = true
config.demo_products = {
"prod_annual" => { apple_product_id: "com.example.pro.annual" },
"prod_monthly" => { apple_product_id: "com.example.pro.monthly" }
}
endWorks with Xcode's StoreKit local testing.
MIT License. See LICENSE for details.