Standalone setup

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.

Installation

Add the gem to your Gemfile:

gem "purchasekit"

Run bundle install:

bundle install

Configuration

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.

Demo mode

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.

Mount the engine

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

JavaScript setup

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)

Event callbacks

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

Build your paywall

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 %>

Paywall helper options

Option Description
customer_id: Required. Your user/customer identifier
success_path: Where to redirect after purchase (defaults to root_path)

Builder methods

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)

Restore purchases

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

Available 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

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)

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.

Next step

Add the native package to your Hotwire Native app: