Subscription System
Subscription System pattern for Ruby.
Recipe for standing up a recurring-billing system on top of
client.products, client.product_prices, and client.subscriptions —
catalog setup, customer signup, mid-cycle upgrades, and cancellation.
Goal
Run a SaaS-style subscription where:
- Catalog is defined once (one
Product, multiplePrices). - Customers sign up to a price; payment instrument is on file.
- Mid-cycle upgrades use proration preview before applying.
- Cancellations honor "end of period" by default.
- Lifecycle webhooks keep your local state in sync.
Implementation
1. Define the catalog (one-time)
client = EasyLabs::Client.new(api_key: ENV.fetch("EASY_API_KEY"))
product = client.products.create(name: "Pro plan")
monthly = client.product_prices.create(
product_id: product[:id],
active: true,
recurring: true,
currency: "USD",
unit_amount: 4_900,
interval: "month",
interval_count: 1,
tax_behavior: "exclusive"
)
annual = client.product_prices.create(
product_id: product[:id],
active: true,
recurring: true,
currency: "USD",
unit_amount: 49_900,
interval: "year",
interval_count: 1,
tax_behavior: "exclusive"
)2. Sign up a customer
customer = client.customers.create(
first_name: "Ada", last_name: "Lovelace", email: "ada@example.com"
)
card = client.payment_instruments.create(
tokenId: token_from_browser,
identityId: customer[:id],
type: "PAYMENT_CARD",
name: "Personal card"
)
sub = client.subscriptions.create(
identity_id: customer[:id],
items: [{ price_id: monthly[:id] }],
instrument_id: card[:id]
)3. Preview, then upgrade
preview = client.subscriptions.proration_preview(
sub[:id],
items: [{ price_id: annual[:id], quantity: 1 }]
)
if preview[:total] <= max_charge_cents
client.subscriptions.update(sub[:id], items: [{ price_id: annual[:id] }])
end4. Cancel at period end
client.subscriptions.cancel(sub[:id], at_period_end: true)5. Sync state from webhooks
post "/webhooks/easy" do
event = EasyLabs::Webhooks.construct_event(
payload: request.body.read,
signature: request.headers["X-Easy-Webhook-Signature"],
secret: ENV.fetch("EASY_WEBHOOK_SECRET")
)
data = event[:data]
case event[:type]
when "subscription.created", "subscription.updated"
Subscription.upsert_by_easy_id(data)
when "subscription.deleted"
Subscription.find_by(easy_id: data[:id])&.update!(state: "canceled")
when "subscription.paused"
Subscription.find_by(easy_id: data[:id])&.update!(state: "paused")
when "subscription.resumed"
Subscription.find_by(easy_id: data[:id])&.update!(state: "active")
when "invoice.paid"
Invoice.upsert_by_easy_id(data)
when "invoice.payment_failed"
DunningMailer.with(invoice: data).failed.deliver_later
end
status 204
endTradeoffs
- Always preview prorations.
proration_previewis cheap and lets you show the customer (and verify against your business rules) what an upgrade actually costs before you charge them. - Cancel at period end vs. immediately. Defaulting to
at_period_end: trueis friendlier and matches most customers' expectations — passingfalsecancels immediately and forfeits the remainder of the period. - Source of truth. Treat the Easy API as the source of truth for subscription state and use webhooks to keep your local cache fresh. Avoid recording subscription state synchronously from the create call — the create response and a subsequent webhook can race.
- Metered billing. For usage-based pricing, layer
report_usagecalls into your existing event pipeline rather than doing it inline at request time. See Subscriptions › Metered usage.