# Subscriptions

> Implement SaaS subscriptions with Stripe billing, pricing tables, webhooks, customer portals, and per-seat or usage-based pricing models.

## Overview

[Section titled “Overview”](#overview)

Subscriptions in Pegasus have three components which must all be setup in order for them to work correctly.

1. **Stripe Billing data**. This is configured in Stripe.
2. **Local Stripe models**. These are synced automatically from Stripe to your local database, using [`dj-stripe`](https://github.com/dj-stripe/dj-stripe).
3. **Pegasus metadata**. This is configured in `apps/subscriptions/metadata.py` and used to augment the data from Stripe.

The easiest way to set up all three is to follow the guide below.

## Getting Started

[Section titled “Getting Started”](#getting-started)

Complete the following steps in order to set up your first subscription workflow.

### Choose your billing setup

[Section titled “Choose your billing setup”](#choose-your-billing-setup)

If you haven’t already, [set up Pegasus and create an account](/getting-started). In your project settings you will see several options related to subscriptions.

![Subscriptions Project configuration](/_astro/subscriptions-config.dhr47Tfp_1eld1g.webp)

The first option is your *billing model*. Most projects should choose *standard*, which lets you create multiple plans with different monthly or annual prices. Choose *per unit* if you want to charge a variable cost based on the number of units used, for example, if you want to charge for every team-member. [Metered billing](https://stripe.com/docs/billing/subscriptions/usage-based), while not officially supported is largely compatible with the standard model.

The second option is your *pricing UI*. If you’re on a standard model or metered billing, it’s recommended to use Stripe’s [embedded pricing table](https://stripe.com/docs/payments/checkout/pricing-table). If you’re using per-unit billing, it’s recommended to choose “managed by your application”.

If all of this is intimidating, don’t worry! You can always change these things later. If you’re unsure what you want to use, it is recommended to choose “standard” and “embedded pricing table” to start, as that is the simplest setup and works well for most projects.

### Set up your billing model in Stripe

[Section titled “Set up your billing model in Stripe”](#set-up-your-billing-model-in-stripe)

Before setting up your development environment for subscriptions, you’ll need to create your billing model in Stripe. You should do this in a test account for development. You’ll eventually be able to copy everything to production once you’re happy with the set up.

[Stripe’s documentation](https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=checkout#create-pricing-model) has guidance on doing this. At a minimum you should create at least one product with a “recurring” price. If you want to offer multiple pricing plans, create one product for each plan. If you want to offer both monthly and annual pricing, make sure every product you add includes both a “monthly” and a “yearly” price.

If you are using the Stripe embedded pricing table, you may also want to add product descriptions and [features](https://stripe.com/docs/payments/checkout/pricing-table#product-features), as these will be used on your pricing page.

If you are using the Stripe embedded pricing table, you should also set it up now, following the [Stripe pricing table documentation](https://stripe.com/docs/payments/checkout/pricing-table).

### Set up your development environment

[Section titled “Set up your development environment”](#set-up-your-development-environment)

Once you’ve created your billing model on Stripe, follow these instructions to set up your development environment.

1. Update the `STRIPE_*` variables in your project’s [`.env` file](/configuration/#settings-and-environment-files) to match the keys from Stripe. See [this page](https://stripe.com/docs/keys) to find your API keys.

2. Run `./manage.py bootstrap_subscriptions`. If things are set up correctly, you should see output that includes information about each product / price that you created, and an output starting with `ACTIVE_PRODUCTS = `containing the products you just created. This step will also automatically update your API keys in the Django admin, as described in [dj-stripe’s instructions](https://dj-stripe.dev/api_keys/#adding-new-api-keys).

3. Next, if you are *not* using the Stripe embedded pricing table:

   1. Paste the `ACTIVE_PRODUCTS` output from the previous step into `apps/subscriptions/metadata.py` overriding what is there. Update any other details you want, for example, the “description” and “features” fields.
   2. Optionally edit the `ACTIVE_PLAN_INTERVALS` variable in `apps/subscriptions/metadata.py` if you don’t plan to include both monthly and annual offerings.

4. Alternatively, if you *are* using the Stripe embedded pricing table set the `STRIPE_PRICING_TABLE_ID` variable in your settings/environment to the pricing table ID you created in Stripe.

Now login and click the “Subscription” tab in the navigation. If you’ve set things up correctly you should see a page that looks like this (it will look slightly different if you are using the Stripe pricing table, or a different CSS framework):

![Subscription Example](/_astro/subscription-example.DVF58m4B_CQWsG.webp)

## Configuring the pricing table

[Section titled “Configuring the pricing table”](#configuring-the-pricing-table)

### Using the embedded Stripe pricing table

[Section titled “Using the embedded Stripe pricing table”](#using-the-embedded-stripe-pricing-table)

The following 5-minute video walks through setting up an embedded pricing table in your project.

Here are the detailed instructions:

If you are using the Stripe embedded pricing table, then all customization happens within the Stripe dashboard. You can change the products, names, descriptions, images, and features by editing the products in Stripe with the desired changes. You can also change the color scheme and other options.

After setting up your pricing table, you should add a custom confirmation URL for each product. This tells Stripe to return to your application to properly process the subscription after it is purchased.

To do this, edit your pricing table, and under “Payment settings” change the confirmation page setting to “Don’t show confirmation page (Redirect customers to your website.)”. It should look like this:

![Stripe Confirmation Page](/_astro/stripe-confirmation-page.d7OnvnxW_cKNJs.webp)

In the URL box, put the following address for (for development), leaving `{CHECKOUT_SESSION_ID}` exactly like it is written:

```plaintext
http://localhost:8000/subscriptions/confirm/?session_id={CHECKOUT_SESSION_ID}
```

In production, enable https, and replace `localhost:8000` with the url of our site. E.g.

```plaintext
https://<yoursite>/subscriptions/confirm/?session_id={CHECKOUT_SESSION_ID}
```

*Make sure you check the option to apply this change to all prices* if you’re using monthly and annual pricing. And then *repeat this process for every product in the pricing page*.

**If you don’t make this change, you will not see subscriptions updated unless you are also running webhooks.**

### Using the in-app pricing table

[Section titled “Using the in-app pricing table”](#using-the-in-app-pricing-table)

If you are using the in-app pricing table, your pricing table configuration is handled in `metadata.py`. You can modify `ACTIVE_PRODUCTS` and `ACTIVE_PLAN_INTERVALS` and see how the page changes.

Whenever you make changes in Stripe, you will need to re-run `./manage.py bootstrap_subscriptions`, and incorporate any necessary changes into the `ACTIVE_PRODUCTS` list.

More background and details on this set up can be found in this [Django Stripe Integration Guide](https://www.saaspegasus.com/guides/django-stripe-integrate/).

## Customer Portal

[Section titled “Customer Portal”](#customer-portal)

Pegasus uses the [Stripe Billing Customer Portal](https://stripe.com/docs/billing/subscriptions/customer-portal) for subscription management after subscription creation To set up the portal you must also enable it in the Stripe dashboard, as outlined in [Stripe’s integration guide](https://docs.stripe.com/customer-management/integrate-customer-portal#configure).

After that, most of the set up should be handled by Pegasus.

**To use the portal you will also need to set up webhooks as per below. Updates made in the portal will not show up if webhooks are not running.**

Pegasus ships with webhooks to handle some common actions taken in the billing portal, including:

* Subscription upgrades and downgrades
* Subscription cancellation (immediately)
* Subscription cancellations (end of billing period)

In the Stripe dashboard, you will need to subscribe to a minimum of `customer.subscription.updated` and `customer.subscription.deleted` to ensure subscription changes through the portal make it to your app successfully.

For more advanced use cases, read through [Stripe’s integration guide](https://docs.stripe.com/customer-management/integrate-customer-portal).

## Webhooks

[Section titled “Webhooks”](#webhooks)

Webhooks are used to notify your app about events that happen in Stripe, e.g. failed payments. More information can be found in [Stripe’s webhook documentation](https://stripe.com/docs/webhooks).

Pegasus ships with webhook functionality ready to go, including default handling of many events taken in Stripe’s checkout and billing portals. That said, you are strongly encouraged to test locally using [Stripe’s excellent guide](https://stripe.com/docs/webhooks/test).

### Webhooks in development

[Section titled “Webhooks in development”](#webhooks-in-development)

In development, the easiest way to set up webhooks is with the [Stripe CLI](https://stripe.com/docs/stripe-cli).

First install the CLI and set it up. Then print your CLI secret with:

```bash
stripe listen --print-secret
```

Or with Docker (no install required):

```bash
docker run --network host --rm -it stripe/stripe-cli listen \
  --print-secret \
  --api-key sk_test_<your_key>
```

Then you can set up your webhook endpoint by running:

```bash
./manage.py bootstrap_dev_webhooks --secret <your_secret>
```

This will create a webhook endpoint for `djstripe` in your application. The `bootstrap_dev_webhooks` will also output a stripe command you can then use to listen for webhooks.

It will look something like this, with the `<uuid>` replaced by your own webhook endpoint’s ID:

```bash
stripe listen \
  --forward-to http://localhost:8000/stripe/webhook/<uuid>/"
```

Or in Docker:

```bash
docker run --network host --rm -it stripe/stripe-cli listen \
  --forward-to  localhost:8000/stripe/webhook/<uuid>/ \
  --api-key sk_test_<your_key>
```

### Webhooks in production

[Section titled “Webhooks in production”](#webhooks-in-production)

The webhook setup changed significantly in version 2025.4.1. If you are on version 2025.4.1 or later, follow these steps, which are taken from the [dj-stripe docs](https://dj-stripe.dev/2.9/usage/webhooks/). For versions earlier than 2025.4.1 see the next section.

* As a superuser, visit the Django admin of your site and navigate to djstripe -> Webhook endpoints -> Add webhook endpoint (or /admin/djstripe/webhookendpoint/add/).

* Select your Stripe account, check “Live mode” and verify the Base url matches your server’s domain, e.g. <https://www.example.com>.

* Under “Advanced” verify the API version is correct.

* Under “Advanced”, choose the enabled events you want to listen for. At a minimum you want:

  * `checkout.session.completed`
  * `customer.subscription.updated`
  * `customer.subscription.deleted`
  * You can add other webhooks as well, (or choose `*` to enable all webhooks) but these are the minimum set required for subscriptions and the billing portal to work properly.

* Verify the other settings (the defaults should be fine) and click “Save”.

After completing these steps, visit your [Stripe dashboard](https://dashboard.stripe.com/webhooks) and confirm the new webhook endpoint has been synced to Stripe. Secrets are managed by dj-stripe, and the webhook should be working!

In production, you should not need to run `stripe listen --forward-to localhost:8000/stripe/webhook/` (or the Docker equivalent).

Once webhooks are properly set up, all underlying Stripe data will be automatically synced from Stripe with no additional setup required on your part.

#### Legacy setup before Pegasus 2025.4.1:

[Section titled “Legacy setup before Pegasus 2025.4.1:”](#legacy-setup-before-pegasus-202541)

**These instructions are only for projects running prior to Pegasus version 2025.4.1. For recent projects, use the instructions above.**

* Navigate to this page [Webhooks](https://dashboard.stripe.com/webhooks) (assuming you’re logged into Stripe).
* Toggle off test mode in the top right corner.
* Click on `Add endpoint`.
* In the `Endpoint URL` field, enter the following URL, replacing `yourserver.com` with your server’s domain name. Note: **the trailing slash is required.**
  * `https://yourserver.com/stripe/webhook/`
* Click on `Select Events to Listen To`.
* Search for `checkout.session.completed`, `customer.subscription.updated`, and `customer.subscription.deleted`, and select them. These events are connected by default (see `apps/subscriptions/webhooks.py` for the source code). You can add other webhooks as well, but these are the minimum set required for subscriptions and the billing portal to work properly.
* Write a description if needed and then click `Add endpoint`.
* **Ensure to set `DJSTRIPE_WEBHOOK_SECRET` in your `settings.py` or as an environment variable.** This value can be found in the Stripe dashboard where you configure your webhook and may be referred to as the `Signing Secret`.

### Custom Webhook Handling

[Section titled “Custom Webhook Handling”](#custom-webhook-handling)

You may want to do more than just update the underlying Stripe objects when processing webhooks, for example, notifying a customer or admin of a failed payment.

Pegasus ships with an example of executing custom logic from a webhook in `apps/subscriptions/webhooks.py`. This basic example will mail your project admins when a Subscription is canceled.

More details on custom webhooks can be found in the [dj-stripe documentation](https://dj-stripe.dev/2.9/usage/webhooks/).

## Supporting multiple currencies

[Section titled “Supporting multiple currencies”](#supporting-multiple-currencies)

If you use Stripe’s embedded pricing table you get multi-currency support out of the box. Follow [the Stripe guide](https://stripe.com/docs/payments/checkout/present-local-currencies?platform=multi-currency-prices) to set your products and prices up for multiple currencies.

If you use an in-app pricing table, Stripe will still present your prices to customers in local currencies, but the pricing table itself will display the prices in your default currency.

## Free trials

[Section titled “Free trials”](#free-trials)

You can easily enable free trials using the option in Stripe’s embedded pricing table. Your customers will be able to sign up with their credit cards for a trial and will have the same experience in your application as someone who is paying for the plan. They’ll be able to update their status from the customer portal, and once the trial period ends they will be billed.

If you’re using trials you must set up webhooks to be notified whether the customer subscribes or cancels at the end of their trial.

It is also possible to use free trials without the embedded pricing table. To do so, you need to add a `trial_end` or `trial_period_days` value to the `subscription_data` in `create_stripe_checkout_session`, as described in [the Stripe documentation](https://stripe.com/docs/billing/subscriptions/trials).

## Feature-Gating

[Section titled “Feature-Gating”](#feature-gating)

Pegasus ships with a demo page with a few feature-gating examples, which are available from a new Pegasus installation under the “Subscription Demo” tab.

These include:

1. Changing content on a page based on the user/team’s subscription.
2. Restricting access to an entire page based on the user/team’s subscription.
3. Showing subscription details like plan, payment details, and renewal date.

### Using the `active_subscription_required` decorator

[Section titled “Using the active\_subscription\_required decorator”](#using-the-active_subscription_required-decorator)

One common use-case is restricting access to a page based on the user’s subscription.

Pegasus ships with a decorator that allows you to do this. You can use it as follows:

```python
@login_required
@active_subscription_required
def subscription_gated_page(request, subscription_holder=None):
    return TemplateResponse(request, 'subscriptions/subscription_gated_page.html')
```

If the user doesn’t have an active subscription, they’ll be redirected to the subscription page to upgrade.

You can also restrict access based on a specific plan (or set of plans), as follows:

```python
@login_required
@active_subscription_required(limit_to_plans=["pro", "enterprise"])
def subscription_gated_page(request, subscription_holder=None):
    return TemplateResponse(request, 'subscriptions/subscription_gated_page.html')
```

In this case the user will only be allowed to view the page if they have a pro or enterprise plan.

### Using the `get_feature_gate_check` helper function

[Section titled “Using the get\_feature\_gate\_check helper function”](#using-the-get_feature_gate_check-helper-function)

For more fine-grained control you can use the `get_feature_gate_check` helper function. This takes in two arguments, the `subscription_holder` (usually a User or Team), and optionally the same `limit_to_plans` list above, and returns a `FeatureGateCheckResult`, which includes whether the check passed (subscription holder has a subscription of the right type), and an optional message explaining the answer.

Example usage:

```python
from apps.subscriptions.feature_gating import get_feature_gate_check




def my_view(request):
    check_result = get_feature_gate_check(request.team, ["professional"])
    if check_result.passed:
        do_actions_pro_only()
    else:
        logging.info(f"Pro actions skipped: {check_result.message}")
```

## Per-Unit / Per-Seat Billing

[Section titled “Per-Unit / Per-Seat Billing”](#per-unit--per-seat-billing)

Pegasus supports per-unit / per-seat billing. Choose this option when building your project to enable it. **It is not recommended to use the Stripe embedded pricing table if you are using per-unit billing.**

For Team-based builds the default unit is Team members. For non-Team builds you will have to implement your own definition of what to use for billing quantities.

Here is [a short video walkthrough of this feature](https://youtu.be/v_ayMEj924w).

### Choosing your billing model

[Section titled “Choosing your billing model”](#choosing-your-billing-model)

Refer to the [Stripe documentation](https://stripe.com/docs/products-prices/pricing-models) for how to set this up in your Price model. You can use any of:

* Standard pricing (e.g. $10/user)
* Package pricing (e.g. $50 / 5 new users)
* Tiered pricing (graduated or volume) (e.g. $50 for up to 5 users, $5/user after that)

### Displaying prices on the subscriptions page

[Section titled “Displaying prices on the subscriptions page”](#displaying-prices-on-the-subscriptions-page)

For per-unit billing you can no longer display a single upgrade price since it is dependent on the number of units.

To avoid displaying an “unknown” price when showing the subscription, you can add a `price_displays` field to your `ProductMetadata` objects that takes the following format:

```python
ProductMetadata(
    stripe_id='<stripe id>',
    name=_('Graduated Pricing'),
    description='A Graduated Pricing plan',
    price_displays={
        PlanInterval.month: 'From $10 per user',
        PlanInterval.year: 'From $100 per user',
    }
),
```

This will show “From $10 per user” or “From $100 per user” when the monthly or annual plan is selected, respectively.

#### Per-seat pricing and the embedded pricing table.

[Section titled “Per-seat pricing and the embedded pricing table.”](#per-seat-pricing-and-the-embedded-pricing-table)

**Though it is possible to use the pricing table with per-seat billing, it is not recommended.**

This is because Stripe does not allow you to pass per-seat quantities with the Pricing Table, so if you use the pricing table with per-seat pricing, your users will be able to choose the number of “seats” (quantity) when they check out.

This is different from the expected behavior, which is that your app sets the quantity explicitly based on the number of team members (or your own business logic, for user-based builds). Since Pegasus is set up to automatically update the subscription quantity based on the “usage” in your application, this could result in your users buying a certain number of seats, and then unexpectedly having the price change to the amount they are actually using.

You can disable Pegasus automatically updating the per-seat quantity, and then modify your application’s business logic to be based off the “quantity” property of the user/team’s subscription, though this is not an officially supported workflow.

### Keeping your Stripe data up to date

[Section titled “Keeping your Stripe data up to date”](#keeping-your-stripe-data-up-to-date)

When changes are made that impact a user’s pricing, you will need to notify Stripe of the change. This should happen automatically every 24 hours as long as you have enabled celery and celerybeat. You can also trigger it manually via a management command `./manage.py sync_subscriptions`.

To ensure this command works properly, you must implement two pieces of business logic:

1. You must update the billing model’s `billing_details_last_changed` field any time the number of units has change.
2. You must override the `get_quantity` function on your billing model to tell Stripe how many units it contains.

**If you use Teams with per-seat billing this will be automatically handled for you by default.** All you have to do is run the management command or connect it to a periodic task.

For User-based, or more complex billing models with Teams you will have to implement these changes yourself.

#### A User-based example

[Section titled “A User-based example”](#a-user-based-example)

Here’s a quick example of how you might do this with User-based billing.

Let’s say your app allows users to define workspaces and they are billed based on the number of workspaces they create.

You might have a workspace model that looks like this:

```python
class Workspace(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='workspaces')
    # other workspace fields here
```

Then you would want to update the `billing_details_last_changed` field of the `CustomUser` object every time a workspace was added or removed (step 1, above). That might look something like this, using [Django signals](https://docs.djangoproject.com/en/stable/topics/signals/):

```python
@receiver(post_save, sender=Workspace)
def update_billing_date_on_workspace_creation(sender, instance, created, **kwargs):
    if created:
        instance.user.billing_details_last_changed = timezone.now()
        instance.user.save()




@receiver(post_delete, sender=Workspace)
def update_billing_date_on_workspace_deletion(sender, instance, **kwargs):
    instance.user.billing_details_last_changed = timezone.now()
    instance.user.save()
```

The other piece of code you would need to add is associating the `get_quantity` function on the user with the number of workspaces they have.

You’d want to add a method like this to `CustomUser`:

```python
class CustomUser(SubscriptionModelBase, AbstractUser):
    # other stuff here


    def get_quantity(self):
        return self.workspaces.count()
```

## Stripe in Production

[Section titled “Stripe in Production”](#stripe-in-production)

In development, you will use your Stripe test account, but when it comes time to go to production, you will want to switch to the live account.

This entails:

1. Setting `STRIPE_LIVE_MODE` to `True` in your settings/environment.
2. Populating `STRIPE_LIVE_PUBLIC_KEY` and `STRIPE_LIVE_SECRET_KEY` in your environment.
3. Updating your `ACTIVE_PRODUCTS` to support both test and live mode (see below)

### Managing Test and Live Stripe Products

[Section titled “Managing Test and Live Stripe Products”](#managing-test-and-live-stripe-products)

When you run `bootstrap_subscriptions` Pegasus will generate a list of your `ACTIVE_PRODUCTS` that includes hard-coded Stripe Product IDs. This works great in development, but presents a problem when trying to enable live mode.

One way to workaround this is to replace the hard-coded product IDs with values from your django settings.

E.g. in `apps/subscriptions/metadata.py` change from:

```python
ACTIVE_PRODUCTS = [
    ProductMetadata(
        stripe_id='prod_abc',  # change this line for every product
        slug='starter',
        ...
```

To:

```python
ACTIVE_PRODUCTS = [
    ProductMetadata(
        stripe_id=settings.STRIPE_PRICE_STARTER,  # to something like this
        slug='starter',
        ...
```

Then in your `settings.py` file, you can define these values based on the `STRIPE_LIVE_MODE` setting:

```python
STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", False)


STRIPE_PRICE_STARTER = "prod_xyz" if STRIPE_LIVE_MODE else "prod_abc"
```

You will have to do this for each of your products.

## Troubleshooting

[Section titled “Troubleshooting”](#troubleshooting)

**Stripe is not returning to the right site after accessing checkout or the billing portal.**

There are two settings that determine how Stripe will call back to your site.

If Stripe is returning to the *wrong site entirely* it is likely a problem with your Django `Site` configuration. See the documentation on [absolute URLs](/configuration/#absolute-urls) to fix this.

If Stripe is returning to the correct site, *but over HTTP instead of HTTPS* (or vice versa) then you need to change the `USE_HTTPS_IN_ABSOLUTE_URLS` setting in `settings.py` or a production settings file.

**Subscriptions are not being created in your app when using the embedded pricing table.**

Make sure that you have updated the confirmation page for every product and every price to `https://<yoursite>/subscriptions/confirm/?session_id={CHECKOUT_SESSION_ID}` as described above.

You can also turn on webhooks to fix this, though it’s recommended to use the custom confirmation page to provide a better user experience.

**Stripe webhooks are failing with a signature error.**

If you get an error like “No signatures found matching the expected signature for payload” or similar there are a few things to check:

First, double check all of your API keys and secrets in your environment/settings files. These are:

* `STRIPE_LIVE_PUBLIC_KEY` and `STRIPE_LIVE_SECRET_KEY` (for live mode), or `STRIPE_TEST_PUBLIC_KEY` and `STRIPE_TEST_SECRET_KEY` (for test mode)
* `STRIPE_LIVE_MODE` should match whether you’re in live / test mode.
* `DJSTRIPE_WEBHOOK_SECRET` should match the secret from the Stripe dashboard.

**Getting “DataError: value too long for type character varying(9)” from webhooks**

This is caused by an issue in `dj-stripe` where a database column is not large enough to support values that were added by Stripe in API version xxx.

There are two recommended workarounds to this:

1. Downgrade your Stripe API version. The most recent supported version is `2023-10-16`. Your API version can be found in the “Developers” section of the Stripe dashboard.
2. Manually add a database migration to your project to increase the size of the column. If you build with Pegasus version 2024.10 or later, this migration will be automatically added to your project. Otherwise, see below for instructions on adding it.

Run:

```bash
python manage.py makemigrations web --empty`
```

Then copy the following into the generated file:

```python
class Migration(migrations.Migration):
    dependencies = [
        ("web", "0001_initial"),
        ("djstripe", "0012_2_8"),
    ]


    operations = [
        migrations.RunSQL(
            "ALTER TABLE djstripe_paymentintent ALTER COLUMN capture_method TYPE varchar(255);"
        ),
    ]
```

*You can [read more about this issue and workarounds here](https://github.com/dj-stripe/dj-stripe/issues/2038#issuecomment-2119244742).*