Keel Billing is a subscription-billing layer for charging customers, managing subscriptions, and reconciling gateway state through webhooks. It ships as a Keel package and supports two gateways behind one API: Stripe and Paddle.
It attaches to a model with a mixin. Your User becomes billable, gains a
gateway customer, and can create subscriptions and charges:
import { Model } from "@shaferllc/keel/core";
import { Billable } from "@shaferllc/keel/billing";
export class User extends Billable(Model) {
static table = "users";
declare email: string;
}
To charge teams instead, set billableModel: "Team" and billableTable: "teams"
in config/billing.ts (the provider's migration adds billing columns to that table).
The saas starter kit does this.
Install
// bootstrap/providers.ts
import { BillingServiceProvider } from "@shaferllc/keel/billing";
export const providers = [AppServiceProvider, BillingServiceProvider];
Publish the config and create the tables:
keel vendor:publish --tag billing-config # writes config/billing.ts
keel migrate # creates subscriptions + subscription_items,
# and adds billing columns to users
Set your keys in .env:
BILLING_GATEWAY=stripe # or "paddle"
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Paddle:
PADDLE_API_KEY=...
PADDLE_WEBHOOK_SECRET=...
PADDLE_CLIENT_TOKEN=...
PADDLE_SANDBOX=true
One API, two gateways
Everything you call goes through a gateway-neutral interface, so switching from
Stripe to Paddle is a config change. The active gateway comes from
config("billing.default"); a billable can also carry its own in
billing_gateway. Money is always an integer in the smallest currency unit
(cents). See Gateway differences for where Paddle's
merchant-of-record model diverges.
Customers
A gateway customer is created lazily the first time you need one, but you can create it up front:
await user.createAsCustomer(); // creates the customer, stores its id
user.hasBillingId(); // true
await user.getCustomerId(); // the id (creates if missing)
Override what gets synced by defining billingName() / billingEmail() on your
model. By default they read name / email.
Subscriptions
Build a subscription with the fluent builder:
await user
.newSubscription("default", "price_pro")
.trialDays(14)
.quantity(3)
.create(paymentMethodId); // paymentMethodId optional if a default is on file
Multiple prices (add-ons) are an array; withMetadata, trialUntil, and
skipTrial are also available. To send the customer to a hosted checkout
instead of charging now, swap .create() for .checkout():
const session = await user
.newSubscription("default", "price_pro")
.checkout({ successUrl: "...", cancelUrl: "..." });
// Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken.
Status
Status questions are answered from local columns — no gateway round-trip:
await user.subscribed(); // valid (active | trial | grace)
await user.subscribedToPrice("price_pro");
await user.onTrial();
const sub = await user.subscription(); // the "default" subscription, or null
sub.active(); sub.onTrial(); sub.recurring();
sub.canceled(); sub.onGracePeriod(); sub.ended();
sub.paused(); sub.valid(); sub.hasIncompletePayment();
Changing a subscription
await sub.swap("price_enterprise"); // change price(s)
await sub.updateQuantity(10);
await sub.incrementQuantity(2);
await sub.decrementQuantity();
Each of these calls the gateway and syncs the result back into the local row.
Cancelling
await sub.cancel(); // at period end — access continues through the grace period
await sub.onGracePeriod();// true
await sub.resume(); // revive a subscription still in its grace period
await sub.cancelNow(); // immediately; sub.ended() becomes true
Trials
await sub.endTrial();
await sub.extendTrial(new Date("2026-01-01"));
user.onGenericTrial(); // a trial_ends_at on the user, before any subscription
Single charges
const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" });
await user.refund(charge.id); // full refund
await user.refund(charge.id, 500); // partial
const session = await user.checkout("price_onetime", { successUrl, cancelUrl });
Payment methods (Stripe)
Collect a card with a SetupIntent, then create the subscription with the resulting payment method:
const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end
const methods = await user.paymentMethods();
Customer portal (Stripe)
Send the customer to Stripe's hosted portal to update their card or cancel:
const portal = await user.billingPortal("https://app.example.com/billing");
// redirect to portal.url
These are Stripe-only capabilities; calling them on the Paddle gateway throws a
BillingError (Paddle collects cards in its own hosted checkout).
Invoices
const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url
Webhooks
The package mounts one webhook endpoint per gateway at
config("billing.webhook.path"):
POST /billing/webhook/stripe
POST /billing/webhook/paddle
Point your gateway dashboard at the matching URL. Each request is verified against the gateway's signing secret (HMAC-SHA256 over the raw body), the local subscription is synced, and typed events fire:
import { listen } from "@shaferllc/keel/core";
listen("billing.subscription.updated", (e) => {
// e.gateway, e.subscriptionId, e.providerId, e.status
});
listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ });
Events: billing.webhook.received, billing.subscription.created / .updated
/ .deleted.
An update to a subscription already in your database is always synced. A brand new subscription born from a Paddle checkout has no local row yet — register a resolver so the handler can create it:
import { resolveBillableUsing } from "@shaferllc/keel/billing";
resolveBillableUsing(async (customerId) => {
const user = (await User.query().where("billing_customer_id", customerId).first());
return user ? { id: user.id, type: "User" } : null;
});
A complete flow
From "user signs up" to "they're subscribed", with the fake gateway for tests:
import { Model } from "@shaferllc/keel/core";
import {
Billable,
BillingManager,
FakeGateway,
setBilling,
} from "@shaferllc/keel/billing";
class User extends Billable(Model) {
static table = "users";
declare email: string;
}
// In a test bootstrap:
const fake = new FakeGateway();
const manager = new BillingManager({
default: "fake",
currency: "usd",
billableModel: "User",
billableTable: "users",
webhook: { path: "billing/webhook" },
gateways: { stripe: { key: "", webhookSecret: "" }, paddle: { key: "", webhookSecret: "" }, fake: {} },
});
manager.register("fake", () => fake);
setBilling(manager);
const user = await User.create({ email: "ada@example.com" });
await user.newSubscription("default", "price_pro").trialDays(14).create();
await user.subscribed(); // true
fake.calls.some((c) => c.method === "createSubscription"); // true
In production you skip the fake manager — BillingServiceProvider wires the
real gateway from config/billing.ts and .env.
Gateway differences
| Concern | Stripe | Paddle |
|---|---|---|
| Create a subscription server-side | create(pmId) |
Not supported — use checkout(); the webhook creates the local row |
One-off charge() |
Confirms a PaymentIntent | Not supported — use checkout({ mode }) / transactions |
SetupIntent / paymentMethods() / billingPortal() |
Supported | Throws BillingError (hosted checkout) |
| Checkout handle | session.url (redirect) |
session.clientToken (overlay/inline) |
| Webhook signature | Stripe-Signature: t=…,v1=… |
Paddle-Signature: ts=…;h1=… |
Schema
The migration is gateway-neutral: subscriptions (with gateway,
provider_id, provider_status, provider_price, trial/grace timestamps),
subscription_items, and columns on users (billing_gateway,
billing_customer_id, pm_type, pm_last_four, trial_ends_at). The default
migration targets the standard users billable table.
Testing
The package ships a FakeGateway — an in-memory gateway that records every call
— so you can drive billing without touching a network:
import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing";
const fake = new FakeGateway();
const manager = new BillingManager(config); // config.default = "fake"
manager.register("fake", () => fake);
setBilling(manager);
await user.newSubscription("default", "price_pro").create();
fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked