How to setup Stripe subscriptions with Django and React
Tags: webdev, react, python, stripe
Published: 2024-06-14 20:06:46

## what we will be building
Recently I added a subscription model to one of my projects, https://www.cvforge.app/, in this app users can create and download resumes, taking advantage of premade examples to help you get started.

Some of these features are exclusively available to Pro members:

![Upgrade button CV Forge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9pt153433v8l6n6qpxx5.png)

New users are welcomed by an ‘upgrade’ button next to their user button. Clicking this will redirect them to a Stripe Checkout page, like so:

![Checkout page CV Forge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q3aj8c2usk5rwlhtymvq.png)

After a user has subscribed they will have access to all features.

## Getting started
For this tutorial we will be using Django and React with the stripe Python sdk, but nothing here is specific to those languages or frameworks so feel free to use what ever you prefer.

## Setting up Stripe

1. Create an account over at https://stripe.com/
2. Install the stripe-cli by following the official guide https://docs.stripe.com/stripe-cli
3. Enable test mode.
4. Get your test secret key from the dashboard, it should start with `sk_test_`.
5. Next we create a product in Stripe, make sure it is set to recurring since we are working with subscriptions.
6. Finally we retrieve the price_id by clicking the 3 dots and selecting ‘Copy price ID’:


![Stripe dashboard CV Forge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/covurux1hod900vzx8d3.png)

## Creating a Checkout Session
If you do not have a Django project setup please checkout Django’s guide on getting started https://docs.djangoproject.com/en/5.0/intro/, from here on I will assume you have a running django project.

Let’s start by installing the stripe sdk.

`pip install stripe`

Next we will create a new app to handle everything payment related.

`python manage.py startapp payments`

In here we will create the appropriate models:

```python
class Customer(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='customer')
    source_id = models.CharField(max_length=255, blank=True, null=True)


class Subscription(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='subscriptions')
    source_id = models.CharField(max_length=255, blank=True, null=True)

    currency = models.CharField(max_length=255, blank=True, null=True)
    amount = models.FloatField(blank=True, null=True)

    started_at = models.DateTimeField(blank=True, null=True)
    ended_at = models.DateTimeField(blank=True, null=True)
    canceled_at = models.DateTimeField(blank=True, null=True)

    status = models.CharField(max_length=255, blank=True, null=True)
```

Both of these models will be modeling models in stripe’s system, source_id here refers to the ids in the corresponding stripe models. We use these models to link a subscription to a user in our system.

Next we will create a checkout view:

```python
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from django.conf import settings
import stripe

@api_view(['POST'])
def checkout_session_view(request):
    try:
        stripe.api_key = '<stripe-api-key>'

        # check if user already has a customer assosciated, otherwise create it
        if not hasattr(request.user, 'customer'):
            stripe_customer = stripe.Customer.create(email=request.user.email)
            customer = Customer.objects.create(user=request.user, source_id=stripe_customer.id)
            customer.save()

        current_subscription = request.user.customer.subscriptions.last()

        # our app does not support multiple subscriptions per user
        if current_subscription and current_subscription.status == 'active':
            return Response({'error': 'You already have an active subscription'},
                            status=status.HTTP_400_BAD_REQUEST)

        session = stripe.checkout.Session.create(
            line_items=[
                {
                    'price': '<stripe-price-id>',
                    'quantity': 1,
                },
            ],
            mode='subscription',
            success_url=settings.CLIENT_URL + '/checkout/success',
            cancel_url=settings.CLIENT_URL + '/checkout/canceled',
            automatic_tax={'enabled': True},
            customer=request.user.customer.source_id,
            customer_update={
                'address': 'auto' # let stripe handle the address
            }
        )
    except Exception as e:
        return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
    
    return Response({'url': session.url}, status=status.HTTP_201_CREATED)
```

As you can see I respond with a url, you can of course also respond with a redirect directly, I however like handeling all of this in the frontend.

Finally we will add these endpoints to our urls:

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

