# How to Add Payments to Your Django SaaS App with Dodo Payments

> A step-by-step guide to integrating payments, subscriptions, and license keys into Django SaaS applications using Dodo Payments.
- **Author**: Ayush Agarwal
- **Published**: 2026-04-05
- **Modified**: 2026-04-07
- **Category**: Integration, Django, How-To
- **URL**: https://dodopayments.com/blogs/accept-payments-django-app

---

Django has been powering SaaS businesses for over a decade. It ships with authentication, an ORM, an admin panel, and a mature ecosystem. What it does not ship with is a payment layer. That gap is where most Django SaaS projects lose weeks of engineering time, wrestling with webhooks, subscription state machines, and tax compliance instead of building product.

This guide walks through integrating [Dodo Payments](https://dodopayments.com) into a Django SaaS application from scratch. You will set up the Python SDK, create products, generate payment links, handle webhooks, manage subscriptions, and validate license keys. Every code example is production-ready and follows Django conventions. If you are building on Next.js or Laravel instead, the same concepts apply in the [add payments Next.js](https://dodopayments.com/blogs/add-payments-nextjs-app) and [add payments Laravel](https://dodopayments.com/blogs/add-payments-laravel-saas) guides.

## Why Django SaaS Payments Are Harder Than They Look

Before diving into code, it helps to understand what makes django payments integration genuinely complex compared to adding a simple checkout button.

A typical SaaS billing layer needs to handle:

- One-time purchases (lifetime deals, credit packs)
- Recurring subscriptions with trial periods and plan changes
- License key delivery for desktop or API products
- Tax calculation across dozens of jurisdictions
- Webhook reliability, meaning events can arrive out of order, be duplicated, or arrive after network failures
- Failed payment recovery and dunning emails

Most payment gateways hand you a low-level API and tell you to figure out the rest. [Dodo Payments](https://dodopayments.com) is built for exactly this stack. It handles merchant-of-record responsibilities, so you don't deal with VAT, GST, or sales tax compliance. It ships first-class subscription management, license key generation, and a Python SDK that maps cleanly to Django patterns.

## Integration Architecture

Here is a high-level view of how the pieces connect:

```mermaid
graph TD
    User["User Browser"] -->|1. Click upgrade| Django["Django App"]
    Django -->|2. Create payment link| DodoAPI["Dodo Payments API"]
    DodoAPI -->|3. Return checkout URL| Django
    Django -->|4. Redirect to checkout| Checkout["Dodo Hosted Checkout"]
    Checkout -->|5. User pays| DodoAPI
    DodoAPI -->|6. POST webhook event| WebhookView["Django Webhook View"]
    WebhookView -->|7. Verify signature| Django
    WebhookView -->|8. Update subscription model| DB["PostgreSQL"]
    DB -->|9. Grant access| User
```

The flow is straightforward. Your Django app requests a payment link from the Dodo API. The user completes payment on the hosted checkout page. Dodo then sends a signed webhook to your server, which you verify and use to update your local database. Your app reads from that database to control feature access.

## Step 1: Project Setup

Start with a standard Django project. If you are adding Dodo Payments to an existing app, skip to Step 2.

```bash
python -m venv venv
source venv/bin/activate
pip install django dodopayments python-dotenv
django-admin startproject myapp .
python manage.py startapp billing
```

Add `billing` to `INSTALLED_APPS` in `settings.py`:

```python
INSTALLED_APPS = [
    # ... default apps
    "billing",
]
```

Create a `.env` file in your project root:

```bash
DODO_PAYMENTS_API_KEY=your_live_or_test_api_key
DODO_WEBHOOK_SECRET=your_webhook_signing_secret
```

Load these in `settings.py`:

```python
import os
from dotenv import load_dotenv

load_dotenv()

DODO_PAYMENTS_API_KEY = os.environ["DODO_PAYMENTS_API_KEY"]
DODO_WEBHOOK_SECRET = os.environ["DODO_WEBHOOK_SECRET"]
```

Both values are available in your [Dodo Payments](https://dodopayments.com) dashboard. See the [Dodo Payments SDKs](https://docs.dodopayments.com/developer-resources/dodo-payments-sdks) page for installation details and SDK changelogs.

## Step 2: Install and Initialize the Python SDK

The Dodo Python SDK wraps the [API reference](https://docs.dodopayments.com/api-reference/introduction) with typed responses and automatic retries. Install it and create a shared client module.

```bash
pip install dodopayments
```

Create `billing/client.py`:

```python
import os
import dodopayments

client = dodopayments.DodoPayments(
    bearer_token=os.environ["DODO_PAYMENTS_API_KEY"]
)
```

Import `client` from this module anywhere you need to make API calls. Keeping the client as a module-level singleton avoids re-instantiating it on every request.

> We designed the Python SDK to feel native to Django developers. The client is stateless and thread-safe, so you initialize it once at module level and pass it around like any other Django service. No request-scoped setup, no middleware required.
>
> - Ayush Agarwal, Co-founder & CPTO at Dodo Payments

## Step 3: Define Your Billing Models

You need local models to track subscription state, payment history, and license keys. This is the source of truth your application reads to enforce access control.

Create `billing/models.py`:

```python
from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()

class Plan(models.Model):
    """Mirrors a product created in the Dodo Payments dashboard."""
    name = models.CharField(max_length=100)
    dodo_product_id = models.CharField(max_length=100, unique=True)
    price_monthly = models.DecimalField(max_digits=8, decimal_places=2)
    price_yearly = models.DecimalField(max_digits=8, decimal_places=2)
    is_active = models.BooleanField(default=True)

    def __str__(self):
        return self.name

class Subscription(models.Model):
    STATUS_CHOICES = [
        ("active", "Active"),
        ("past_due", "Past Due"),
        ("cancelled", "Cancelled"),
        ("trialing", "Trialing"),
        ("paused", "Paused"),
    ]

    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="subscription")
    plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
    dodo_subscription_id = models.CharField(max_length=100, unique=True)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="active")
    current_period_end = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    @property
    def is_active(self):
        return self.status in ("active", "trialing")

    def __str__(self):
        return f"{self.user.email} - {self.status}"

class LicenseKey(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="license_keys")
    key = models.CharField(max_length=255, unique=True)
    dodo_license_key_id = models.CharField(max_length=100, unique=True)
    product_id = models.CharField(max_length=100)
    is_active = models.BooleanField(default=True)
    activations_limit = models.IntegerField(default=1)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.key[:16]}... ({self.user.email})"

class PaymentEvent(models.Model):
    """Raw log of incoming webhook events for debugging and replay."""
    dodo_event_id = models.CharField(max_length=100, unique=True)
    event_type = models.CharField(max_length=100)
    payload = models.JSONField()
    processed_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.event_type} - {self.dodo_event_id}"
```

Run migrations:

```bash
python manage.py makemigrations billing
python manage.py migrate
```

## Step 4: Create Products in the Dashboard

Before generating payment links, create your products in the Dodo Payments dashboard. You can create one-time products, subscriptions, and license key products. Each product gets a unique `product_id` that you store in your `Plan` model.

See the full [integration guide](https://docs.dodopayments.com/developer-resources/integration-guide) for a walkthrough of the dashboard product creation flow.

Alternatively, create a product via the SDK in a management command:

```python
# billing/management/commands/create_plan.py
from django.core.management.base import BaseCommand
from billing.client import client
from billing.models import Plan

class Command(BaseCommand):
    help = "Create a subscription product in Dodo Payments and save to DB"

    def add_arguments(self, parser):
        parser.add_argument("--name", type=str, required=True)
        parser.add_argument("--amount", type=int, required=True, help="Amount in cents")
        parser.add_argument("--currency", type=str, default="USD")

    def handle(self, *args, **options):
        product = client.products.create(
            name=options["name"],
            description=f"{options['name']} subscription plan",
            price={"currency": options["currency"], "amount": options["amount"]},
            tax_category="saas",
        )
        self.stdout.write(f"Created product: {product.product_id}")
```

## Step 5: Generate Payment Links

Payment links are the core of the django payment gateway integration. When a user clicks "Upgrade," your view creates a checkout session and redirects them to the hosted payment page.

Create `billing/views.py`:

```python
import os
from django.shortcuts import redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse
from billing.client import client
from billing.models import Plan, Subscription

@login_required
def create_checkout(request, plan_id):
    """Generate a Dodo Payments payment link and redirect the user."""
    plan = get_object_or_404(Plan, id=plan_id, is_active=True)

    billing_interval = request.GET.get("interval", "monthly")

    payment = client.payments.create(
        billing={
            "city": "",
            "country": "US",
            "state": "",
            "street": "",
            "zipcode": "",
        },
        customer={
            "email": request.user.email,
            "name": request.user.get_full_name() or request.user.username,
        },
        product_cart=[
            {
                "product_id": plan.dodo_product_id,
                "quantity": 1,
            }
        ],
        return_url=request.build_absolute_uri("/billing/success/"),
        metadata={
            "user_id": str(request.user.id),
            "plan_id": str(plan.id),
        },
    )

    return redirect(payment.payment_link)

@login_required
def checkout_success(request):
    """Landing page after a successful payment."""
    return render(request, "billing/success.html")
```

The `metadata` field is important. Dodo Payments sends it back in the webhook payload, so you can match the payment to a specific user without relying on the customer email alone.

For a fully embedded checkout experience that keeps users on your domain, see the [overlay checkout](https://docs.dodopayments.com/developer-resources/overlay-checkout) documentation. It uses a lightweight JavaScript snippet to render the Dodo checkout in a modal instead of a redirect.

## Step 6: Handle Webhooks

Webhooks are the backbone of reliable django saas payments. Dodo Payments sends signed POST requests to your server whenever something meaningful happens: a subscription activates, a payment fails, a subscription cancels.

The critical rule is to verify the webhook signature before processing anything. Dodo signs every request with your webhook secret, and processing unverified events is a serious security vulnerability.

Create the webhook view in `billing/views.py`:

```python
import hashlib
import hmac
import json
import time
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from billing.models import Subscription, Plan, LicenseKey, PaymentEvent
from django.contrib.auth import get_user_model

User = get_user_model()

@csrf_exempt
@require_POST
def dodo_webhook(request):
    """Receive and verify Dodo Payments webhook events."""
    signature = request.headers.get("webhook-signature", "")
    timestamp = request.headers.get("webhook-timestamp", "")
    webhook_id = request.headers.get("webhook-id", "")

    if not all([signature, timestamp, webhook_id]):
        return HttpResponse("Missing webhook headers", status=400)

    # Verify the timestamp is within 5 minutes to prevent replay attacks
    try:
        event_timestamp = int(timestamp)
        if abs(time.time() - event_timestamp) > 300:
            return HttpResponse("Timestamp too old", status=400)
    except ValueError:
        return HttpResponse("Invalid timestamp", status=400)

    # Reconstruct the signed payload
    body = request.body.decode("utf-8")
    signed_content = f"{webhook_id}.{timestamp}.{body}"

    # Compute the expected signature
    secret = settings.DODO_WEBHOOK_SECRET
    expected_sig = hmac.new(
        secret.encode("utf-8"),
        signed_content.encode("utf-8"),
        hashlib.sha256,
    ).digest()

    import base64
    expected_sig_b64 = base64.b64encode(expected_sig).decode("utf-8")

    # The signature header can contain multiple signatures separated by spaces
    received_signatures = [
        s.split(",", 1)[1] if "," in s else s
        for s in signature.split(" ")
    ]

    if not any(
        hmac.compare_digest(expected_sig_b64, sig)
        for sig in received_signatures
    ):
        return HttpResponse("Invalid signature", status=401)

    try:
        payload = json.loads(body)
    except json.JSONDecodeError:
        return HttpResponse("Invalid JSON", status=400)

    event_type = payload.get("type")
    event_id = payload.get("id", webhook_id)

    # Idempotency: skip events we have already processed
    if PaymentEvent.objects.filter(dodo_event_id=event_id).exists():
        return HttpResponse("Already processed", status=200)

    PaymentEvent.objects.create(
        dodo_event_id=event_id,
        event_type=event_type,
        payload=payload,
    )

    handle_webhook_event(event_type, payload)

    return HttpResponse("OK", status=200)

def handle_webhook_event(event_type, payload):
    """Route webhook events to the appropriate handler."""
    handlers = {
        "subscription.active": handle_subscription_active,
        "subscription.cancelled": handle_subscription_cancelled,
        "subscription.past_due": handle_subscription_past_due,
        "payment.succeeded": handle_payment_succeeded,
        "license_key.created": handle_license_key_created,
    }
    handler = handlers.get(event_type)
    if handler:
        handler(payload)

def handle_subscription_active(payload):
    data = payload.get("data", {})
    user_id = data.get("metadata", {}).get("user_id")
    plan_id = data.get("metadata", {}).get("plan_id")

    if not user_id:
        return

    try:
        user = User.objects.get(id=user_id)
        plan = Plan.objects.get(id=plan_id)
    except (User.DoesNotExist, Plan.DoesNotExist):
        return

    from django.utils import timezone
    import datetime

    period_end = data.get("current_period_end")
    period_end_dt = None
    if period_end:
        period_end_dt = timezone.datetime.fromisoformat(period_end)

    Subscription.objects.update_or_create(
        user=user,
        defaults={
            "plan": plan,
            "dodo_subscription_id": data["subscription_id"],
            "status": "active",
            "current_period_end": period_end_dt,
        },
    )

def handle_subscription_cancelled(payload):
    data = payload.get("data", {})
    subscription_id = data.get("subscription_id")
    if not subscription_id:
        return

    Subscription.objects.filter(
        dodo_subscription_id=subscription_id
    ).update(status="cancelled")

def handle_subscription_past_due(payload):
    data = payload.get("data", {})
    subscription_id = data.get("subscription_id")
    if not subscription_id:
        return

    Subscription.objects.filter(
        dodo_subscription_id=subscription_id
    ).update(status="past_due")

def handle_payment_succeeded(payload):
    # Handle one-time payment fulfillment here
    data = payload.get("data", {})
    # e.g., send confirmation email, activate credits, etc.
    pass

def handle_license_key_created(payload):
    data = payload.get("data", {})
    user_id = data.get("metadata", {}).get("user_id")

    if not user_id:
        return

    try:
        user = User.objects.get(id=user_id)
    except User.DoesNotExist:
        return

    LicenseKey.objects.get_or_create(
        dodo_license_key_id=data["license_key_id"],
        defaults={
            "user": user,
            "key": data["key"],
            "product_id": data["product_id"],
            "activations_limit": data.get("activations_limit", 1),
        },
    )
```

The `PaymentEvent` model provides idempotency. If Dodo retries a webhook because your server returned a non-200 response, the second delivery is a no-op. This pattern is essential for production reliability.

> Webhook idempotency is the most underestimated part of payment integrations. In Django, the ORM makes it trivial to check for duplicate events before processing. We see production issues disappear when teams add that one-line existence check before their handler logic runs.
>
> - Ayush Agarwal, Co-founder & CPTO at Dodo Payments

See the full [webhooks](https://docs.dodopayments.com/developer-resources/webhooks) documentation for all available event types and retry policies.

## Step 7: Wire Up URLs

Create `billing/urls.py`:

```python
from django.urls import path
from billing import views

urlpatterns = [
    path("checkout/<int:plan_id>/", views.create_checkout, name="billing_checkout"),
    path("success/", views.checkout_success, name="billing_success"),
    path("webhook/", views.dodo_webhook, name="billing_webhook"),
]
```

Include these in your project's main `urls.py`:

```python
from django.urls import path, include

urlpatterns = [
    # ... other patterns
    path("billing/", include("billing.urls")),
]
```

Your webhook URL will be `https://yourdomain.com/billing/webhook/`. Register this endpoint in the Dodo Payments dashboard to start receiving events.

## Step 8: Subscription Management

Once a user has an active subscription, you need ways to let them manage it: view their plan, cancel, or change their billing cycle. The [subscription management](https://docs.dodopayments.com/features/subscription) documentation covers all available subscription operations.

Add these views to `billing/views.py`:

```python
@login_required
def billing_portal(request):
    """Display the user's current subscription status."""
    try:
        subscription = request.user.subscription
    except Subscription.DoesNotExist:
        subscription = None

    plans = Plan.objects.filter(is_active=True)

    context = {
        "subscription": subscription,
        "plans": plans,
    }
    return render(request, "billing/portal.html", context)

@login_required
def cancel_subscription(request):
    """Cancel the user's active subscription at period end."""
    if request.method != "POST":
        return redirect("billing_portal")

    try:
        subscription = request.user.subscription
    except Subscription.DoesNotExist:
        return redirect("billing_portal")

    client.subscriptions.update(
        subscription_id=subscription.dodo_subscription_id,
        cancel_at_next_billing_date=True,
    )

    # Optimistically update the local status
    subscription.status = "cancelled"
    subscription.save(update_fields=["status", "updated_at"])

    return redirect("billing_portal")

@login_required
def change_plan(request, plan_id):
    """Upgrade or downgrade the user's subscription plan."""
    if request.method != "POST":
        return redirect("billing_portal")

    new_plan = get_object_or_404(Plan, id=plan_id, is_active=True)

    try:
        subscription = request.user.subscription
    except Subscription.DoesNotExist:
        return redirect("billing_portal")

    client.subscriptions.update(
        subscription_id=subscription.dodo_subscription_id,
        product_id=new_plan.dodo_product_id,
    )

    subscription.plan = new_plan
    subscription.save(update_fields=["plan", "updated_at"])

    return redirect("billing_portal")
```

For a complete list of subscription update options, see the [API reference](https://docs.dodopayments.com/api-reference/introduction).

## Step 9: License Key Validation

If you are selling a desktop application, CLI tool, or API with per-seat licensing, [license keys](https://docs.dodopayments.com/features/license-keys) are delivered automatically by Dodo Payments after a successful purchase. Your webhook handler already saves them to the `LicenseKey` model. Now you need an endpoint to validate them.

Add a validation view to `billing/views.py`:

```python
from django.views.decorators.http import require_GET

@require_GET
def validate_license(request):
    """
    Public endpoint for client applications to validate a license key.
    Expected query params: key, instance_id
    """
    key = request.GET.get("key", "").strip()
    instance_id = request.GET.get("instance_id", "").strip()

    if not key:
        return JsonResponse({"valid": False, "error": "key is required"}, status=400)

    try:
        license_key = LicenseKey.objects.select_related("user").get(
            key=key,
            is_active=True,
        )
    except LicenseKey.DoesNotExist:
        return JsonResponse({"valid": False, "error": "invalid key"}, status=404)

    # Also verify with the Dodo API to get live activation count
    try:
        dodo_key = client.license_keys.get(license_key.dodo_license_key_id)
        activations_remaining = (
            dodo_key.activations_limit - dodo_key.activations_count
        )
    except Exception:
        activations_remaining = None

    return JsonResponse({
        "valid": True,
        "email": license_key.user.email,
        "product_id": license_key.product_id,
        "activations_remaining": activations_remaining,
    })
```

Add the URL to `billing/urls.py`:

```python
path("validate-license/", views.validate_license, name="validate_license"),
```

Your desktop or CLI clients call `GET /billing/validate-license/?key=XXXX&instance_id=YYYY` to verify a license at startup. The combination of local database lookup and live Dodo API verification gives you both speed and accuracy.

## Step 10: Enforce Access in Views

With subscription state tracked locally, enforcing access is straightforward. Create a decorator in `billing/decorators.py`:

```python
from functools import wraps
from django.shortcuts import redirect
from django.contrib import messages

def subscription_required(view_func):
    """Redirect users without an active subscription to the billing portal."""
    @wraps(view_func)
    def wrapper(request, *args, **kwargs):
        if not request.user.is_authenticated:
            return redirect("login")

        try:
            subscription = request.user.subscription
            if not subscription.is_active:
                messages.warning(request, "Your subscription is not active.")
                return redirect("billing_portal")
        except Exception:
            messages.info(request, "Upgrade to access this feature.")
            return redirect("billing_portal")

        return view_func(request, *args, **kwargs)

    return wrapper
```

Apply it to any view that requires an active subscription:

```python
from billing.decorators import subscription_required

@login_required
@subscription_required
def dashboard(request):
    return render(request, "app/dashboard.html")
```

## Pricing and Next Steps

[Dodo Payments pricing](https://dodopayments.com/pricing) is straightforward: a flat percentage per transaction with no monthly platform fees. There are no setup costs, no hidden fees for additional currencies, and no extra charges for features like license keys or subscription management. For early-stage SaaS products, this structure keeps costs directly tied to revenue.

If you are looking at how Dodo fits into your broader payment stack decision, the [best payment platform for AI startups](https://dodopayments.com/blogs/best-payment-platform-ai-startups) post covers the landscape in detail.

Once your integration is live, consider these next steps:

- Set up the [overlay checkout](https://docs.dodopayments.com/developer-resources/overlay-checkout) to keep users on your domain during checkout
- Add Celery tasks to retry failed subscription renewals before marking them past due
- Build a usage-based billing layer on top of the one-time payment API for credit-based products
- Enable dunning emails in the Dodo dashboard to recover failed payments automatically

The full [Dodo Payments SDKs](https://docs.dodopayments.com/developer-resources/dodo-payments-sdks) page lists the latest SDK version, changelog, and TypeScript types if you are mixing Django with a TypeScript frontend.

---

## FAQ

### What is the simplest way to add payments to a Django app?

The simplest path is to create a product in the Dodo Payments dashboard and use the [integration guide](https://docs.dodopayments.com/developer-resources/integration-guide) to generate a payment link. You paste the link into a template as a standard anchor tag. No SDK, no webhook, no server-side code required. When you are ready for more control, add the Python SDK and the webhook handler described above.

### How do I test webhooks locally during Django development?

Use a tunneling tool like ngrok to expose your local server to the internet. Run `ngrok http 8000` and copy the HTTPS URL it provides. Register that URL as your webhook endpoint in the Dodo Payments dashboard. Set your `.env` file with your test API key and webhook secret. The Dodo dashboard also has a webhook replay feature that lets you resend past events to your endpoint.

### Can I use Dodo Payments for both subscriptions and one-time purchases in the same app?

Yes. The `product_cart` field in the `client.payments.create()` call accepts any mix of product types. You can put a subscription product and a one-time add-on in the same checkout. Dodo handles the billing logic for each item type independently. Subscription events and payment events arrive as separate webhooks, so your handlers stay clean.

### How do I handle failed subscription payments?

Dodo Payments sends a `subscription.past_due` webhook event when a renewal payment fails. Your webhook handler updates the local subscription status to `past_due`. You can then show a banner in your app prompting the user to update their payment method, or block access to paid features until the subscription is restored. Dodo also has a built-in dunning system that automatically retries failed charges on a configurable schedule.

### Is Dodo Payments suitable for selling to customers globally from a Django SaaS?

Dodo Payments operates as a merchant of record, which means it handles VAT, GST, sales tax, and compliance in every jurisdiction where your customers pay. Your Django app does not need any tax calculation logic. Customers see localized payment methods, and Dodo files the tax returns. This is particularly valuable for solo founders and small teams who want to sell globally without hiring a finance team. The [subscription management](https://docs.dodopayments.com/features/subscription) and [license keys](https://docs.dodopayments.com/features/license-keys) features work across all supported countries without any additional configuration.