Backend
Billing
pleate_backend.billing sells subscriptions with Stripe Checkout, lets customers manage them in the Stripe billing portal and keeps a local copy of each subscription in sync through webhooks. Plans are a list in your settings, and each plan maps one to one onto a Pricing card. Mounted at /api/billing.
Pricing with checkout
usePricing loads the plans, tracks the monthly or yearly switch and starts Checkout for the chosen plan.
Setup
Install and configure Stripe
shpip install "pleate-backend[stripe]"Create the products and prices in Stripe, then describe the plans:
settings.pyPLEATE["BILLING"] = { "STRIPE_SECRET_KEY": env("STRIPE_SECRET_KEY"), "STRIPE_WEBHOOK_SECRET": env("STRIPE_WEBHOOK_SECRET"), # whsec_... "CURRENCY": "usd", "TRIAL_DAYS": 14, "PLANS": [ {"id": "free", "name": "Free", "description": "For trying things out.", "features": ["1 project", "Community support"]}, # no prices: free {"id": "pro", "name": "Pro", "description": "For growing teams.", "features": ["Unlimited projects", "Priority support", {"label": "Webhooks", "hint": "Signed, with retries"}], "highlighted": True, "badge": "Popular", "cta": "Start free trial", "prices": {"month": {"price_id": "price_1Pro...", "amount": 1200}, "year": {"price_id": "price_1ProY...", "amount": 12000}}}, {"id": "enterprise", "name": "Enterprise", "custom_price": "Let's talk", "features": ["SSO", "Audit log export", {"label": "Uptime SLA", "included": True}]}, ], }amountis in minor units (cents). A plan without prices is free;"custom": Trueor acustom_pricelabel makes a sales led plan.Point the Stripe webhook at the API
In the Stripe dashboard add an endpoint at
https://plate.app/api/billing/webhookwith the events below, and copy its signing secret intoSTRIPE_WEBHOOK_SECRET. Locally, forward with the Stripe CLI:stripe listen.Handled:
checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed(sends thebilling.payment_failednotification) andinvoice.paid. Each event id is applied once.Set the frontend paths
Checkout returns to
BILLING_SUCCESS(with?session_id=) orBILLING_CANCEL; the portal returns toBILLING_PORTAL_RETURN. Defaults:/billing/success,/pricing,/settings/billing.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /billing/plans | Anyone. {currency, trial_days, current_plan, plans} |
| GET | /billing/subscription | {subscription}: the active one, else the most recent, else null. |
| POST | /billing/checkout | {plan, interval: month | year} → {url} of Stripe Checkout. |
| POST | /billing/portal | → {url} of the billing portal. |
| POST | /billing/webhook | Stripe only. Raw event with Stripe-Signature → {received, duplicate}. |
Each plan in /billing/plans has id, name, description, features, highlighted, badge, cta, price ({monthly, yearly} per month in major units, yearly shown as its monthly equivalent, or null), custom_price, amounts (raw minor unit charges), intervals and current. current_plan is the plan of the active subscription, else the free plan, for signed in users.
On the frontend
const { isSignedIn } = useMe();
const pricing = usePricing({ isSignedIn, signInHref: "/login?next=/pricing" });
<Pricing {...pricing.pricingProps} />
const { subscription, openPortal } = useSubscription();
{subscription && <Button onClick={openPortal}>Manage billing</Button>}usePricing renders a call to action per plan (disabled on the current plan, busy while Checkout starts), or takes renderCta to draw your own. Signed out visitors who pick a paid plan go to signInHref.
Protecting features
from pleate_backend.billing.permissions import HasActiveSubscription
from pleate_backend.billing.services import active_subscription
class ExportView(APIView):
permission_classes = [IsAuthenticated, HasActiveSubscription] # any plan
class WebhookSettingsView(APIView):
permission_classes = [IsAuthenticated, HasActiveSubscription(plans=["pro"])]
active_subscription(request.user) # Subscription | NoneAccess is granted for the statuses active, trialing and past_due. /auth/me carries extra.subscription ({plan_id, status, current_period_end, cancel_at_period_end} or null) for the UI. React to changes with the billing.signals.subscription_changed signal (user, subscription, previous_status).
Settings
PLEATE["BILLING"]:
| Key | Default | Meaning |
|---|---|---|
| STRIPE_SECRET_KEY | None | sk_live_... or sk_test_... |
| STRIPE_WEBHOOK_SECRET | None | whsec_... of the webhook endpoint. |
| CURRENCY | "usd" | Shown by Pricing. |
| TRIAL_DAYS | 0 | Free days on new subscriptions. |
| EVENT_RETENTION_DAYS | 30 | Webhook events kept for deduplication, then deleted by a TTL index. |
| PLANS | [] | The plans, see above. |
Error codes
| Code | Status | When |
|---|---|---|
| invalid_plan | 400 | Unknown plan, free plan or missing interval price. |
| billing_not_configured | 503 | No Stripe key. |
| already_subscribed | 409 | Use the portal to change plan. |
| checkout_in_progress | 409 | Another checkout for the same user is starting (double click). |
| event_in_progress | 409 | Webhook redelivered while the first delivery runs; Stripe retries later. |
| billing_error | 502 | Stripe refused or failed. |
| no_customer | 400 | Portal requested before any checkout. |
| invalid_signature | 400 | Webhook signature check failed. |
| subscription_required | 403 | Raised by HasActiveSubscription. |