PurchaseKit
In-app purchase webhooks for Rails. Receive normalized Apple and Google subscription events with a simple callback interface.
How it works
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.
Installation
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)Pay gem integration
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.
Event callbacks (without Pay)
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
endAvailable events
| 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 |
Event payload
| 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.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 |
Idempotency
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.
Paywall helper
Build a paywall using the included helper. Subscribe to the Turbo Stream for real-time redirects:
<%= 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" %>
<% end %>
<%= button_to "Restore purchases", restore_purchases_path %>The restore link checks your server for an active subscription. Implement the endpoint in your app:
# routes.rb
post "restore_purchases", to: "subscriptions#restore"
# subscriptions_controller.rb
def restore
if current_user.subscribed?
redirect_to dashboard_path, notice: "Your subscription is active."
else
redirect_to paywall_path, alert: "No active subscription found."
end
endProducts are fetched from the PurchaseKit API:
@annual = PurchaseKit::Product.find("prod_XXXXXXXX")
@monthly = PurchaseKit::Product.find("prod_YYYYYYYY")Demo mode
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.
License
MIT License. See LICENSE for details.