Skip to main content

Build a paid content site with replit.web and Stripe

In this tutorial, we'll combine replit.web and Stripe to build a digital content storefront. Anyone with a Replit account will be able to log into our website and purchase premium PDFs. Our site will also keep track of what each user has purchased, so they can build up a library.

By the end of this tutorial, you'll be able to:

  • Build a dynamic web application with replit.web.
  • Use Stripe to sell digital content.

Paid content site functionality

Getting started

To get started, create a Python repl.

Create python repl

Our application will have the following functionality:

  • Users can log in with their Replit accounts.
  • Users can purchase PDFs.
  • Users can view free PDFs and PDFs that they've previously purchased.
  • Administrators can upload new PDFs.

We've covered both replit.web and Stripe in previous tutorials, so some aspects of the following may be familiar if you've built a brick shop.

We'll start our app off with the following import statements in

import os, shutil
import stripe
from flask import Flask, render_template, render_template_string, flash, redirect, url_for, request, jsonify
from flask.helpers import send_from_directory
from werkzeug.utils import secure_filename
from replit import db, web
from functools import wraps

Here we're importing most of what we'll need for our application:

  1. Python's os and shutil packages, which provide useful functions for working with files and directories.
  2. Stripe's Python library.
  3. Flask, our web framework and the heart of the application.
  4. A Flask helper function send_from_directory, which will allow us to send PDFs to users.
  5. A function secure_filename from the Werkzeug WSGI (which Flask is built on) that we'll use when admins upload PDFs and other files.
  6. Replit's web framework and Replit DB integration, which we'll use for user authentication and persistent data storage.
  7. The wraps tool from Python's functools, which we'll use to make authorization decorators for restricting access to sensitive application functionality.

Now that the imports are out of the way, let's start on our application scaffold. Add the following code to

app = Flask(__name__,

This code initializes our Flask application. We've added a static_folder and static_url_path so that we can serve static files directly from our repl's file pane without writing routing code for each file. This will be useful for things like images and stylesheets.

Add the following code to initialize your application's secret key:

# Secret key
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]

Our secret key will be a long, random string. You can generate one in your repl's Python console with the following two lines of code:

import random, string
''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(20))

Random string

Rather than putting this value directly into our code, we'll retrieve it from an environment variable. This will keep it out of source control and is good practice for sensitive data.

In your repl's Secrets tab, add a new key named SECRET_KEY and enter the random string you just generated as its value.

Repl secrets

Once that's done, return to and add the code below to initialize our Replit database:

# Database setup
def db_init():
if "content" not in db.keys():
db["content"] = {}

if "orders" not in db.keys():
db["orders"] = {}

# Create directories
if not os.path.exists("static"):

if not os.path.exists("content"):


Replit's Database can be thought of and used as one big Python dictionary that we can access with db. Any values we store in db will persist between repl restarts.

We've written a function to initialize the database as we may want to do it again if we need to refresh our data during testing. Whenever we initialize our database, we will also create the content and static directories, which will contain user-uploaded files.

Next we need to create our UserStore (a secondary database keyed by username), and list of admins:

users = web.UserStore()


Make sure to replace the contents of the ADMINS list with your Replit username.

Finally, let's make our root page. Add the following code, and then run your repl.

# Main app
def index():
return f"Hello {}"

Because we've added the @web.authenticated function decorator to our index page, it will only be available to logged-in users. You should see this now, as your app will show a login button. Click on that button, and authorize your application to use Replit authentication in the window that pops up.

Login button

Having done that, you should now see the greeting we implemented above. If you send your repl to a friend, they will also be able to log in and see their Replit username on the greeting message.

Content upload and other admin functionality

Before we do anything else with our site, we need to have some PDFs to sell. While we could manually upload our PDFs to our repl and write code to add each one to the database, it will make our site more user-friendly if we include an upload form for this purpose.

This upload form should only be accessible by admins, so we can enforce some level of quality control. We'll also create a route that allows admins to refresh the application database.

Access control

Add the following functions to, just below the line where you've assigned ADMINS:

# Helper functions
def is_admin(username):
return username in ADMINS

