Integrating subscription billing into a SaaS product looks deceptively simple at first.
You create a checkout page, charge the customer, and unlock premium features.
But once you move beyond the happy path, billing systems become distributed systems problems.
You start dealing with:
- asynchronous webhooks
- duplicate events
- out-of-order delivery
- payment recovery flows
- subscription pauses
- scheduled cancellations
- state synchronization
- authorization consistency
This article walks through how I designed a production-oriented SaaS billing architecture using Paddle and FastAPI.
Although the implementation is Python-specific, the architecture itself works for any backend stack.
The key design principle behind the entire system was:
Paddle handles billing. The backend handles authorization and product access.
Architecture Overview
The frontend never directly manages subscription state.
Instead:
- Paddle owns billing infrastructure
- the backend owns authorization state
- webhooks continuously synchronize both systems
The backend keeps an internal subscriptions table that acts as the runtime source of truth for product access.
This is important.
The application never asks Paddle in real time whether a user should have access to the product.
Instead:
Access control = internal subscription state
That separation prevents:
- vendor coupling
- authorization drift
- runtime billing outages affecting product access
- frontend tampering
Step 1 — Create a Paddle Sandbox Account
Start by creating a Paddle Sandbox account.
Paddle provides:
- sandbox environment
- API keys
- webhook simulation
- hosted checkout
- subscription management APIs
You'll need:
| Variable | Purpose |
|---|---|
PADDLE_API_KEY | REST API authentication |
PADDLE_ENVIRONMENT | sandbox or live |
PADDLE_WEBHOOK_SECRET | webhook signature verification |
Your backend config might look like this:
PADDLE_API_KEY=your_api_key
PADDLE_ENVIRONMENT=sandbox
PADDLE_WEBHOOK_SECRET=your_webhook_secret
Environment selection controls the API base URL:
if environment == "sandbox":
base_url = "https://sandbox-api.paddle.com"
else:
base_url = "https://api.paddle.com"
Step 2 — Create Your Product and Price
Inside Paddle:
- Create a Product
- Create a recurring Price
- Copy the generated Price ID
Example:
PADDLE_DEFAULT_PRICE_ID=pri_xxxxxxxxx
This price ID is what your backend uses when creating checkout transactions.
At this stage, keep pricing simple:
- one product
- one recurring monthly price
- one subscription per business/user
You can always expand later into:
- yearly plans
- seat-based billing
- multiple products
- usage-based pricing
Step 3 — Design Your Internal Subscription Model
Before writing billing code, design your internal subscription schema.
This is one of the most important architectural decisions.
A common mistake is relying directly on Paddle subscription objects for authorization decisions.
Instead, maintain your own internal subscription state.
Example fields:
| Field | Purpose |
|---|---|
plan_type | FREE or PAID |
status | active, paused, canceled, past_due, pending |
paddle_customer_id | Internal linkage |
paddle_subscription_id | Internal linkage |
current_period_start / end | Billing period |
scheduled_change | Pending pause/cancel |
paddle_last_event_occurred_at | Stale-event protection |
The important thing here is:
Paddle is not your source of truth. Your backend is.
Paddle events synchronize into your internal state.
Subscription Lifecycle
The internal subscription lifecycle looks like this:
One important detail:
Paddle does not allow resuming canceled subscriptions.
So once a subscription becomes canceled:
- you must create a completely new checkout transaction
- which creates a new Paddle subscription
That means your backend should treat:
- canceled subscriptions
- expired free trials
as requiring a brand new checkout flow.
Step 4 — Build a Thin Paddle Service Layer
Avoid calling Paddle APIs directly inside route handlers.
Instead, isolate all Paddle logic inside a dedicated service layer.
Typical responsibilities:
| Function | Endpoint |
|---|---|
create_checkout_transaction() | POST /transactions |
pause_subscription() | POST /subscriptions/{id}/pause |
cancel_subscription() | POST /subscriptions/{id}/cancel |
resume_subscription() | POST /subscriptions/{id}/resume |
update_payment_method() | GET /subscriptions/{id}/update-payment-method-transaction |
This keeps:
- Paddle-specific logic centralized
- route handlers clean
- testing easier
- future billing-provider migrations possible
Step 5 — Implement Checkout Creation
The frontend should never create Paddle transactions directly.
Instead:
- frontend calls backend
- backend creates Paddle transaction
- backend returns hosted checkout URL
Checkout Flow
Embed Internal Metadata Using custom_data
This is extremely important.
When creating transactions, embed your internal identifiers inside Paddle custom_data.
Example:
{
"app_subscription_id": "subscription_uuid",
"app_user_id": "user_id"
}
Without this:
- webhook reconciliation becomes painful
- you lose direct mapping between Paddle events and internal entities
This metadata becomes the bridge between external billing events and internal authorization state.
Step 6 — Implement Webhook Verification
Billing systems are asynchronous systems.
Webhooks are not optional.
Your backend must continuously synchronize subscription state from Paddle.
Verify Paddle Signatures
Paddle sends a Paddle-Signature header.
You should:
- extract timestamp + signature
- compute HMAC-SHA256
- compare against header signature
The signature payload format is:
{timestamp}:{raw_body}
Invalid signatures should immediately return 401.
Step 7 — Add Webhook Deduplication
Webhook providers retry events.
You must make webhook processing idempotent.
Create a webhook ledger table:
paddle_webhook_events
Store:
- event ID
- processing status
- timestamps
- errors
Before processing:
- insert event_id
- reject duplicates
Deduplication Flow
Without this, duplicate webhooks can:
- double-activate subscriptions
- overwrite state
- create inconsistent billing records
Step 8 — Protect Against Out-of-Order Events
This is one of the most overlooked billing problems.
Webhook ordering is NOT guaranteed.
Example:
subscription.updatedarrives first- older
transaction.completedretry arrives later
Without protection, old events can overwrite newer state.
To solve this, every subscription stores:
paddle_last_event_occurred_at
Before applying updates:
if occurred_at <= paddle_last_event_occurred_at:
ignore_event()
This single check prevents an entire category of synchronization bugs.
Step 9 — Handle transaction.completed
The transaction.completed webhook is used only for initial checkout activation.
When received:
- resolve internal subscription using
custom_data - activate subscription
- store Paddle customer/subscription IDs
- store billing period
- upgrade FREE → PAID if necessary
Activation Flow
One important design choice:
Only activate subscriptions when:
transaction.origin == web OR api
This prevents unrelated transactions from mutating subscription state.
Step 10 — Handle subscription.updated
This becomes the canonical lifecycle synchronization event.
This webhook handles:
- renewals
- pauses
- payment failures
- cancellations
- scheduled changes
- payment recovery
This is the most important webhook in the system.
subscription.updated Flow
Important Lifecycle Rules
active — Subscription is valid and access is allowed.
past_due — Access may still remain enabled depending on your business rules.
paused — Subscription remains paused until resumed.
canceled — Subscription cannot be resumed in Paddle. The customer must create a new checkout transaction.
Step 11 — Implement Pause and Cancel Flows
Users should be able to:
- pause immediately
- pause at period end
- cancel immediately
- cancel at period end
Pause Flow
The backend stores Paddle's scheduled_change response immediately so the frontend can reflect pending lifecycle changes without waiting for the webhook.
Step 12 — Resume Flows
Resume behavior depends on current subscription state.
If subscription is paused — Call Paddle resume endpoint.
If subscription has scheduled pause/cancel — Clear scheduled_change.
If subscription is canceled — Reject resume request. The user must create a new checkout flow.
This distinction is important because Paddle treats canceled subscriptions as permanently terminated.
Step 13 — Payment Method Updates
Paddle provides an update-payment-method transaction flow.
Your backend:
- requests update transaction
- returns hosted checkout URL
- user updates card details
Importantly:
Do NOT treat update-payment
transaction.completedevents as subscription lifecycle events.
Subscription lifecycle synchronization should still come from:
subscription.updated
This keeps billing state consistent.
Common Mistakes to Avoid
Mistake #1 — Using Paddle as Runtime Authorization
Your application should not query Paddle every time access needs to be checked.
Maintain internal state.
Mistake #2 — Ignoring Webhook Deduplication
Webhook retries are normal.
Without deduplication, state corruption becomes possible.
Mistake #3 — Trusting Event Ordering
Webhook ordering is not guaranteed.
Always implement stale-event protection.
Mistake #4 — Treating transaction.completed as Lifecycle Truth
Checkout completion is not subscription lifecycle management.
Use:
transaction.completedfor activationsubscription.updatedfor lifecycle synchronization
Production Learnings
After building this system, a few things became very clear.
Billing systems are distributed systems
The complexity is not in checkout creation.
It's in synchronization.
Internal state ownership matters
The backend should own authorization decisions.
Not the billing provider.
Webhook architecture matters more than checkout
Most billing bugs happen:
- after payment succeeds
- during lifecycle synchronization
not during checkout itself.
Keep the integration surface small
A thin Paddle service layer keeps the architecture maintainable and portable.
Start with fewer webhook types
You do not need to support every webhook immediately.
Start with:
transaction.completedsubscription.updated
Then expand as needed.
Future Improvements
Some areas I would improve next:
| Area | Improvement |
|---|---|
| Webhook processing | Async queue workers |
| Pricing | Multi-plan support |
| Billing model | Seat-based subscriptions |
| Reliability | Dead-letter queues |
| Operations | Webhook replay tooling |
| Observability | Admin dashboards |
Final Thoughts
Subscription billing is much more than payment collection.
A reliable billing system requires:
- asynchronous synchronization
- idempotent event processing
- lifecycle management
- authorization consistency
- careful separation of concerns
The most important design decision in this architecture was simple:
Paddle owns billing. The backend owns authorization.
That separation made the entire system easier to reason about, easier to debug, and significantly more resilient in production.