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:
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:
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
- Create an account over at https://stripe.com/
- Install the stripe-cli by following the official guide https://docs.stripe.com/stripe-cli
- Enable test mode.
- Get your test secret key from the dashboard, it should start with
sk_test_
. - Next we create a product in Stripe, make sure it is set to recurring since we are working with subscriptions.
- Finally we retrieve the price_id by clicking the 3 dots and selecting ‘Copy price ID’:
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)
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)
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')
]
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>
)
}
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)
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
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'),
]
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>
)
}
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.
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)
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'),
]
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>
)
}
Moving to production
- Go to your product page in the stripe dashboard and copy over your product to live mode (see screenshot)
- Next disable test mode
- Retrieve your production secret key and webhook secret
- Find your product and copy the price_id again like we did for test mode
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)
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
# ...
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>
)
Which should look something like this:
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)