Skip to content

Payments & Billing

SpeedPy ships a complete billing layer, not a payment SDK you have to wire up yourself. It is off by default — nothing on this page is required to run the app locally, in tests, or in demo mode. When you are ready to charge, you pick a provider, set a handful of env vars, run one catalog command, and turn it on.

Two providers ship in the box behind one interface:

  • Stripe — hosted Checkout, the classic choice.
  • Paddle (Billing v2) — a Merchant of Record, so Paddle handles sales tax/VAT registration and remittance for you.

You choose one with a single setting. Application code never imports Stripe or Paddle directly, so switching provider is a config change, not a rewrite.

This is a first-party layer, not a dependency

SpeedPy does not depend on django-paddle-billing or a Stripe helper package for its logic. The adapters, the plan registry, the runtime state machine, the webhook handling, and the local subscription records are all in mainapp/billing/ and mainapp/models/billing.py, so you can read and change every line.

How billing works

Four ideas hold the whole layer together.

The billable account is a Team or a User. When SPEEDPY_TEAMS_ENABLED=True, the team is billed (the user's default team). When teams are off, the user is billed. Nothing else in your code needs to know which. See Teams.

The plan registry is the single source of truth. mainapp/subscription_plans.py defines every plan — its price, features, and limits — and drives the pricing page, the upgrade UI, feature gating, and the provider catalog commands. Stripe and Paddle never drift from your app's own plan definitions, because they are generated from this one file.

Runtime state is three-valued and fails closed. Every "can this account do X right now?" question resolves to enabled, grace, or disabled. An unknown provider status, a missing subscription row, or an unrecognised price never grants paid access. If something is wrong, the account loses paid features — it does not silently keep them.

Webhooks never trust the customer email. Checkout carries a signed payload with billable_type, billable_id, plan_key, and interval. The webhook resolves the account from that signed data, so a customer cannot type someone else's email at checkout and attach a subscription to the wrong account.

Pricing page
   → Checkout  (carries signed custom_data: account, plan, interval)
   → Provider  (Stripe / Paddle)
   → Webhook   (signature verified, deduped by event id)
   → BillingSubscription row
   → Runtime state: enabled / grace / disabled
   → Feature gating in your app

The plan registry

Plans live in mainapp/subscription_plans.py. The boilerplate ships a generic free / pro / business / enterprise set with placeholder prices — tune them before launch.

SUBSCRIPTION_PLANS = {
    "pro": {
        "key": "pro",
        "name": "Pro",
        "price_monthly": 19,
        "price_yearly": 190,
        "is_paid": True,
        "is_contact": False,
        "features": ["Everything in Free", "Up to 10 team members", ...],
        "limits": {"max_team_members": 10},
        "provider_prices": {
            "stripe": {"monthly": _price_id("STRIPE_PRICE_PRO_MONTHLY"),
                       "yearly":  _price_id("STRIPE_PRICE_PRO_YEARLY")},
            "paddle": {"monthly": _price_id("PADDLE_PRICE_PRO_MONTHLY"),
                       "yearly":  _price_id("PADDLE_PRICE_PRO_YEARLY")},
        },
    },
    # free, business, enterprise ...
}

A few deliberate design choices are worth knowing:

  • Price IDs come from env vars. _price_id() reads each ID from settings and returns "" when it is unset. A missing ID never raises — it just disables checkout for that one plan/interval. Free-plan behaviour, billing-disabled installs, and the test suite are unaffected by empty price IDs.
  • is_paid: False plans (Free) are always enabled; there is nothing to bill.
  • is_contact: True plans (Enterprise) have no price IDs and no self-serve checkout — they render as a "contact us" tier and are skipped by the catalog commands.

Gating features from your code

Read runtime state through the helpers in mainapp/billing/state.py. Views, forms, and tasks should never inspect provider state directly.

from mainapp.billing import state

state.get_billing_state(billable)          # "enabled" | "grace" | "disabled"
state.can_create_records(billable)         # False during grace and when disabled
state.account_has_feature(billable, "x")   # plan feature check, fails closed
state.account_limit(billable, "max_team_members")  # numeric limit, None = unlimited
state.effective_plan_key(billable)         # the plan in effect right now

The three states map to concrete behaviour:

State What it means Effect
enabled Paid and current (or on the Free plan) Everything works, subject to plan limits
grace Payment is past due, inside the grace window Paid features stay on, but creating new records is blocked
disabled Grace expired, cancelled, or misconfigured Paid features off — fails closed

SPEEDPY_BILLING_GRACE_PERIOD_DAYS (default 30) sets how long a past_due subscription stays in grace before it drops to disabled.

Enabling billing

Turn the layer on and pick exactly one provider:

SPEEDPY_BILLING_ENABLED=True
SPEEDPY_BILLING_PROVIDER=stripe        # or "paddle"
SPEEDPY_BILLING_GRACE_PERIOD_DAYS=30   # optional, default 30

Existing subscriptions keep their own provider

Switching SPEEDPY_BILLING_PROVIDER changes which provider handles new checkouts. Subscriptions created earlier keep the provider that created them, and each provider's webhook is always processed by its own adapter. You can migrate providers without stranding existing customers.

The rest of the setup depends on the provider you chose.

Stripe

Full walkthrough: STRIPE_SETUP.md in the project root.

  1. Create a secret key (Developers → API keys) with products & prices write, checkout sessions write, billing portal sessions write, subscriptions read.

    STRIPE_SECRET_KEY=sk_test_...        # sk_live_... in production
    STRIPE_PUBLISHABLE_KEY=pk_test_...   # optional, client-side
    STRIPE_WEBHOOK_SECRET=whsec_...      # from the webhook endpoint below
    
  2. Generate products and prices from your registry (idempotent, matched by stable lookup keys), then paste the printed IDs into .env:

    uv run python manage.py setup_stripe_catalog --dry-run   # preview
    uv run python manage.py setup_stripe_catalog             # create/sync
    
    STRIPE_PRICE_PRO_MONTHLY=price_...
    STRIPE_PRICE_PRO_YEARLY=price_...
    STRIPE_PRICE_BUSINESS_MONTHLY=price_...
    STRIPE_PRICE_BUSINESS_YEARLY=price_...
    
  3. Add a webhook endpoint (Developers → Webhooks) at https://your-domain/billing/webhooks/stripe/ for checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted. Copy the signing secret into STRIPE_WEBHOOK_SECRET.

Stripe uses test mode for staging and live mode for production — separate environments with separate keys and price IDs.

Paddle

Full walkthrough: PADDLE_SETUP.md in the project root.

  1. Pick the environment (sandbox and production are fully separate dashboards):

    PADDLE_ENVIRONMENT=sandbox    # or "production"
    
  2. From Developer Tools, create the keys. Minimal API-key scopes: products & prices read/write, customer read/write, subscription read, customer portal sessions write. Under Checkout → Website approval, add every domain that opens checkout (production, staging, and localhost).

    PADDLE_API_KEY=...           # server-side (catalog, portal sessions)
    PADDLE_CLIENT_TOKEN=...      # browser-safe (checkout overlay only)
    PADDLE_WEBHOOK_SECRET=...    # from the notification destination below
    
  3. Generate products and prices, then paste the printed IDs into .env:

    uv run python manage.py setup_paddle_catalog --dry-run   # preview
    uv run python manage.py setup_paddle_catalog             # create/sync
    
    PADDLE_PRICE_PRO_MONTHLY=pri_...
    PADDLE_PRICE_PRO_YEARLY=pri_...
    PADDLE_PRICE_BUSINESS_MONTHLY=pri_...
    PADDLE_PRICE_BUSINESS_YEARLY=pri_...
    
  4. Add a notification destination at https://your-domain/billing/webhooks/paddle/ for the subscription.* family (created, activated, updated, past_due, paused, resumed, canceled, trialing). Copy its secret into PADDLE_WEBHOOK_SECRET.

Paddle needs a default payment link

Set Checkout → Default payment link to https://your-domain/billing/checkout/resume/. Paddle refuses to create any transaction until this URL is configured, and it is used for dunning and transaction-checkout links.

Webhooks

Both providers post to a provider-specific path under /billing/webhooks/. Signatures are verified before anything is trusted — Paddle against the Paddle-Signature header (HMAC-SHA256), Stripe via construct_event. Events are de-duplicated by event id, so provider retries and out-of-order deliveries are safe to receive more than once. The endpoint returns 200 for handled logic errors so the provider does not retry a message that will never succeed. See Webhooks for the general pattern.

Provider status → runtime state

Both adapters normalise the provider's own status vocabulary to the same three runtime states, so your gating code is identical whichever provider you run:

Provider status Runtime state Effect
active, trialing enabled Paid features on
past_due gracedisabled Grace window, then fails closed
paused enabled Paid features on
canceled / cancelled enabled until period end, then disabled Access to the paid-through date
anything unknown ignored Never grants access

A daily process_billing_subscriptions Celery task reconciles state and downgrades accounts shortly after a cancelled period ends. Confirm it runs in production — see Background Tasks.

Go-live checklist

  • [ ] Real keys/tokens for the live environment (sk_live_ / PADDLE_ENVIRONMENT=production).
  • [ ] Provider account verified (Paddle also verifies your business and website).
  • [ ] Catalog command re-run against the live environment; price-ID env vars updated.
  • [ ] Live webhook endpoint registered and its signing secret set.
  • [ ] SPEEDPY_BILLING_ENABLED=True and SPEEDPY_BILLING_PROVIDER set.
  • [ ] A real checkout, the customer portal, and a cancellation tested end-to-end.
  • [ ] The process_billing_subscriptions Celery beat task confirmed running.

Where the code lives

Everything is first-party and readable:

Path Responsibility
mainapp/subscription_plans.py The plan registry — single source of truth
mainapp/billing/base.py The provider-neutral BillingAdapter interface
mainapp/billing/stripe.py, paddle.py The two provider adapters
mainapp/billing/registry.py Provider selection and per-provider routing
mainapp/billing/state.py Runtime state and feature-gating helpers
mainapp/billing/webhooks.py, signing.py Webhook handling and signed tokens
mainapp/models/billing.py BillingCustomer / BillingSubscription records
mainapp/views/billing.py Checkout, portal, and webhook views
mainapp/management/commands/setup_{stripe,paddle}_catalog.py Catalog generators
mainapp/tasks/billing.py The daily reconciliation task

See also the root-level STRIPE_SETUP.md and PADDLE_SETUP.md for the full, step-by-step provider guides.