Backend

Billing

Subscriptions with Stripe Checkout, the customer portal and webhooks, configured as a list of plans that maps onto Pricing.

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.

Mock backendChoose a paid plan to start a checkout

Setup

  1. Install and configure Stripe

    sh
    pip install "pleate-backend[stripe]"

    Create the products and prices in Stripe, then describe the plans:

    settings.py
    PLEATE["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}]},
        ],
    }

    amount is in minor units (cents). A plan without prices is free; "custom": True or a custom_price label makes a sales led plan.

  2. Point the Stripe webhook at the API

    In the Stripe dashboard add an endpoint at https://plate.app/api/billing/webhook with the events below, and copy its signing secret into STRIPE_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 the billing.payment_failed notification) and invoice.paid. Each event id is applied once.

  3. Set the frontend paths

    Checkout returns to BILLING_SUCCESS (with ?session_id=) or BILLING_CANCEL; the portal returns to BILLING_PORTAL_RETURN. Defaults: /billing/success, /pricing, /settings/billing.

Endpoints

MethodPathPurpose
GET/billing/plansAnyone. {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/webhookStripe 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

tsx
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

python
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 | None

Access 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"]:

KeyDefaultMeaning
STRIPE_SECRET_KEYNonesk_live_... or sk_test_...
STRIPE_WEBHOOK_SECRETNonewhsec_... of the webhook endpoint.
CURRENCY"usd"Shown by Pricing.
TRIAL_DAYS0Free days on new subscriptions.
EVENT_RETENTION_DAYS30Webhook events kept for deduplication, then deleted by a TTL index.
PLANS[]The plans, see above.

Error codes

CodeStatusWhen
invalid_plan400Unknown plan, free plan or missing interval price.
billing_not_configured503No Stripe key.
already_subscribed409Use the portal to change plan.
checkout_in_progress409Another checkout for the same user is starting (double click).
event_in_progress409Webhook redelivered while the first delivery runs; Stripe retries later.
billing_error502Stripe refused or failed.
no_customer400Portal requested before any checkout.
invalid_signature400Webhook signature check failed.
subscription_required403Raised by HasActiveSubscription.