urlpatterns = [
    path('checkout/session/', checkout_session_view, name='checkout-session')
]
```

Now we will create the before mentioned upgrade button. For this I am using React with react-query in combination with Orval, but you can of course use anything you would like for this:

```react
function UpgradeButton() {
  const { mutate, isPending }= useCheckoutSessionCreate({
    mutation: {
      onSuccess: (data) => {
        window.location.href = data.url;
      },
      onError: (error) => {
        // handle error..
      },
    },
  });

  function handleClick() {
    mutate({ data: {} })
  }

  return (
    <Button onClick={handleClick} disabled={isPending}>
      <CrownIcon className="w-4 h-4 mr-2" />
      Upgrade
    </Button>
  )
}
```

_Note: don’t forget to add a success and canceled page for after checkout_

## Handeling Webhooks
We will use stripe’s webhooks to keep our models in sync with stripe. Retrieve the stripe webhook secret from the stripe dashboard.

Add a second endpoint to handle the stripe webhooks:

```python
@api_view(['POST'])
def checkout_webhook_view(request):
    stripe.api_key = '<stripe-api-key>'

    payload = request.body
    sig_header = request.META['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = 'stripe-webhook-secret'

    # Verify that the request comes from stripe
    try:
        event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
    except ValueError:
        return Response(status=status.HTTP_400_BAD_REQUEST)
    except stripe.error.SignatureVerificationError:
        return Response(status=status.HTTP_400_BAD_REQUEST)

    if event['type'] == 'customer.subscription.created':
        handle_subscription_created(event)
    elif event['type'] in ['customer.subscription.updated', 'customer.subscription.deleted']:
        handle_subscription_updated(event)

    return Response(status=status.HTTP_200_OK)
```

For handeling the actual events I like to create a seperate services.py file, where I have two handlers, one for creating and one for updating or deleting:

```python
def handle_subscription_created(event):
    stripe_subscription = stripe.Subscription.retrieve(event['data']['object']['id'])
    customer = Customer.objects.get(source_id=stripe_subscription.customer)

    subscription = Subscription.objects.create(
        customer=customer,
        source_id=stripe_subscription.id,
        status=stripe_subscription.status,
        currency=stripe_subscription.currency,
        amount=stripe_subscription.plan.amount / 100,
        started_at=datetime.fromtimestamp(stripe_subscription.created),
    )
    subscription.save()

    return subscription

def handle_subscription_updated(event):
    stripe_subscription = stripe.Subscription.retrieve(event['data']['object']['id'])

    subscription = Subscription.objects.get(source_id=stripe_subscription.id)

    subscription.status = stripe_subscription.status

    if stripe_subscription.canceled_at:
        subscription.canceled_at = datetime.fromtimestamp(stripe_subscription.canceled_at)
    if subscription.ended_at:
        subscription.ended_at = datetime.fromtimestamp(stripe_subscription.ended_at)

    subscription.save()

    return subscription
```

When a user starts a subscription by going through checkout we will receive a `customer.subscription.created` event, which we will use to create the subscription in our system.

When a user cancels or renews their subscription we will receive a `customer.subscription.updated` event, which we will use to update that subscription.

And finally when the billing period of an expired subscription ends we will receive an `customer.subscription.deleted` event at which point the subscription will no longer be set to ‘active’.

Make sure to add the new view to your urls:

```python
from django.urls import path
from .views import checkout_session_view, checkout_webhook_view

urlpatterns = [
    path('checkout/session/', checkout_session_view, name='checkout-session'),
    path('checkout/webhook', checkout_webhook_view, name='checkout-webhook'),
]
```

And update your settings.py to stop append slash from being enforced:

`APPEND_SLASH = False`

You can now test this by enabling test mode and starting the stripe-cli and make sure it points to your webhook url.

`stripe listen — forward-to localhost:8000/api/checkout/webhook`

Now go through your checkout flow in the frontend, you can use the test credit card `4242 4242 4242 4242` with a valid expired date in the future.

Your endpoint should pick up on the webhook events and update your models accordingly. Consider creating an `admin.py` to easily monitor your models.

## Updating the frontend
Finally we will update our frontend to hide and disable certain features if the users subscription status is not active. Keep in mind to always provide clear feedback.

For example instead of disabling a button have it trigger a dialog to communicate something being a paid feature.

```react
function DownloadButton() {
  const { subscription } = useUser(); // custom hook to retrieve user details
  
  function handleDownload() {
    // do something...
  }

  function handleCheckout() {
    // do something...
  }

  if (subscription.status === 'active') {
    <Button onClick={handleDownload}>
      <DownloadIcon className="w-4 h-4 mr-2" /> Download
    </Button>
  }

  return (
    <AlertDialog>
      <AlertDialogTrigger asChild>
        <Button>
          <DownloadIcon className="w-4 h-4 mr-2" /> Download
        </Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>This is a paid feature</AlertDialogTitle>
          <AlertDialogDescription>
            In order to use this feature, you need to upgrade to our Pro plan.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>
              Close
          </AlertDialogCancel>
          <AlertDialogAction onClick={handleCheckout}>
            <CrownIcon className="w-4 h-4 mr-2" /> Upgrade Now
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}
```

As you can see I render the same download button, but if the user does not have an active subscription it triggers a dialog instead.

## Letting users manage their subscriptions
As user you want to cancel or renew your subscription and download your invoices, for this stripe has a prebuild portal we can link to.

![Pro plan CV Forge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f1c3y5ayhuauroihftxo.png)

Let’s create another endpoint for this:

```python
def customer_portal(request):
    stripe.api_key = '<stripe-api-key>'

    try:
        session = stripe.billing_portal.Session.create(
            customer=request.user.customer.source_id,
            return_url=settings.CLIENT_URL + '/settings',
        )
    except Exception as e:
        return JsonResponse({'error': str(e)}, status=400)

    return JsonResponse({'url': session.url}, status=201)
```

Make sure to enable the portal in your stripe settings https://dashboard.stripe.com/settings/billing/portal

Again we will respond with a url and let the frontend handle it, but of course you can also simply respond with a redirect here. We make use of the `customer.source_id` that we create when a checkout session is created.

We update our urls once more:

```python
from django.urls import path
from .views import checkout_session_view, checkout_webhook_view, customer_portal_view

urlpatterns = [
    path('checkout/session/', checkout_session_view, name='checkout-session'),
    path('checkout/webhook', checkout_webhook_view, name='checkout-webhook'),
    path('customer/portal/', customer_portal_view, name='customer-portal'),
]
```

In the frontend we create card as showed above, with a button which will redirect the user. Make sure to only render this for users that have an active subscription.

```react
function CustomerPortal() {
  const { subscription } = useUser();

  const { mutate, isPending } = useCustomerPortalCreate({
    mutation: {
      onSuccess(data) {
        // redirect page
        window.location.href = data.url;
      },
      onError(error) {
        // handle error...
      },
    },
  });

  function handlePortal() {
    mutate({ data: {} })
  }

  if (subscription.status !== 'active') {
    return null;
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>
          <CrownIcon className="w-5 h-5 mr-2" /> plan
        </CardTitle>
        <CardDescription>
          You will be redirected to our customer portal where you can cancel or renew your subscription and view
          your invoices
        </CardDescription>
      </CardHeader>
      <CardFooter>
        <Button onClick={handlePortal} disabled={isPending} variant="secondary">
          Manage Subscription <SquareArrowOutUpRightIcon className="w-4 h-4 ml-2" />
        </Button>
      </CardFooter>
    </Card>
  )
}
```

## Moving to production

1. Go to your product page in the stripe dashboard and copy over your product to live mode (see screenshot)
2. Next disable test mode
3. Retrieve your production secret key and webhook secret
4. Find your product and copy the price_id again like we did for test mode

![Stripe products CV Forge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bx34jovoqm1hhzcij5ok.png)


Make sure your application takes advantage of these new values in your production environment.

Congratiulations you now have a working subscription in your project!

## Final notes
You are now ready to go, but there are some potential edge cases you want to consider handeling.

## Canceled subscriptions that are still active
When a user cancels their subscription they still have access till the end of the subscription period, you might want to inform the user of this. Stripe provides us with some additional values, which we can add to our models.

First we update our models:

```python
class Subscription(models.Model):
    # ...

    cancel_at_period_end = models.BooleanField(default=False)
    cancel_at = models.DateTimeField(blank=True, null=True)
```

`cancel_at_period_end` indicates that our subscription will expire at the end of the billing period and `cancel_at` indicates at what date this will happen.

in our services we should also handle this to ensure these values are updated accordingly:

```python
def handle_subscription_updated(event):
    # ...

    subscription.cancel_at_period_end = stripe_subscription.cancel_at_period_end
    subscription.cancel_at = datetime.fromtimestamp(
        stripe_subscription.cancel_at) if stripe_subscription.cancel_at else None

    # ...
```

Next in our frontend we can handle it as such, where we render an alert if `cancel_at_period_end`:

```react
subscription.cancel_at_period_end && (
  <Alert>
    <AlertCircleIcon className="h-4 w-4" />
    <AlertTitle>Your subscription has been canceled</AlertTitle>
    <AlertDescription>
      You can continue using our services till the end if your current billing period. Your subscription
      will end on {format(new Date(subscription.cancel_at), 'MMMM dd, yyyy')}
     </AlertDescription>
  </Alert>
)
```

Which should look something like this:

![Your subscription was canceled CV Forge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/brpracifrwh1favev2by.png)


## Verified emails
It’s important that users have verfied email addresses when they subscribe to your service. Depending on how your auth is implemented you might have to check on this before allowing a user to create a checkout session. Keep this in mind!

---

That is it! Thank you so much for following along with this guide, I hope you found it helpful. If you have any questions reach out to me on [X/Twitter](https://x.com/atodeshi)

To see this example in action or if you are simply in need of a resume go checkout my latest project over at [CV Forge](https://www.cvforge.app/)!