Build an HTML paywall that triggers native in-app purchases.
Fetch products in your controller:
class PaywallsController < ApplicationController
def show
@annual = PurchaseKit::Product.find("prod_XXXXXXXX")
@monthly = PurchaseKit::Product.find("prod_YYYYYYYY")
end
end
You'll find product IDs in the PurchaseKit dashboard under Apps → [Your App] → Products.
<%= turbo_stream_from current_user.payment_processor %>
<%= purchasekit_paywall customer_id: current_user.payment_processor.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 %>
<%= turbo_stream_from current_user.payment_processor %>
<%= purchasekit_paywall customer_id: current_user.payment_processor.id, success_path: dashboard_path do |paywall| %>
<div class="d-flex flex-column gap-2 mb-3">
<%= paywall.plan_option product: @annual, selected: true, class: "btn btn-outline-primary text-start" do %>
<strong>Annual</strong>
<span class="text-muted"><%= paywall.price %>/year</span>
<% end %>
<%= paywall.plan_option product: @monthly, class: "btn btn-outline-primary text-start" do %>
<strong>Monthly</strong>
<span class="text-muted"><%= paywall.price %>/month</span>
<% end %>
</div>
<%= paywall.submit "Subscribe", class: "btn btn-primary w-100" %>
<div class="text-center mt-2">
<%= button_to "Restore purchases", restore_purchases_path, class: "btn btn-link link-secondary small p-0" %>
</div>
<% end %>
| Option | Description |
|---|---|
customer_id: |
Required. Your Pay::Customer ID (use current_user.payment_processor.id) |
success_path: |
Where to redirect after purchase (defaults to root_path) |
proration_mode: |
Google Play replacement mode for swapping base plans within one umbrella subscription, e.g. monthly to annual. Defaults to charge_prorated_price. Ignored on Apple. See Android subscriptions. |
| 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
The paywall dispatches a Stimulus event at each step of a purchase, so you can swap your own copy without guessing the timing. The events bubble, so listening on a wrapper element is enough.
| Event | Fires when |
|---|---|
purchasekit--paywall:initiated |
The purchase intent is created and the native purchase is about to start. |
purchasekit--paywall:store-confirmed |
The store confirms the purchase. |
purchasekit--paywall:awaiting-webhook |
The form is disabled, waiting for the webhook to land and redirect. |
purchasekit--paywall:complete |
The redirect fires, from either the Turbo Stream broadcast or the 30 second fallback. |
Wrap the paywall in a small controller and listen:
<div data-controller="purchase-status">
<span data-status>Subscribe to continue</span>
<%= purchasekit_paywall customer_id: ..., success_path: ... do |paywall| %>
...
<% end %>
</div>
// app/javascript/controllers/purchase_status_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.addEventListener("purchasekit--paywall:initiated", this.#confirming)
this.element.addEventListener("purchasekit--paywall:complete", this.#done)
}
#confirming = () => this.#status("Confirming your purchase...")
#done = () => this.#status("All set, taking you in...")
#status(text) { this.element.querySelector("[data-status]").textContent = text }
}
The gem handles these events automatically:
| Event | Action |
|---|---|
subscription.created |
Creates Pay::Subscription, redirects user |
subscription.updated |
Updates status and period dates |
subscription.canceled |
Marks subscription as canceled |
subscription.expired |
Marks subscription as expired |
Add the native package to your Hotwire Native app: