Skip to main content
Version: 2.x

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 commerce section 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:

Terminal
python -m pip install "bursar[postgres,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.

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()

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:

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,
)

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.

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}

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.

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}

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.

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,
)