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: Falseplans (Free) are alwaysenabled; there is nothing to bill.is_contact: Trueplans (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.
-
Create a secret key (Developers → API keys) with products & prices write, checkout sessions write, billing portal sessions write, subscriptions read.
-
Generate products and prices from your registry (idempotent, matched by stable lookup keys), then paste the printed IDs into
.env: -
Add a webhook endpoint (Developers → Webhooks) at
https://your-domain/billing/webhooks/stripe/forcheckout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted. Copy the signing secret intoSTRIPE_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.
-
Pick the environment (sandbox and production are fully separate dashboards):
-
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). -
Generate products and prices, then paste the printed IDs into
.env: -
Add a notification destination at
https://your-domain/billing/webhooks/paddle/for thesubscription.*family (created,activated,updated,past_due,paused,resumed,canceled,trialing). Copy its secret intoPADDLE_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 |
grace → disabled |
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=TrueandSPEEDPY_BILLING_PROVIDERset. - [ ] A real checkout, the customer portal, and a cancellation tested end-to-end.
- [ ] The
process_billing_subscriptionsCelery 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.