@beignet/provider-payments-stripe
Stripe-backed payments provider for Beignet applications.
This package adapts Stripe to the provider-neutral PaymentsPort from
@beignet/core/payments. Use it for hosted checkout, billing portal sessions,
refunds, and verified webhook ingestion. Keep plans, prices, subscriptions,
and billing authorization in your app's features/billing feature. Use
@beignet/core/entitlements for the app-facing product access decision shape.
For non-payment Stripe events, use @beignet/provider-webhooks-stripe with
the generic @beignet/core/webhooks primitive instead.
Install
bun add @beignet/provider-payments-stripe stripe @beignet/coreConfigure
Set the server-side Stripe environment variables:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_PUBLISHABLE_KEY is optional and exposed only through the Stripe escape
hatch. Never send STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET to the browser.
beignet doctor --strict checks that installed Stripe payments providers are
registered in server/providers.ts and that STRIPE_SECRET_KEY and
STRIPE_WEBHOOK_SECRET are present in app env examples or config.
Wire the provider
// server/providers.ts
import { stripePaymentsProvider } from "@beignet/provider-payments-stripe";
export const providers = [
// other providers...
stripePaymentsProvider,
] as const;Add the payments port to your app ports:
// ports/index.ts
import type { PaymentsPort } from "@beignet/core/payments";
export type AppPorts = {
payments: PaymentsPort;
// other ports...
};If the provider contributes the port at startup, list payments as deferred in
infra/app-ports.ts.
The provider also exposes ctx.ports.stripe.client as an escape hatch for
Stripe operations the stable Beignet port does not model.
The provider contributes:
ctx.ports.payments, the standard BeignetPaymentsPortctx.ports.stripe, an escape hatch with the Stripe client and publishable key
For a full app-owned billing slice, start with beignet make payments, run
beignet db generate and beignet db migrate, then swap the generated local
memory payments provider to stripePaymentsProvider when Stripe env vars are
configured.
Cut over from memory payments
Generated billing slices use memory payments for local development. Before using Stripe in a deployed app:
- Create the Stripe product and price, then set your app's billing price env
var, such as
BILLING_TEAM_PRICE_ID. - Set
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, and optionalSTRIPE_PUBLISHABLE_KEYfor the target environment. - Replace
createMemoryPaymentsProvider()inserver/providers.tswithstripePaymentsProvider. - Deploy billing migrations before accepting live webhook traffic.
- Configure the Stripe webhook endpoint at
/api/webhooks/paymentsand subscribe to checkout, subscription, and invoice events. - Run
bun beignet doctor --strictand smoke a test-mode checkout.
See https://beignetjs.com/payments for the full cutover and webhook operations guide.
Create checkout
import { requireTenant } from "@beignet/core/ports";
import { env } from "@/lib/env";
import { useCase } from "@/lib/use-case";
export const createCheckoutSession = useCase
.command("billing.createCheckoutSession")
.run(async ({ ctx }) => {
const tenant = requireTenant(ctx);
return ctx.ports.payments.createCheckoutSession({
mode: "subscription",
lineItems: [{ priceId: "price_pro" }],
successUrl: `${env.APP_URL}/billing/success`,
cancelUrl: `${env.APP_URL}/billing`,
clientReferenceId: tenant.id,
metadata: { tenantId: tenant.id },
});
});Treat the checkout URL as the start of a provider workflow, not fulfillment. Grant access from verified webhooks after the provider confirms payment or subscription state.
Avoid tenant-wide provider idempotency keys for checkout creation. Use Beignet HTTP idempotency on the route, or pass a per-request provider idempotency key when your app explicitly carries one through to the use case.
Verify webhooks
Webhook routes must read the raw body before parsing JSON so Stripe can verify
the signature. In Next.js apps, use createPaymentWebhookRoute(...) from
@beignet/next:
// app/api/webhooks/payments/route.ts
import { createPaymentWebhookRoute } from "@beignet/next";
import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
import { server } from "@/server";
export const runtime = "nodejs";
export const { POST } = createPaymentWebhookRoute({
server,
handle: async ({ ctx, event }) => {
await handlePaymentWebhookUseCase.run({ ctx, input: event });
return {
status: 200,
body: { received: true },
};
},
});This is the canonical route for Beignet billing flows. It calls
ctx.ports.payments.verifyWebhook(...) and passes your handler a normalized
PaymentWebhookEvent; you do not need a defineWebhook(...) catalog or
@beignet/provider-webhooks-stripe for the generated payments slice.
The helper defaults to Stripe's stripe-signature header. The billing use case
should key idempotency on the provider event ID, update app-owned billing state
in a Unit of Work, record domain events, and use outbox/jobs/notifications for
post-commit effects.
Valid but unhandled Stripe event types should usually be acknowledged as no-ops after verification. Handler exceptions return 500 from the route helper so Stripe can retry; invalid signatures and malformed payloads return 400.
For local webhook testing, forward Stripe CLI events to the same route:
stripe listen --forward-to http://localhost:3000/api/webhooks/paymentsSet STRIPE_WEBHOOK_SECRET to the whsec_... printed by that command for the
local app process. Dashboard endpoint secrets, live-mode secrets, and local
Stripe CLI secrets are different values.
Instrumentation
The provider records custom provider events through the payments watcher:
payments.checkout.createpayments.checkout.createdpayments.checkout.failedpayments.portal.createpayments.portal.createdpayments.portal.failedpayments.refund.createpayments.refund.createdpayments.refund.failedpayments.webhook.verifiedpayments.webhook.failed
Devtools displays these in the first-class payments view.
Failure behavior
Stripe API failures throw provider errors from the corresponding payments port
operation. Invalid webhook signatures and malformed webhook payloads return 400
from createPaymentWebhookRoute(...); handler failures return 500 so Stripe can
retry. Verified but unhandled Stripe events should usually be acknowledged
after app code decides they are not relevant.
Local and tests
Generated billing slices start with the memory payments provider from
@beignet/core/payments so local development and tests do not need Stripe
credentials. Queue memory webhook events in tests and switch
server/providers.ts to stripePaymentsProvider only when the target
environment has STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
Deployment notes
- Deploy billing migrations before accepting live Stripe webhooks.
- Keep test-mode and live-mode secrets separate.
- Use the endpoint-specific
whsec_...value for each deployed route or local Stripe CLI session. - Keep long-running fulfillment behind Beignet jobs, outbox delivery, or listeners after the verified webhook is recorded.
- Use
beignet doctor --strictas part of cutover so memory payments wiring and missing Stripe env/config are caught before deployment.