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)
end

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

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

With Pay

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.

Without Pay

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

Restore purchases

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
end

If 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")

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" }
  }
end

Works with Xcode's StoreKit local testing.

License

MIT License. See LICENSE for details.