DEV Community

Ato Deshi
Ato Deshi

Posted on

How to setup Stripe subscriptions with Django and React

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

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

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

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:

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)
Enter fullscreen mode Exit fullscreen mode

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:

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)
Enter fullscreen mode Exit fullscreen mode

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:

from django.urls import path
from .views import checkout_session_view

urlpatterns = [
    path('checkout/session/', checkout_session_view, name='checkout-session')
]
Enter fullscreen mode Exit fullscreen mode

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:

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

@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)
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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'),
]
Enter fullscreen mode Exit fullscreen mode

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.

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

Let’s create another endpoint for this:

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)
Enter fullscreen mode Exit fullscreen mode

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:

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'),
]
Enter fullscreen mode Exit fullscreen mode

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.

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

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:

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

    cancel_at_period_end = models.BooleanField(default=False)
    cancel_at = models.DateTimeField(blank=True, null=True)
Enter fullscreen mode Exit fullscreen mode

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:

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

    # ...
Enter fullscreen mode Exit fullscreen mode

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

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>
)
Enter fullscreen mode Exit fullscreen mode

Which should look something like this:

Your subscription was canceled CV Forge

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

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!

Top comments (0)