← All posts

Blog ·

Designing a SaaS Billing Architecture with Paddle

A step-by-step architecture for integrating Paddle into a any backend with internal subscription state, idempotent webhook handling, stale-event protection, and clear billing vs authorization boundaries.

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
Rendering diagram…

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:

VariablePurpose
PADDLE_API_KEYREST API authentication
PADDLE_ENVIRONMENTsandbox or live
PADDLE_WEBHOOK_SECRETwebhook 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:

  1. Create a Product
  2. Create a recurring Price
  3. 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:

FieldPurpose
plan_typeFREE or PAID
statusactive, paused, canceled, past_due, pending
paddle_customer_idInternal linkage
paddle_subscription_idInternal linkage
current_period_start / endBilling period
scheduled_changePending pause/cancel
paddle_last_event_occurred_atStale-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:

Rendering diagram…

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.

Rendering diagram…

Typical responsibilities:

FunctionEndpoint
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:

  1. frontend calls backend
  2. backend creates Paddle transaction
  3. backend returns hosted checkout URL

Checkout Flow

Rendering diagram…

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:

  1. extract timestamp + signature
  2. compute HMAC-SHA256
  3. compare against header signature
Rendering diagram…

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

Rendering diagram…

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.updated arrives first
  • older transaction.completed retry 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

Rendering diagram…

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

Rendering diagram…

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

Rendering diagram…

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:

  1. requests update transaction
  2. returns hosted checkout URL
  3. user updates card details

Importantly:

Do NOT treat update-payment transaction.completed events 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.completed for activation
  • subscription.updated for 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.completed
  • subscription.updated

Then expand as needed.

Future Improvements

Some areas I would improve next:

AreaImprovement
Webhook processingAsync queue workers
PricingMulti-plan support
Billing modelSeat-based subscriptions
ReliabilityDead-letter queues
OperationsWebhook replay tooling
ObservabilityAdmin 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.