Billing
@bext-stack/framework/billing is the reusable Stripe layer bext sites kept
re-implementing per-site — a Cashier-style facade over the Rust
Payment Providers capability. It's a thin, typed
wrapper over the Stripe REST API (no stripe SDK — just fetch and form
encoding) plus the security-critical piece everyone gets wrong: webhook
signature verification.
When To Use It#
- Start a subscription or one-off checkout.
- Send a customer to the Stripe billing portal.
- Gate features on an active subscription.
- Verify incoming Stripe webhooks safely.
Checkout & subscriptions#
import { createBilling, subscribed } from "@bext-stack/framework/billing";
const billing = createBilling({ secretKey: process.env.STRIPE_SECRET_KEY });
// action — start a subscription checkout, redirect to the returned url:
const { url } = await billing.checkoutSession({
priceId: "price_pro", mode: "subscription",
successUrl: "https://app/ok", cancelUrl: "https://app/cancel",
customer: "cus_1", // or customerEmail for a new customer
});
// gate features on an active subscription:
const subs = await billing.listSubscriptions({ customer: "cus_1" });
if (subscribed(subs)) { /* unlock */ }
await billing.cancelSubscription("sub_1", { atPeriodEnd: true });
const { url: portal } = await billing.portalSession({ customer: "cus_1", returnUrl: "https://app/account" });
| Member | Does |
|---|---|
createCustomer({ email, name?, metadata? }) |
create a Stripe customer |
checkoutSession({ priceId, mode?, successUrl, cancelUrl, customer? | customerEmail? }) |
a Checkout Session (.url to redirect to) |
portalSession({ customer, returnUrl }) |
a billing-portal session url |
listSubscriptions({ customer, status? }) |
the customer's subscriptions |
cancelSubscription(id, { atPeriodEnd? }) |
cancel now (DELETE) or at period end |
raw(method, path, form?) |
any Stripe endpoint |
subscribed(subs, { statuses? }) |
is any subscription entitling (active/trialing) |
Webhooks — verify the signature#
Never trust a webhook body without verifying it. constructEvent reconstructs
${timestamp}.${payload}, HMAC-SHA256s it (hex), constant-time compares to
the header's v1, and enforces a replay tolerance:
import { constructEvent } from "@bext-stack/framework/billing";
export async function POST({ request }: { request: Request }) {
const body = await request.text(); // the RAW body — verify before parsing
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 the subscription active */ }
return new Response(null, { status: 200 });
}
verifyStripeSignature(payload, header, secret, toleranceSecs?) is the boolean
form; signStripePayload(payload, secret, ts) builds a valid header (for tests /
mocking a sender).
The HTTP transport is injectable: pass secretKey for real Stripe, or a
request mock for tests — so your billing code is unit-testable with no network
and no keys, and webhook verification is testable with signStripePayload.
Relationship to the Payment Providers capability#
The Rust Payment Providers capability is the server-side provider seam. This module is the in-isolate TypeScript client a PRISM app calls directly for the common Stripe flows — one reusable package instead of per-site glue.
Try It#
The live billing demo runs the
subscribe → active → cancel lifecycle over a mocked Stripe transport (state
persisted via the ORM), and shows a valid vs. tampered webhook
signature being accepted / rejected live. Source in
sites/demo/src/app/examples/billing/page.tsx.
See Also#
- Payment Providers — the Rust capability this complements.
- Data & Migrations — where subscription state persists.
- Authentication — the HMAC signer this reuses.
- Application Toolkit — the rest of the TypeScript app layer.