bext.dev
DocsApplication Toolkit
Application Toolkit3 min read
On this page

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#

ts
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:

ts
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).

Tip

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#

Edit this page ↗Need a hand? ↗
FIND YOUR NEXT STEP

Start with a topic, a command, or a question.