Billing & subscriptions

createBilling wraps the Stripe API (checkout, portal, subscriptions) with no SDK — just fetch — plus webhook signature verification, the critical piece everyone gets wrong. Here the Stripe transport is mocked (no keys needed); the subscription state is really persisted via the ORM.

Tip
The HTTP transport is injectable: pass secretKey for real Stripe, or a mock for tests. constructEvent() reconstructs ${t}.${payload}, hex-HMAC-SHA256s it, constant-time compares to the header's v1, and enforces a replay window — the webhook protection Cashier gives you.

Status

✓ subscribed — plan Pro

Webhook signature verification

Stripe-Signature (t, v1)valid signature: accepted ✓
Stripe-Signature (altered v1)tampered signature: rejected ✗
src/app/examples/billing/page.tsxTSX
import { createBilling, subscribed, constructEvent } from "@bext-stack/framework/billing";

const billing = createBilling({ secretKey: process.env.STRIPE_SECRET_KEY });

// start a subscription checkout:
const { url } = await billing.checkoutSession({
  priceId: "price_pro", mode: "subscription",
  successUrl: "/ok", cancelUrl: "/cancel", customer: "cus_1",
});  // redirect the user to `url`

// gate features on an active subscription:
const subs = await billing.listSubscriptions({ customer: "cus_1" });
if (subscribed(subs)) { /* unlock */ }

// webhook route — verify the signature (constant-time HMAC + replay window):
export async function POST({ request }) {
  const body = await request.text();
  const event = constructEvent(body, request.headers.get("stripe-signature"), process.env.STRIPE_WEBHOOK_SECRET);
  if (!event) return new Response("bad signature", { status: 400 });
  if (event.type === "checkout.session.completed") { /* mark active */ }
  return new Response(null, { status: 200 });
}