Integrate subscriptions and payments
This guide wires one payment provider into Bursar, creates a hosted checkout, and sends the provider's raw webhook request through signature verification.
Prerequisites
- Follow multi-tenancy to provision a tenant and separate migration and runtime database credentials.
- Publish a validated configuration whose
commercesection declares your provider and offers. - Read billing concepts for offer and subscription semantics.
- Create provider products and prices, register a webhook endpoint, and store the API and webhook secrets on the server.
Install the provider SDK alongside Bursar:
- Python
- TypeScript
python -m pip install "bursar[postgres,stripe]"
npm install @zonastery/bursar pg stripe
:::important Provider catalog ownership
Bursar does not create or synchronize products and prices in Stripe, Dodo, or another provider. Your embedding application owns that catalog. Every provider identifier in the active Bursar config must already exist in the same provider environment as the API key.
:::
1. Construct billing and commerce
Create both tenant-bound stores, register a lazy provider factory, then require
the commerce capability once. commerce is nullable on an unconfigured
facade; require_commerce() and requireCommerce() turn a configuration
mistake into a typed error at startup.
- Python
- TypeScript
import os
import stripe
from bursar import Bursar, CommerceOptions, PostgresBillingStore, PostgresStore
from bursar.providers import StripeProvider
stripe_client = stripe.StripeClient(os.environ["STRIPE_SECRET_KEY"])
provider_environment = "test"
credit_store = PostgresStore(
database_url,
tenant_id=tenant_id,
provider_environment=provider_environment,
)
billing_store = PostgresBillingStore(
database_url,
tenant_id=tenant_id,
provider_environment=provider_environment,
)
bursar = Bursar(
credit_store=credit_store,
billing_store=billing_store,
commerce_options=CommerceOptions(
tenant_id=tenant_id,
provider_environment=provider_environment,
default_provider="stripe",
providers={
"stripe": lambda context: StripeProvider(
get_client=lambda: stripe_client,
webhook_secret=os.environ["STRIPE_WEBHOOK_SECRET"],
event_sink=context.event_sink,
),
},
),
)
commerce = bursar.require_commerce()
import { Bursar, PostgresBillingStore, PostgresStore } from "@zonastery/bursar";
import { StripeProvider } from "@zonastery/bursar/providers/stripe";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const providerEnvironment = "test" as const;
const creditStore = new PostgresStore({
postgres: databaseUrl,
tenantId,
providerEnvironment,
});
const billingStore = new PostgresBillingStore({
postgres: databaseUrl,
tenantId,
providerEnvironment,
});
const bursar = new Bursar({
creditStore,
billingStore,
commerceOptions: {
tenantId,
providerEnvironment,
defaultProvider: "stripe",
providers: {
stripe: (context) =>
new StripeProvider({
getClient: () => stripe,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
eventSink: context.eventSink,
}),
},
},
});
const commerce = bursar.requireCommerce();
The provider factory is lazy and receives Bursar's event sink. Bursar-created
checkouts attach the trusted financial account as bursar_account_id; later
events that do not repeat that metadata are reconciled through persisted
customer, subscription, and payment references.
Use test for test-mode provider credentials, live for production
credentials, and sandbox for a provider sandbox distinct from test mode.
Credit, billing, and commerce objects in one facade must use the same value.
Use Dodo instead
Install dodopayments in TypeScript or use
python -m pip install "bursar[postgres,dodo]", then replace the Stripe client
and factory:
- Python
- TypeScript
from typing import Literal
from dodopayments import AsyncDodoPayments
from bursar.providers import DodoProvider
dodo_environment: Literal["live_mode", "test_mode"] = (
"live_mode"
if os.environ.get("DODO_PAYMENTS_ENVIRONMENT") == "live_mode"
else "test_mode"
)
dodo = AsyncDodoPayments(
bearer_token=os.environ["DODO_PAYMENTS_API_KEY"],
environment=dodo_environment,
)
def dodo_factory(context):
return DodoProvider(
get_client=lambda: dodo,
webhook_key=os.environ["DODO_PAYMENTS_WEBHOOK_KEY"],
setup_product_id=os.environ["DODO_SETUP_PRODUCT_ID"],
event_sink=context.event_sink,
)
import type { CommerceProviderFactory } from "@zonastery/bursar";
import { DodoProvider } from "@zonastery/bursar/providers/dodo";
import DodoPayments from "dodopayments";
const dodo = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
environment:
process.env.DODO_PAYMENTS_ENVIRONMENT === "live_mode"
? "live_mode"
: "test_mode",
});
const dodoFactory: CommerceProviderFactory = (context) =>
new DodoProvider({
getClient: () => dodo,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY!,
setupProductId: process.env.DODO_SETUP_PRODUCT_ID!,
eventSink: context.eventSink,
});
Register that factory under dodo, set default_provider="dodo" or
defaultProvider: "dodo", and install dodopayments instead of stripe.
DODO_SETUP_PRODUCT_ID is the Dodo subscription product your application
chooses for mandate-only payment-method setup when an account has no active
subscription. Product creation and synchronization remain application-owned.
2. Create checkout from authenticated server code
subject_id/subjectId is the authenticated member or actor authorizing the
checkout. account_id/accountId is the financial subject receiving the
subscription or credits, so it may identify that member's team. The two values
may be equal for a personal account, but they are not aliases. Derive the actor
from the verified server session or token, resolve and authorize the account
through your trusted application mapping, and do not trust either identifier
directly from the browser.
- Python
- TypeScript
from bursar import CreateCheckoutInput
checkout = await commerce.create_checkout(
CreateCheckoutInput(
subject_id=actor_id,
account_id=account_id,
offer_key="pro_monthly",
return_url="https://app.example.com/billing/success",
cancel_url="https://app.example.com/billing",
operation_key=f"checkout:{request_id}",
)
)
return {"checkout_url": checkout.url}
const checkout = await commerce.createCheckout({
subjectId: actorId,
accountId,
offerKey: "pro_monthly",
returnUrl: "https://app.example.com/billing/success",
cancelUrl: "https://app.example.com/billing",
operationKey: `checkout:${requestId}`,
});
return Response.json({ checkoutUrl: checkout.url });
Treat the return URL as navigation only. Credits and subscription access change after a verified provider webhook, not when a browser reaches that URL.
3. Verify and ingest raw webhooks
Pass the exact raw body and request headers to commerce before parsing JSON. The selected provider adapter verifies the signature, normalizes the event, and submits it through Bursar's idempotent billing event sink.
- Python
- TypeScript
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
result = await commerce.handle_webhook(
provider="stripe",
raw_body=(await request.body()).decode("utf-8"),
headers=dict(request.headers),
)
if not result.received:
status = 503 if result.retryable else 400
raise HTTPException(status_code=status, detail="Webhook rejected")
return {"received": True}
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const result = await commerce.handleWebhook({
provider: "stripe",
rawBody: await request.text(),
headers: Object.fromEntries(request.headers.entries()),
});
const status = result.received ? 200 : result.retryable ? 503 : 400;
return Response.json({ received: result.received }, { status });
}
Never call ingest_billing_event or ingestBillingEvent directly from a
public webhook route. Those methods accept an already trusted normalized
event and exist for verified custom adapters.
For Dodo on Next.js, use createDodoNextWebhookHandler from
@zonastery/bursar/providers/dodo/nextjs; it composes Bursar with Dodo's
official @dodopayments/nextjs webhook adapter. For Stripe, the provider calls
the official SDK's constructEvent/construct_event API. Stripe does not ship
a separate first-party Next.js webhook adapter, so preserving the raw request
body is the framework integration boundary—do not reimplement signature
verification in application code.
The normalized constants use each language's public naming convention:
Python uses BillingEventType.subscription_created and
BillingEventType.subscription_renewed; TypeScript uses
BillingEventType.SUBSCRIPTION_CREATED and
BillingEventType.SUBSCRIPTION_RENEWED.
Subscription lifecycle
A subscription-created, activated, or plan-changed event in a positive state assigns the offer's plan. Cancellation, expiry, pause, and customer deletion remove that assignment or move the account to the configured terminal plan.
A renewal or successful subscription invoice grants the offer's cycle credits.
The provider event identifier makes the grant replay-safe. A
replace_previous renewal expires the previous cycle's remainder before the
new grant lands.
Auto-recharge after deduction
When commerce is configured, every deduction runs the configured auto-recharge
check. Users opt in through commerce.auto_recharge in Python or
commerce.autoRecharge in TypeScript. The active config controls eligible
top-ups, thresholds, cooldowns, limits, and failure behavior; the verified
successful-payment webhook posts the resulting credits.
Plan changes
Preview first, show the provider quote to the customer, then confirm with the
returned fingerprint. Bursar re-quotes and raises QuoteChangedError if the
amount or effective time moved.
- Python
- TypeScript
preview = await commerce.preview_plan_change(account_id, offer_key="pro_monthly")
confirmed = await commerce.confirm_plan_change(
account_id,
"plan-change:0195",
offer_key="pro_monthly",
quote_fingerprint=preview.quote_fingerprint,
)
const preview = await commerce.previewPlanChange({
accountId,
offerKey: "pro_monthly",
});
const confirmed = await commerce.confirmPlanChange({
accountId,
operationKey: "plan-change:0195",
offerKey: "pro_monthly",
quoteFingerprint: preview.quoteFingerprint,
});