# Auth decorators
def admin_only(f):
def decorated_function(*args, **kwargs):

if not is_admin(
flash("Permission denied.", "warning")
return redirect(url_for("index"))

return f(*args, **kwargs)

return decorated_function

The code in the second function may look a bit strange if you haven't written your own decorators before. Here's how it works: admin_only is the name of our decorator. You can think of decorators as functions that take other functions as arguments. (The two code snippets below are for illustration and not part of our program.) Therefore, if we write the following:

def admin_function():
return f"Hello admin"


it will be roughly equivalent to:

def admin_function():
return f"Hello admin"


So whenever admin_function gets called, the code we've defined in decorated_function will execute before anything we define in admin_function. This means we don't have to include an if not is_admin check in every piece of admin functionality. As per the code, if a non-admin attempts to access restricted functionality, our app will flash a warning message and redirect them to the home page.

Now we can create the following admin routes below the definition of the index function:

# Admin functionality
@app.route('/admin/content-create', methods=["GET", "POST"])
def content_create():

def flush_db():

Note that both of these functions are protected with the @web.authenticated and @admin_only decorators, restricting their use to logged-in admins.

The first function will let our admins create content, and the second will allow us to flush the database. While the second function will be useful during development, it's not something we'd want to use in a finished application, as our database will contain records of user payments.

Content creation form

Before we can fill in the code for content creation, we need to create the web form our admins will use. As the form creation code will include a lot of information and functionality and require several special imports, we're going to put it in its own file so we can keep a navigable codebase. In your repl's files pane, create

Create file

Enter the following import statements at the top of

from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import StringField, TextAreaField, SubmitField, FloatField, ValidationError
from wtforms.validators import InputRequired, NumberRange, Length
from replit import db

Here we're importing from WTForms, an extensive library for building web forms, and Flask WTF, a library which bridges WTForms and Flask. We're also importing our Replit database, which we'll need for uniqueness validations.

The structure of our forms is dictated by the structure of our database. In our db_init function, we defined two dictionaries, "content" and "orders". The former will contain entries for each of the PDFs we have for sale. These entries will contain the PDF's filename as well as general metadata. Thus, our "content" data structure will look something like this:

"content": {
"ID": {
"name": "NAME",
"description": "DESCRIPTION",
"file": "PDF_FILENAME",
"preview_image": "IMAGE_FILENAME",
"price": 5

The ID value will be the content's name, all-lowercase, with spaces replaced by hyphens, so we can use it in our app's URLs. Let's create a function that turns names into IDs, in, just below our imports:

def name_to_id(name):
return name.lower().replace(" ", "-")

Now we can create our form. With Flask WTF, we model a form as a class inheriting from FlaskForm. This class takes in the value of Flask's request.form and applies validations to the fields therein. Add the following class definition to the bottom of

class ContentCreateForm(FlaskForm):
name = StringField(

description = TextAreaField(

file = FileField(
"PDF file",
FileAllowed(['pdf'], "PDFs only.")

image = FileField(
"Preview image",
FileAllowed(['jpg', 'jpeg', 'png', 'svg'], "Images only.")

price = FloatField(
"Price in USD (0 = free)",

submit = SubmitField("Create content")

def validate_name(form, field):
if name_to_id( in db["content"].keys():
raise ValidationError("Content name already taken.")

When admins create content, they'll specify a name, a description, and a price, as well as upload both the PDF and a preview image. We've used WTForm's validators to restrict the file types that can be uploaded for each. Should we decide to branch out from selling PDFs in the future, we can add additional file extensions to the file field's FileAllowed validator. We could also make individual fields optional by removing their InputRequired() or FileRequired() validators.

The final part of our form is a custom validator to reject new PDFs with IDs that match existing PDFs. Because we're validating on ID rather than name, admins won't be able to create PDFs with the same name but different capitalization (e.g. "Sherlock Holmes" and "SHERLOCK HOLMES").

We've finished creating our form class. Now we can return to and import the class with the following import statement, which you can add just below the other imports at the top of the file.

from forms import name_to_id, ContentCreateForm

Note that we've also imported name_to_id, which we'll use when populating the database.

Admin routes

We can now use our form to implement our content creation route. Populate the content_create function with this code:

# Admin functionality
@app.route('/admin/content-create', methods=["GET", "POST"])
def content_create():

form = ContentCreateForm()

if request.method == "POST" and form.validate():
content_name =
content_id = name_to_id(content_name)
content_price =

content_file =
content_filename = secure_filename(content_file.filename)'content', content_filename))

image_file =
image_filename = secure_filename(image_file.filename)'static', image_filename))

content_paywalled = content_price > 0

# Construct content dictionary
db["content"][content_id] = {
"name": content_name,
"filename": content_filename,
"preview_image": image_filename,
"paywalled": content_paywalled,
"price": content_price,

flash("Content created!")
return redirect(url_for('content', content_id=content_id))

return render_template("admin/content-create.html",
form = form,

First, we create an instance of ContentCreateForm. This will automatically use the values in request.form, including the uploaded files. We then check whether the current request is an HTTP POST, and we call validate() on the form. Behind the scenes, this method will run all of our field validators, and return error messages to the user for fields that fail validation. It will only return True once all fields validate.

Once we know we've got valid form input, we can save its data to our database. We construct our content's ID using the helper function from, store our content's price value, and then save our PDF and image files to the content and static directories. Saving images to static will allow Flask to serve them without us writing additional code. We'll need custom code for PDFs, however, as we need to ensure they're only accessible to paying customers.

We use the variable content_paywalled to determine whether this PDF should be available for free or behind a paywall.

Finally, we save our content's details to the database and redirect the creator to the content page, which we'll build in the next section.

At the bottom of the function, we render our content-create page and tell it which form to use. This will happen regardless of whether the initiating request was a GET or a POST. We'll create the template and define the context function when we build the application front-end.

Next, we need to create our database flushing functionality. Populate the flush_db function with the following code:

def flush_db():
# clear db
del db["content"]
del db["orders"]

# clear users
for _, user in users.items():
user["content_library"] = []

# delete content and images

# reinit

return redirect(url_for("index"))

After deleting all database content and uploaded files, we call db_init() to start afresh. Keep in mind that this function should not be used if you're storing real user data unless you've made a backup.

Content viewing and paywalls

Now that our site admins can upload PDFs, we need a way for users to view them. We'll start by creating another helper function, just below the definition of is_admin:

def owns_content(username, content_id):
if "content_library" in users[username].keys() and users[username]["content_library"] is not None:
return content_id in users[username]["content_library"]

We have to do several checks on our user's content_library, as it can be in a few different states – the key might not exist, or it might be set to None, or it might be a list. We'll use this function to determine which content has been purchased by a given user and thus avoid writing all these checks again.

Now we need to create our application's content-viewing routes. We'll start by rewriting the / route so that it renders a template rather than a greeting string. This page will contain a list of PDFs. Change the code in index to the following:

# Main app
def index():
return render_template("index.html",

Then we'll write a route that displays individual PDF metadata, by adding this function just below the definition of index:

def content(content_id):
return render_template("content.html",

The content_id value will be the same ID that we're using in our database. This page will contain the content's name, preview image, description, and either a download link, or a purchase link, depending on whether the PDF is paywalled, and whether the current user has purchased it.

Lastly, we need a route that handles downloading actual PDFs. Add the following code just below the content function definition:

def content_file(content_id):

content = db["content"][content_id]

if not content["paywalled"] or owns_content(, content_id):
return send_from_directory("content", path=content["filename"])
return "Access denied."

If the current user owns this PDF, or it's not paywalled, we use Flask's send_from_directory to allow them to download it. Otherwise, we return an error message.

Creating the application frontend

We have most of our application back-end, so now let's create the front-end. We'll do this using HTML and Jinja, Flask's front-end templating language.

First, let's create the following HTML files in a new directory called templates:

|__ admin/
| |__ content-create.html
|__ _macros.html
|__ content.html
|__ index.html
|__ layout.html

Folder structure

Once you've created these files, let's populate them, starting with templates/layout.html:

<!DOCTYPE html>
<title>Books and Manuscripts</title>
{% with messages = get_flashed_messages() %} {% if messages %}
<ul class="flashes">
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
{% endif %} {% endwith %} {% if name != None %}
<p>Logged in as {{ username }}</p>
{% endif %} {% block body %}{% endblock %}

We'll use this file as the base of all our pages, so we don't need to repeat the same HTML. It contains features we want on every page, such as flashed messages, and an indication of who's currently logged in. All subsequent pages will inject content into the body block:

{% block body %}{% endblock %}

Next, we need to populate another helper file, templates/_macros.html:

{% macro render_field(field) %}
<dt>{{ field.label }}</dt>
{{ field(**kwargs)|safe }} {% if field.errors %}
<ul class="errors">
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
{% endif %}
{% endmacro %}

This file defines the Jinja macro render_field, which we'll use to provide our form fields with error-handling, provided by WTForms.

We'll use this macro in templates/admin/content-create.html, which we'll populate with the following code:

{% extends "layout.html" %} {% block body %} {% from "_macros.html" import
render_field %}
<h1>Upload content item</h1>
{{ render_field( }} {{ render_field(form.description) }} {{
render_field(form.file) }} {{ render_field(form.image) }} {{
render_field(form.price) }} {{ form.csrf_token }} {{ form.submit }}
{% endblock %}

Here, {% extends "layout.html" %} tells our templating engine to use layout.html as a base template, and {% block body %} ... {% endblock %} defines the code to place inside layout.html's body block.

Our render_function macro will be used to show our different form fields – some of these will be text input fields, while others will be file upload fields. Our form also has a hidden field specified by {{ form.csrf_token }}. This is a security feature WTForms provides to prevent cross-site request forgery vulnerabilities.

Let's define our home page now, with a list of content items. Add the following code to templates/index.html:

{% extends "layout.html" %} {% block body %}
{% for id, content in content.items() %}
<a href="/content/{{ id }}">{{ }}</a>
{% if content.paywalled %} {% if id in my_library %} (PURCHASED) {% else %}
({{ "${:,.2f}".format(content.price) }}) {% endif %} {% endif %}
{% endfor %} {% if admin %}
<li><a href="/admin/content-create">NEW CONTENT...</a></li>
{% endif %}

{% if admin %}
<h1>Admin functions</h1>
<li><a href="/admin/db-flush">Flush database</a></li>
{% endif %} {% endblock %}

We display each piece of content in a list. If an item is paywalled, we show its price if the current user hasn't already purchased it, or "(PURCHASED)" if they have.

In addition, we use {% if admin %} blocks to include links to admin functionality, such as content creation and database flushing, that will only display when an admin is logged in.

The last page we need to create is templates/content.html, which will display information about individual PDFs:

{% extends "layout.html" %} {% block body %}
<h1>{{ content[content_id].name }}</h1>
<img src="/{{ content[content_id].preview_image }}" style="max-width: 150px" />
<p>{{ content[content_id].description }}</p>
{% if content_id in my_library or not content[content_id].paywalled %}
<a href="/content-file/{{ content_id }}">Download PDF</a>
{% else %}
<form action="/checkout/{{ content_id }}" method="POST">
<button type="submit" id="checkout-button">
Buy {{ content[content_id].name }} for {{
"${:,.2f}".format(content[content_id].price) }}
{% endif %} {% endblock %}

As with the home page, we display different parts of the page depending on whether the content is paywalled, and whether the current user owns it. If the user must purchase the PDF, we include a single-button form that posts to /checkout/<content_id>, an application route we'll create in the next section.

We've referred to a lot of different variables in our front-end templates. Flask's Jinja templating framework allows us to pass the variables we need into render_template, as we did when building the application backend. Our content creation page needed a form, and our content viewing pages needed an ID. In addition, we unpack the return value of a function named context to all of our rendered pages. Define this function now with our other helper functions in, just below owns_content:

def context():
if "content_library" in users.current.keys() and users.current["content_library"] is not None:
my_library = users.current["content_library"]
my_library = []

return {
"my_library": my_library,
"admin": is_admin(,
"content": db["content"]

This will give every page most of the application's state, including the full content dictionary and the current user's library. If we find we need another piece of state later, we can add it to the context helper function, and it will be available to all our pages.

Run your repl now and add some content. For best results, open the site in a new tab, rather than using it in your repl's browser.

Open in new window

If you add free PDFs, you'll be able to download them, but you won't be able to purchase paywalled PDFs yet.

Free pdf download

Integrating with Stripe

Our application is fully functional for free PDFs. To have users pay for premium PDFs, we'll integrate Stripe Checkout. This will save us the trouble and risk of developing our own payment gateway or storing users' card details.

To use Stripe Checkout, you will need an activated Stripe account. Create one now at if you haven't already.

Once you've created a Stripe account, add the following code near the top of, just below the import statements:

# Stripe setup
stripe.api_key = os.environ["STRIPE_KEY"]


You can find your Stripe API keys on this page of the developer dashboard. Make sure that you're in test mode and copy the secret key to your clipboard. Then return to your repl and create an environment variable called STRIPE_KEY with the value you just copied from Stripe.

Stripe Key

You will also need to replace the value of DOMAIN with your repl's root URL. You can get this URL from the in-repl browser.

Repl URL

Stripe Checkout

Stripe provides detailed technical documentation and code snippets in a variety of languages, so setting up basic integration is largely a matter of copying and adapting these code snippets to our needs. We'll start by creating the /checkout/<content_id> route. This will create a new Stripe checkout session and redirect the user to a Stripe payment page. Add the following code below your content_file function definition:

# Stripe integration
@app.route("/checkout/<content_id>", methods=["POST"])
def checkout(content_id):

# Proceed to checkout
checkout_session = stripe.checkout.Session.create(
"price_data": {
"currency": "usd",
"product_data": {
"name": db["content"][content_id]["name"],
"images": [DOMAIN + "/" + db["content"][content_id]["preview_image"]]
'unit_amount': int(db["content"][content_id]["price"]*100),
"quantity": 1
success_url=DOMAIN + '/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url=DOMAIN + '/cancel'
except Exception as e:
return str(e)

# Record order
order_id =
db["orders"][order_id] = {
"content_id": content_id,

return redirect(checkout_session.url, code=303)

This code is adapted from Stripe's sample integration Python code. It initiates a checkout from the pricing and product details we provide and redirects the user to Stripe's checkout website to pay. If payment is successful, it sends the user to a success_url on our site; otherwise, it sends to the user to a cancel_url. We'll define both of these shortly.

We've made two key changes to the sample code. First, we've included the details for our content item in line_items:

"price_data": {
"currency": "usd",
"product_data": {
"name": db["content"][content_id]["name"],
"images": [DOMAIN + "/" + db["content"][content_id]["preview_image"]]
'unit_amount': int(db["content"][content_id]["price"]*100),
"quantity": 1

Rather than defining individual products on Stripe's side, we're programmatically constructing our products at checkout time. This saves us from having to add our PDF metadata in two places. We provide our product's name, and the full URL of its preview image, so both can be shown on the Stripe Checkout page. As Stripe expects prices in cents, we multiply the price from our database by 100 before converting it to an integer.

The second change we've made to the sample code is to record the order details in our database. We need to do this so that we can fulfill the order once it's paid for.

    # Record order
order_id =
db["orders"][order_id] = {
"content_id": content_id,

We reuse Stripe's Checkout Session object's id as our order_id so that we can link the two later.

If you run your repl now, you should be able to reach the Stripe checkout page for any paywalled content you've added. Don't try to pay for anything yet though, as we still need to build order fulfillment.

Paywall Checkout page

Stripe fulfillment

As we're selling digital goods, we can integrate fulfillment directly into our application by adding purchased content to the buyer's library as soon as payment has been made. We'll do this with a function called fulfill_order, which you can add just below the checkout function definition.

def fulfill_order(session):
# Get order details
content_id = db["orders"][]["content_id"]
buyer = db["orders"][]["buyer"]

# Add content to library
if session.payment_status == "paid" and not owns_content(buyer, content_id):
if users[buyer]["content_library"] is not None:
users[buyer]["content_library"] = [content_id]

This function takes a Stripe Checkout Session object, retrieves the corresponding order from our database, and then adds the order's content to the buyer's library if a payment has been made, and the buyer does not already own the content.

We'll invoke this function from our /success route, which we'll define just below it.

@app.route('/success', methods=['GET'])
def success():

# Get payment info from Stripe
session = stripe.checkout.Session.retrieve(request.args.get('session_id'))

# Abort if user is not buyer
if != db["orders"][]["buyer"]:
return "Access denied."


return render_template_string(f'<html><body><h1>Thanks for your order, {}!</h1><p>Your purchase has been added to your <a href="/">library</a>.</p></body></html>')

Here we retrieve the session details from the session_id GET parameter Stripe passed to our app, ensure that the current user is also the order buyer, and call fulfill_order. We then render a simple success page. You can replace this with a full Jinja template if you want to make it a bit fancier.

We also need to define the /cancel route, used if the payment fails. This one is quite simple:

@app.route('/cancel', methods=['GET'])
def cancel():
return render_template_string("<html><body><h1>Order canceled.</h1></body></html>")

If you run your repl now, you should be able to purchase content. You can find test credit card numbers on the Stripe integration testing documentation page. You can use any future date as the expiry date and any CVV.

PDF purchased


A potential problem with the way we're fulfilling orders is that a user might close the Stripe Checkout tab or lose internet connectivity after their payment has been confirmed, but before they're redirected to our /success route. If this happens, we'll have their money, but they won't have their PDF.

For this reason, Stripe provides an additional method for fulfilling orders, based on webhooks. A webhook is an HTTP route intended to be used by machines rather than people. Much like we've created routes for our admins to upload PDFs, and our users to buy PDFs, we'll now create a route for Stripe's bots to notify our application of completed payments.

First, you'll need to create a webhook on your Stripe Dashboard. Visit the Webhooks page and click Add endpoint. You should then see a page like this:

Add webhook

On this page, do the following:

  1. For the Endpoint URL value, enter your repl's URL, followed by /fulfill-hook.

  2. Select the checkout.session.completed event from Select events to listen to.

    Webhook event

  3. Click Add endpoint.

Stripe should then redirect you to your new webhook's details page. From here you can see webhook details, logs and the signing secret. The signing secret is used to ensure that our webhook only accepts requests from Stripe – otherwise, anyone could call it with spoofed data and complete orders without paying. Reveal your webhook's signing secret and copy it to your clipboard, then return to your repl.

Signing secret

We'll use another environment variable here. Add the following code below your cancel function definition:

endpoint_secret = os.environ['ENDPOINT_SECRET']

Then create an environment variable called ENDPOINT_SECRET with the value you just copied from Stripe.

For our app's webhook code, we can once again tweak Stripe's sample code. We'll use this order fulfillment code as a base. Add this code below your endpoint_secret assignment:

@app.route('/fulfill-hook', methods=['POST'])
def fulfill_webhook():
event = None
payload =
sig_header = request.headers['STRIPE_SIGNATURE']

event = stripe.Webhook.construct_event(
payload, sig_header, endpoint_secret
except ValueError as e:
# Invalid payload
raise e
except stripe.error.SignatureVerificationError as e:
# Invalid signature
raise e

# Handle the event
if event['type'] == 'checkout.session.completed':
session = event['data']['object']

# Fulfill the purchase...
print('Unhandled event type {}'.format(event['type']))

return jsonify(success=True)

After ensuring that the request we've received comes from Stripe, we retrieve the Checkout Session object from Stripe's checkout.session.completed event and use it to call fulfill_order.

If you run your repl now, you should be able to purchase a PDF, close the checkout page after your payment is accepted but before being redirected, and still end up with the PDF in your library. You can also view webhook invocation logs on the Stripe Dashboard.

Stripe webhook success

Where next?

We've built a functional if fairly basic storefront for digital goods. If you'd like to continue with this project, consider the following extensions:

  • Improving the site's appearance with custom CSS.
  • Branching out from PDFs to other files, such as audio podcasts, videos, or desktop software.
  • Providing a subscription option that gives users access to all PDFs for a limited time.
  • Converting the site into a peer-to-peer marketplace where users can all upload and purchase files from each other.

You can find the code for this tutorial here:

Was this helpful?