Use the purchasekit gem without Pay for custom subscription handling.
This guide is for apps that don't use the Pay gem. If you use Pay, see Setup with Pay instead.
Add the gem to your Gemfile:
gem "purchasekit"
Run bundle install:
bundle install
Create an initializer at 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)
end
Add your credentials:
bin/rails credentials:edit
purchasekit:
api_key: sk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
app_id: app_XXXXXXXX
webhook_secret: whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
You'll find these values in the PurchaseKit dashboard under Account → Developer.
For local development, you can use demo mode without PurchaseKit credentials:
PurchaseKit.configure do |config|
config.demo_mode = Rails.application.credentials.dig(:purchasekit, :api_key).blank?
# ... credentials
end
Demo mode simulates the purchase flow locally. Disable it in production by setting credentials.
Add the engine to your routes:
# config/routes.rb
Rails.application.routes.draw do
mount PurchaseKit::Engine, at: "/purchasekit"
# ...
end
This adds:
- /purchasekit/webhooks - Receives webhooks from PurchaseKit
- /purchasekit/purchases - Creates purchase intents for the native app
Import the Turbo Stream actions for real-time redirects after purchase:
// app/javascript/application.js
import "purchasekit/turbo_actions"
Register the gem's Stimulus controllers:
// app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eagerLoadControllersFrom("purchasekit", application)
Register callbacks to handle subscription events. These fire when webhooks are received:
# 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)
config.on(:subscription_created) do |event|
user = User.find(event.customer_id)
user.subscriptions.create!(
processor_id: event.subscription_id,
store: event.store,
store_product_id: event.store_product_id,
status: event.status,
current_period_end: event.current_period_end
)
end
config.on(:subscription_canceled) do |event|
subscription = Subscription.find_by(processor_id: event.subscription_id)
subscription&.update!(status: "canceled", ends_at: event.ends_at)
end
config.on(:subscription_expired) do |event|
subscription = Subscription.find_by(processor_id: event.subscription_id)
subscription&.update!(status: "expired")
end
end
Fetch products in your controller:
class PaywallsController < ApplicationController
def show
@annual = PurchaseKit::Product.find("prod_XXXXXXXX")
@monthly = PurchaseKit::Product.find("prod_YYYYYYYY")
end
end
Render the paywall using the helper. Subscribe to 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 %>
| Option | Description |
|---|---|
customer_id: |
Required. Your user/customer identifier |
success_path: |
Where to redirect after purchase (defaults to root_path) |
| Method | Description |
|---|---|
plan_option(product:, selected:) |
Radio button and label for a plan |
price |
Localized price (must be inside plan_option block) |
submit(text) |
Submit button (disabled until prices load) |
Add a restore link that checks your server for an active subscription:
# config/routes.rb
post "restore_purchases", to: "subscriptions#restore"
# app/controllers/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
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 |
All events provide these methods:
| Method | Description |
|---|---|
event.event_id |
Unique event identifier (for idempotency) |
event.customer_id |
Your user ID (passed when creating purchase intent) |
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 current billing period |
event.current_period_end |
End of current billing period |
event.ends_at |
When subscription will end (if canceled) |
event.success_path |
Redirect path (for subscription_created) |
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.
Add the native package to your Hotwire Native app: