Accepting Payments with Stripe, Vue.js, and Flask

Last updated April 24th, 2023

In this tutorial, we'll develop a web app for selling books using Stripe (for payment processing), Vue.js (the client-side app), and Flask (the server-side API).

This is an intermediate-level tutorial. It assumes that you a have basic working knowledge of Vue and Flask. Review the following resources for more info:

  1. Developing a Single Page App with Flask and Vue.js
  2. Developing Web Applications with Python and Flask
  3. Learn Vue by Building and Deploying a CRUD App

Final app:

final app

Main dependencies:

  • Vue v3.2.47
  • Node v20.0.0
  • npm v9.6.4
  • Flask v2.2.3
  • Python v3.11.3

Contents

Objectives

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

  1. Work with an existing CRUD app, powered by Vue and Flask
  2. Create an order checkout Vue component
  3. Validate credit card information and process payments with Stripe Checkout

Project Setup

Clone the base Flask and Vue project from the flask-vue-crud repo:

$ git clone https://github.com/testdrivenio/flask-vue-crud flask-vue-stripe
$ cd flask-vue-stripe

Create and activate a virtual environment, and then spin up the Flask app:

$ cd server
$ python3.11 -m venv env
$ source env/bin/activate
(env)$

(env)$ pip install -r requirements.txt
(env)$ flask run --port=5001 --debug

The above commands, for creating and activating a virtual environment, may differ depending on your environment and operating system. Feel free to swap out virtualenv and Pip for Poetry or Pipenv. For more, review Modern Python Environments.

Point your browser of choice at http://localhost:5001/ping. You should see:

"pong!"

Then, install the dependencies and run the Vue app in a different terminal window:

$ cd client
$ npm install
$ npm run dev

Navigate to http://localhost:5173. Make sure the basic CRUD functionality works as expected:

base app

Want to learn how to build this project? Check out the Developing a Single Page App with Flask and Vue.js tutorial.

What are we building?

Our goal is to build a web app that allows end users to purchase books.

The client-side Vue app will display the books available for purchase and redirect the end user to the checkout form via Stripe.js and Stripe Checkout. After the payment process is complete, users will be redirected to either a success or failure page also managed by Vue.

The Flask app, meanwhile, uses the Stripe Python Library for interacting with the Stripe API to create a checkout session.

final app

Like the previous tutorial, Developing a Single Page App with Flask and Vue.js, we'll only be dealing with the happy path through the app. Check your understanding by incorporating proper error-handling on your own.

Books CRUD

First, let's add a purchase price to the existing list of books on the server-side and update the appropriate CRUD functions on the client -- GET, POST, and PUT.

GET

Start by adding the price to each dict in the BOOKS list in server/app.py:

BOOKS = [
    {
        'id': uuid.uuid4().hex,
        'title': 'On the Road',
        'author': 'Jack Kerouac',
        'read': True,
        'price': '19.99'
    },
    {
        'id': uuid.uuid4().hex,
        'title': 'Harry Potter and the Philosopher\'s Stone',
        'author': 'J. K. Rowling',
        'read': False,
        'price': '9.99'
    },
    {
        'id': uuid.uuid4().hex,
        'title': 'Green Eggs and Ham',
        'author': 'Dr. Seuss',
        'read': True,
        'price': '3.99'
    }
]

Then, update the table in the Books component, client/src/components/Books.vue, to display the purchase price:

<table class="table table-hover">
  <thead>
    <tr>
      <th scope="col">Title</th>
      <th scope="col">Author</th>
      <th scope="col">Read?</th>
      <th scope="col">Purchase Price</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr v-for="(book, index) in books" :key="index">
      <td>{{ book.title }}</td>
      <td>{{ book.author }}</td>
      <td>
        <span v-if="book.read">Yes</span>
        <span v-else>No</span>
      </td>
      <td>${{ book.price }}</td>
      <td>
        <div class="btn-group" role="group">
          <button
            type="button"
            class="btn btn-warning btn-sm"
            @click="toggleEditBookModal(book)">
            Update
          </button>
          <button
            type="button"
            class="btn btn-danger btn-sm"
            @click="handleDeleteBook(book)">
            Delete
          </button>
        </div>
      </td>
    </tr>
  </tbody>
</table>

You should now see:

add purchase price to Books component

POST

Add a new form input to addBookModal, between the author and read form inputs:

<div class="mb-3">
  <label for="addBookPrice" class="form-label">Purchase price:</label>
  <input
    type="number"
    step="0.01"
    class="form-control"
    id="addBookPrice"
    v-model="addBookForm.price"
    placeholder="Enter price">
</div>

The modal should now look like:

<!-- add new book modal -->
<div
  ref="addBookModal"
  class="modal fade"
  :class="{ show: activeAddBookModal, 'd-block': activeAddBookModal }"
  tabindex="-1"
  role="dialog">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Add a new book</h5>
        <button
          type="button"
          class="close"
          data-dismiss="modal"
          aria-label="Close"
          @click="toggleAddBookModal">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <form>
          <div class="mb-3">
            <label for="addBookTitle" class="form-label">Title:</label>
            <input
              type="text"
              class="form-control"
              id="addBookTitle"
              v-model="addBookForm.title"
              placeholder="Enter title">
          </div>
          <div class="mb-3">
            <label for="addBookAuthor" class="form-label">Author:</label>
            <input
              type="text"
              class="form-control"
              id="addBookAuthor"
              v-model="addBookForm.author"
              placeholder="Enter author">
          </div>
          <div class="mb-3">
            <label for="addBookPrice" class="form-label">Purchase price:</label>
            <input
              type="number"
              step="0.01"
              class="form-control"
              id="addBookPrice"
              v-model="addBookForm.price"
              placeholder="Enter price">
          </div>
          <div class="mb-3 form-check">
            <input
              type="checkbox"
              class="form-check-input"
              id="addBookRead"
              v-model="addBookForm.read">
            <label class="form-check-label" for="addBookRead">Read?</label>
          </div>
          <div class="btn-group" role="group">
            <button
              type="button"
              class="btn btn-primary btn-sm"
              @click="handleAddSubmit">
              Submit
            </button>
            <button
              type="button"
              class="btn btn-danger btn-sm"
              @click="handleAddReset">
              Reset
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</div>
<div v-if="activeAddBookModal" class="modal-backdrop fade show"></div>

Then, add price to the state:

addBookForm: {
  title: '',
  author: '',
  read: [],
  price: '',
},

The state is now bound to the form's input value. Think about what this means. When the state is updated, the form input will be updated as well -- and vice versa. Here's an example of this in action with the vue-devtools browser extension:

state model bind

Add the price to the payload in the handleAddSubmit method like so:

handleAddSubmit() {
  this.toggleAddBookModal();
  let read = false;
  if (this.addBookForm.read[0]) {
    read = true;
  }
  const payload = {
    title: this.addBookForm.title,
    author: this.addBookForm.author,
    read, // property shorthand
    price: this.addBookForm.price,
  };
  this.addBook(payload);
  this.initForm();
},

Update initForm to clear out the value after the end user submits the form or clicks the "reset" button:

initForm() {
  this.addBookForm.title = '';
  this.addBookForm.author = '';
  this.addBookForm.read = [];
  this.addBookForm.price = '';
  this.editBookForm.id = '';
  this.editBookForm.title = '';
  this.editBookForm.author = '';
  this.editBookForm.read = [];
},

Finally, update the route in server/app.py:

@app.route('/books', methods=['GET', 'POST'])
def all_books():
    response_object = {'status': 'success'}
    if request.method == 'POST':
        post_data = request.get_json()
        BOOKS.append({
            'id': uuid.uuid4().hex,
            'title': post_data.get('title'),
            'author': post_data.get('author'),
            'read': post_data.get('read'),
            'price': post_data.get('price')
        })
        response_object['message'] = 'Book added!'
    else:
        response_object['books'] = BOOKS
    return jsonify(response_object)

Test it out!

add book

Don't forget to handle errors on both the client and server!

PUT

Do the same, on your own, for editing a book:

  1. Add a new form input to the modal
  2. Update editBookForm in the state
  3. Add the price to the payload in the handleEditSubmit method
  4. Update initForm
  5. Update the server-side route

Need help? Review the previous section again. You can also grab the final code from the flask-vue-stripe repo.

Purchase Button

Add a "purchase" button to the Books component, just below the "delete" button:

<td>
  <div class="btn-group" role="group">
    <button
      type="button"
      class="btn btn-warning btn-sm"
      @click="toggleEditBookModal(book)">
      Update
    </button>
    <button
      type="button"
      class="btn btn-danger btn-sm"
      @click="handleDeleteBook(book)">
      Delete
    </button>
    <button
      type="button"
      class="btn btn-primary btn-sm"
      @click="handlePurchaseBook(book)">
      Purchase
    </button>
  </div>
</td>

Next, add handlePurchaseBook to the component's methods:

handlePurchaseBook(book) {
  console.log(book.id);
},

Test it out:

purchase button

Stripe Keys

Sign up for a Stripe account, if you don't already have one.

Server

Install the Stripe Python library:

(env)$ pip install stripe==5.4.0

Grab the test mode API keys from the Stripe dashboard:

stripe dashboard

Set them as environment variables within the terminal window where you're running the server:

(env)$ export STRIPE_PUBLISHABLE_KEY=<YOUR_STRIPE_PUBLISHABLE_KEY>
(env)$ export STRIPE_SECRET_KEY=<YOUR_STRIPE_SECRET_KEY>

Import the Stripe library into server/app.py and assign the keys to stripe.api_key so that they will be used automatically when interacting with the API:

import os
import uuid

import stripe
from flask import Flask, jsonify, request
from flask_cors import CORS


...


# configuration
DEBUG = True

# instantiate the app
app = Flask(__name__)
app.config.from_object(__name__)

# configure stripe
stripe_keys = {
    'secret_key': os.environ['STRIPE_SECRET_KEY'],
    'publishable_key': os.environ['STRIPE_PUBLISHABLE_KEY'],
}

stripe.api_key = stripe_keys['secret_key']

# enable CORS
CORS(app, resources={r'/*': {'origins': '*'}})


...


if __name__ == '__main__':
    app.run()

Next, add a new route handler that returns the publishable key:

@app.route('/config')
def get_publishable_key():
    stripe_config = {'publicKey': stripe_keys['publishable_key']}
    return jsonify(stripe_config)

This will be used on the client side to configure the Stripe.js library.

Client

Turning to the client, add Stripe.js to client/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
    <script src="https://js.stripe.com/v3/"></script>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

Next, add a new method to the Books component called getStripePublishableKey:

getStripePublishableKey() {
  fetch('http://localhost:5001/config')
    .then((result) => result.json())
    .then((data) => {
      // Initialize Stripe.js
      this.stripe = Stripe(data.publicKey);
    });
},

Call this method in the created hook:

created() {
  this.getBooks();
  this.getStripePublishableKey();
},

Now, after the instance is created, a call will be made to http://localhost:5001/config, which will respond with the Stripe publishable key. We'll then use this key to create a new instance of Stripe.js.

Shipping to production? You'll want to use an environment variable to dynamically set the base server-side URL (which is currently http://localhost:5001). Review the docs for more info.

Add stripe to `the state:

data() {
  return {
    activeAddBookModal: false,
    activeEditBookModal: false,
    addBookForm: {
      title: '',
      author: '',
      read: [],
      price: '',
    },
    books: [],
    editBookForm: {
      id: '',
      title: '',
      author: '',
      read: [],
      price: '',
    },
    message: '',
    showMessage: false,
    stripe: null,
  };
},

Stripe Checkout

Next, we need to generate a new Checkout Session ID on the server-side. After clicking the purchase button, an AJAX request will be sent to the server to generate this ID. The server will send the ID back and the user will be redirected to the checkout.

Server

Add the following route handler:

@app.route('/create-checkout-session', methods=['POST'])
def create_checkout_session():
    domain_url = 'http://localhost:5173'

    try:
        data = json.loads(request.data)

        # get book
        book_to_purchase = ''
        for book in BOOKS:
            if book['id'] == data['book_id']:
                book_to_purchase = book

        # create new checkout session
        checkout_session = stripe.checkout.Session.create(
            success_url=domain_url +
            '/success?session_id={CHECKOUT_SESSION_ID}',
            cancel_url=domain_url + '/canceled',
            payment_method_types=['card'],
            mode='payment',
            line_items=[
                {
                    'name': book_to_purchase['title'],
                    'quantity': 1,
                    'currency': 'usd',
                    'amount': round(float(book_to_purchase['price']) * 100),
                }
            ]
        )

        return jsonify({'sessionId': checkout_session['id']})
    except Exception as e:
        return jsonify(error=str(e)), 403

Here, we-

  1. Defined a domain_url for redirecting the user back to the client after a purchase is complete
  2. Obtained the book info
  3. Created the Checkout Session
  4. Sent the ID back in the response

Take note of the success_url and cancel_url. The user will be redirected back to those URLs in the event of a successful payment or cancellation, respectively. We'll set the /success and /cancelled routes up shortly on the client.

Also, did you notice that we converted the float to an integer via round(float(book_to_purchase['price']) * 100)? Stripe only allows integer values for the price. For production code, you'll probably want to store the price as an integer value in the database -- e.g., $3.99 should be stored as 399.

Add the import to the top:

import json

Client

On the client, update the handlePurchaseBook method:

handlePurchaseBook(book) {
  // Get Checkout Session ID
  fetch('http://localhost:5001/create-checkout-session', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ book_id: book.id }),
  })
    .then((result) => result.json())
    .then((data) => {
      console.log(data);
      // Redirect to Stripe Checkout
      return this.stripe.redirectToCheckout({ sessionId: data.sessionId });
    })
    .then((res) => {
      console.log(res);
    });
},

Here, after resolving the result.json() promise, we called the redirectToCheckout method with the Checkout Session ID from the resolved promise.

Let's test it out. Navigate to http://localhost:5173. Click one of the purchase buttons. You should be redirected to an instance of Stripe Checkout (a Stripe-hosted page to securely collect payment information) with the basic product information:

stripe checkout

You can test the form by using one of the several test card numbers that Stripe provides. Let's use 4242 4242 4242 4242.

  • Email: a valid email
  • Card number: 4242 4242 4242 4242
  • Expiration: any date in the future
  • CVC: any three numbers
  • Name: anything
  • Postal code: any five numbers

The payment should be processed successfully, but the redirect will fail since we have not set up the /success route yet.

You should see the purchase back in the Stripe Dashboard:

stripe dashboard

Redirect Pages

Finally, let's set up routes and components for handling a successful payment or cancellation.

Success

When a payment is successful, we'll redirect the user to an order complete page, thanking them for making a purchase.

Add a new component file called OrderSuccess.vue to "client/src/components":

<template>
  <div class="container">
    <div class="row">
      <div class="col-sm-10">
        <h1>Thanks for purchasing!</h1>
        <hr><br>
        <router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
      </div>
    </div>
  </div>
</template>

Update the router in client/src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import Books from '../components/Books.vue'
import OrderSuccess from '../components/OrderSuccess.vue'
import Ping from '../components/Ping.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Books',
      component: Books,
    },
    {
      path: '/ping',
      name: 'ping',
      component: Ping
    },
    {
      path: '/success',
      name: 'OrderSuccess',
      component: OrderSuccess,
    },
  ]
})

export default router

final app

Finally, you could display info about the purchase using the session_id query param:

http://localhost:5173/success?session_id=cs_test_a1qw4pxWK9mF2SDvbiQXqg5quq4yZYUvjNkqPq1H3wbUclXOue0hES6lWl

You can access it like so:

<script>
export default {
  mounted() {
    console.log(this.$route.query.session_id);
  },
};
</script>

From there, you'll want to set up a route handler on the server-side, to look up the session info via stripe.checkout.Session.retrieve(id). Try this out on your own.

Cancellation

For the /canceled redirect, add a new component called client/src/components/OrderCanceled.vue:

<template>
  <div class="container">
    <div class="row">
      <div class="col-sm-10">
        <h1>Your payment was cancelled.</h1>
        <hr><br>
        <router-link to="/" class="btn btn-primary btn-sm">Back Home</router-link>
      </div>
    </div>
  </div>
</template>

Then, update the router:

import { createRouter, createWebHistory } from 'vue-router'
import Books from '../components/Books.vue'
import OrderCanceled from '../components/OrderCanceled.vue'
import OrderSuccess from '../components/OrderSuccess.vue'
import Ping from '../components/Ping.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Books',
      component: Books,
    },
    {
      path: '/ping',
      name: 'ping',
      component: Ping
    },
    {
      path: '/success',
      name: 'OrderSuccess',
      component: OrderSuccess,
    },
    {
      path: '/canceled',
      name: 'OrderCanceled',
      component: OrderCanceled,
    },
  ]
})

export default router

Test it out one last time.

Conclusion

That's it! Be sure to review the objectives from the top. You can find the final code in the flask-vue-stripe repo on GitHub.

Looking for more?

  1. Add client and server-side unit and integration tests.
  2. Create a shopping cart so customers can purchase more than one book at a time.
  3. Add Postgres to store the books and the orders.
  4. Containerize Vue and Flask (and Postgres, if you add it) with Docker to simplify the development workflow.
  5. Add images to the books and create a more robust product page.
  6. Capture emails and send email confirmations (review Sending Confirmation Emails with Flask, Redis Queue, and Amazon SES).
  7. Deploy the client-side static files to AWS S3 and the server-side app to an EC2 instance.
  8. Going into production? Think about the best way to update the Stripe keys so they are dynamic based on the environment.
Featured Course

Test-Driven Development with Python, Flask, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a microservice powered by Python and Flask. You'll also apply the practices of Test-Driven Development with pytest as you develop a RESTful API.

Featured Course

Test-Driven Development with Python, Flask, and Docker

In this course, you'll learn how to set up a development environment with Docker in order to build and deploy a microservice powered by Python and Flask. You'll also apply the practices of Test-Driven Development with pytest as you develop a RESTful API.