Accepting online payments is only half the job. A complete booking system should also record every successful payment in my accounting software without requiring manual data entry. In this tutorial, you'll learn how to integrate Stripe with a Flask application to securely accept online payments and automatically create sales receipts in QuickBooks Online. By the end, the Jersey City Pickleball Club booking system will process payments, keep financial records in sync, and provide a solid foundation for generating receipts and reports, as well as future business automation.
Prerequisite:
This tutorial is part of the Flask Landing Page and Reservation System Series.
📚 View the Complete Flask Landing Page and Reservation System Series
Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install flask python-quickbooks intuit-oauth python-dotenv stripe
Then, we need to set up the file and folder structure, as below:Stripe is a global technology company that provides payment processing software
QuickBooks is an accounting and financial management software developed by Intuit.
Why did I connect both APIs?
When a user books a court, it will link the booking to the payment in Stripe and record a sales receipt in QuickBooks concurrently. Therefore, the club will gain the following benefits:
- Improve cash flow by accepting secure online payments before the booking is confirmed.
- Reduce the risk of cash loss or theft, as all payments are processed electronically instead of handling cash at the venue.
- Lower operating costs by reducing the need for ticket-selling staff and manual data entry into the accounting system.
- Save time through automation, since payment information is automatically transferred to QuickBooks without re-entering data.
- Minimise human errors, such as incorrect payment amounts, duplicate entries, or missing transactions.
- Maintain accurate financial records, with every successful payment immediately recorded as a sales receipt.
- Simplify bookkeeping and reconciliation, making it easier to match bank deposits, Stripe payouts, and accounting records.
- Generate real-time financial reports, allowing managers to monitor revenue, sales trends, and cash flow at any time.
- Enhance the customer experience, as players receive instant payment confirmation and receipts after completing their booking.
- Provide a complete audit trail, linking each booking, Stripe payment, and QuickBooks sales receipt for easy tracking and dispute resolution.
From the customers' perspective, the online booking system offers greater convenience and flexibility. Players can reserve a court anytime and anywhere without travelling to the club. They avoid traffic congestion, parking difficulties, and long queues, while enjoying instant booking confirmation and secure online payment. As a result, the entire booking process becomes quicker, simpler, and more user-friendly.
![]() |
STRIPE_SECRET_KEY = 'paste here'
STRIPE_PUBLISHABLE_KEY= 'paste here'from payments import payment_bpapp.register_blueprint(payment_bp)dotenv.load_dotenv()app.config['SECRET_KEY'] = 'your-secret-key'app.config["STRIPE_SECRET_KEY"] = os.getenv("STRIPE_SECRET_KEY")
from flask import (request, redirect, url_for, Blueprint, flash,
current_app, render_template)
from flask_login import login_required, current_user
import stripe
payment_bp = Blueprint("payment", __name__)
@payment_bp.route("/create-checkout-session", methods=["POST"])
@login_required
def create_checkout_session():
stripe.api_key = current_app.config["STRIPE_SECRET_KEY"]
court = request.form["booking_court_selection"]
booking_date = request.form["booking_date"]
time_slot = request.form["booking_time_slot"]
checkout_session = stripe.checkout.Session.create(
mode="payment",
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "usd",
"product_data": {
"name": f"{court} Court Booking"
},
"unit_amount": 2000
},
"quantity": 1
}],
metadata={
"username": current_user.username,
"court": court,
"date": booking_date,
"time_slot": time_slot
},
success_url=url_for(
"payment.payment_success",
_external=True
) + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=url_for(
"payment.payment_cancel",
_external=True
)
)
return redirect(checkout_session.url)@payment_bp.route(...)registers the URL/create-checkout-session.-
methods=["POST"]means this route only accepts POST requests, preventing users from accidentally creating a payment by visiting the URL directly. -
@login_requiredensures that only authenticated users can make a payment.
- Stripe requires my secret API key to authenticate every request.
- Rather than hard-coding it, the key is retrieved from my Flask configuration.
- These values are submitted from my booking form.
- They will be used to create the payment description and stored with the payment.
- This tells Stripe to create a brand-new checkout session.
- Think of it as creating a temporary online checkout page for this specific booking.

5. Payment Mode
- Stripe supports several modes:
-
"payment"– one-time payment ✅ "subscription"– recurring payments"setup"– save a payment method without charging- Since a court booking is paid once, it
"payment"is the correct choice.
- Only card payments are accepted.
- Stripe can also support additional payment methods depending on my account and region
- A Checkout Session can contain one or more products.
- Here I am charging for one court booking.
- Currency - The payment is charged in US dollars.
- Product Name - The booking description is generated dynamically.
- Price - Stripe expects the smallest currency unit.
- Quantity - Only one court reservation is being purchased.
- Metadata is one of Stripe's most useful features.
- These values are not shown to the customer, but they are stored with the payment.
- Later, when Stripe sends a webhook after a successful payment, my application can retrieve this metadata to
- create the booking in TinyDB,
- create a Sales Receipt in QuickBooks Online,
- send a confirmation email,
- generate a receipt.
- This avoids needing hidden form fields or temporary server-side storage.
- After a successful payment, Stripe redirects the customer back to my application.
- The placeholder
{CHECKOUT_SESSION_ID}is automatically replaced by Stripe with the actual session ID. - I can then retrieve the session to verify the payment and display payment details.
- If the customer cancels the payment, Stripe redirects them here instead.
@payment_bp.route("/payment-success")
@login_required
def payment_success():
stripe.api_key = current_app.config["STRIPE_SECRET_KEY"]
session_id = request.args.get("session_id")
session = stripe.checkout.Session.retrieve(session_id)
if session.payment_status == "paid":
bookings_table.insert({
"username": session.metadata["username"],
"court": session.metadata["court"],
"date": session.metadata["date"],
"time_slot": session.metadata["time_slot"],
"status": "Accepted",
"payment": "Paid",
"stripe_session": session.id
})
flash("Payment successful! Your booking is being confirmed.")
return redirect(url_for("dashboard"))- This route handles requests to
/payment-success. - The
@login_requireddecorator ensures that only authenticated users can access this page. Since bookings belong to registered users, this prevents anonymous users from confirming bookings.
- Before communicating with Stripe, my application authenticates itself using the secret API key stored in the Flask configuration.
- After a successful payment, Stripe redirects the browser to a URL similar to:
'/payment-success?session_id=cs_test_a1B2C3D4...'
- This line extracts the
session_idfrom the URL.

4. Retrieve the Checkout Session
- Rather than trusting information sent by the browser, my application asks Stripe for the official Checkout Session.
- This session contains trusted information such as:
- Payment status
- Customer information
- Metadata
- Amount paid
- Currency
- Session ID
- Using Stripe as the source of truth helps prevent users from tampering with payment details.
- My application checks whether Stripe confirms the payment was successful.
- If the payment is not marked as
"paid", no booking is created. - This prevents unpaid reservations from being stored
- Once payment has been verified, the booking is inserted into my TinyDB database.
- Username - Instead of reading the username from the submitted form, I retrieve it from the metadata stored by Stripe.
- Court - The selected court is restored from the Checkout Session.
- Date - The booking date is retrieved from Stripe.
- Time Slot - This restores the reserved playing time.
- Booking Status - Since payment has been completed successfully, the booking is immediately marked as Accepted.
- Payment Status - This records that payment has already been received.
- Stripe Session ID - Saving the Stripe Session ID is very useful.
- It allows you to:
- look up the payment later,
- process refunds,
- retrieve receipts,
- investigate disputes,
- link the booking to QuickBooks sales receipts.
- A flash message is stored in the user's session.
- Finally, the user is redirected to the dashboard where they can view their confirmed booking.
@payment_bp.route("/payment-cancel")
@login_required
def payment_cancel():
stripe.api_key = current_app.config["STRIPE_SECRET_KEY"]
flash("Payment cancelled. Your booking was not completed.")
return redirect(url_for("search"))
1. Route Definition
- This route is executed when Stripe redirects the customer to the cancel URL.
- If the customer clicks Back, Cancel, or closes the checkout before paying, Stripe redirects them to this route.
- This sets my Stripe secret key.
- In this particular function, however, it isn't actually used because no request is made to Stripe.
- A flash message is stored in the user's session.
This reassures the user that:
- no booking has been confirmed,
- no payment has been processed.
- they are free to try again.
- Finally, the user is redirected back to the search page.
- From there, they can:
- choose another court,
- select a different time slot,
- restart the payment process.
Step 3: Install and configure stripe.exe and create a webhook
& "D:\Learning App\Website\JCPC-content\stripe.exe" --version
& "D:\Learning App\Website\JCPC-content\stripe.exe" login
& "D:\Learning App\Website\JCPC-content\stripe.exe" listen --forward-to localhost:5000/stripe-webhookSTRIPE_WEBHOOK_SECRET=paste it hereimport dotenv
import os
dotenv.load_dotenv()
app.config["STRIPE_WEBHOOK_SECRET"] = os.getenv("STRIPE_WEBHOOK_SECRET")Now, the Stripe webhook secret is brought to the app and later passed to payment.py for further configuration.@payment_bp.route("/stripe-webhook", methods=["POST"])
def stripe_webhook():
payload = request.data
sig_header = request.headers.get("Stripe-Signature")
endpoint_secret = current_app.config["STRIPE_WEBHOOK_SECRET"]
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
endpoint_secret
)
except Exception as e:
return str(e), 400
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
save_booking(session)
create_sales_receipt(
customer_name=
session["metadata"]["username"],
court=
session["metadata"]["court"],
amount=20
)
return "", 200
This creates an endpoint called
/stripe-webhook.- Unlike normal routes, users never visit this URL. Instead, Stripe sends an HTTP POST request to this endpoint whenever one of the subscribed events occurs.
- When Stripe sends a webhook, it includes:
-
payload– the JSON data describing the event. Stripe-Signature– a digital signature proving that the request came from Stripe.
- It is generated by Stripe and stored in my Flask configuration.
- Its purpose is to verify that the webhook is genuine and has not been altered.
- This is one of the most important lines in the entire Stripe integration.
Stripe performs three checks:
- Is the request really from Stripe?
- Has the payload been modified?
- Does the signature match my webhook secret?
- If all checks pass, Stripe returns a verified event object.
- If verification fails:
- the request is rejected,
- my application returns HTTP 400 (Bad Request),
- no booking is created,
- no Sales Receipt is generated.
- This protects my application from fake webhook requests.
- Stripe supports many different events, including:
-
checkout.session.completed payment_intent.succeededinvoice.paidcharge.refunded- My application only reacts when a Checkout Session has been completed successfully.
7. Retrieve the Checkout Session
- The event contains many pieces of information.
- It contains:
- session ID
- payment status
- amount paid
- customer details
- metadata
- This calls my helper function.
- Because the metadata was attached when the Checkout Session was created, all the booking information is available without asking the customer to submit it again.
- After saving the booking, your application immediately records the transaction in QuickBooks Online.
- It sends:
- customer name
- court booked
- amount charged
- to the QuickBooks API, which creates a Sales Receipt automatically.
- This keeps my booking system and accounting records synchronised
- If my server instead returned an error such as 500, Stripe would retry sending the webhook several times according to its retry policy.
def save_booking(session):
existing_booking = bookings_table.search(
Query().stripe_session == session["id"]
)
if existing_booking:
print("Booking already exists")
return
bookings_table.insert({
"username": session["metadata"]["username"],
"court": session["metadata"]["court"],
"date": session["metadata"]["date"],
"time_slot": session["metadata"]["time_slot"],
"payment": "Paid",
"status": "Accepted",
"stripe_session": session["id"]
})
- The function receives
sessionas an argument. - This
sessionis the Stripe Checkout Session object from my webhook
- This searches my TinyDB table for an existing booking with the same Stripe Session ID.
- Breaking it down:
bookings_table.search()- TinyDB searches the database and returns matching records.
This tells TinyDB:
- Look at the
stripe_sessionfield.
- Compare it with the current Stripe Checkout Session ID.
- The duplicate check prevents this.
- If TinyDB finds a matching record: Booking already exists
- Stops execution: No new booking is inserted.
- If no existing booking is found, the code continues: A new record is added to TinyDB.
- Save Username: Retrieves the username stored in Stripe metadata.
- Save Court
- Save Date
- Save Time Slot
- Save Payment Status
- Since this function is called only after the payment has already been confirmed by Stripe.
- Save Booking Status: The booking is officially confirmed.
- Store Stripe Reference: This stores the Stripe Checkout Session ID. Later you can use this ID to:
- retrieve payment details,
- issue refunds,
- display receipts,
- troubleshoot payment problems.
🎁 Get Your FREE Flask Cheat Sheet
Get more Flask, Python automation, Docker, and HTMX tutorials delivered to your inbox.
✓ Practical coding tutorials
✓ Automation tips for SMEs
✓ New project ideas and templates
Download my FREE Flask Cheat Sheet (PDF)
First of all, I go to the top right corner to select MyHub. Then, in the dropdown selection, I will select Workspace. Next, I will click the sample workspace box and click the new workspace. Then it will direct me to the Create a New App page. I will name the application "Jersey City Pickleball Club." Then, it authorises adding permissions for both accounting and payment, and I need to confirm the authorisation. Once all is done, it will provide me both the Client ID and client secret. So, I need to copy both keys and paste them into the .env file.
Back to the dashboard, I click the "Get Production Key" box, then toggle to the development tab, and click the link in the redirect URL. Finally, delete the provided URL replace with the new URL "http://localhost:5000/quickbooks-callback."
QB_CLIENT_ID='paste it here'
QB_CLIENT_SECRET='paste it here'
QB_REDIRECT_URI=http://localhost:5000/quickbooks-callbackfrom flask import redirect, current_app, request, Blueprint
from urllib.parse import urlencode
import requests
from requests.auth import HTTPBasicAuth
quickbooks_bp = Blueprint("quickbooks", __name__)
@quickbooks_bp.route("/quickbooks-login")
def quickbooks_login():
params = {
"client_id": current_app.config["QB_CLIENT_ID"],
"scope": "com.intuit.quickbooks.accounting",
"redirect_uri": current_app.config["QB_REDIRECT_URI"],
"response_type": "code",
"state": "test123"
}
url = (
"https://appcenter.intuit.com/connect/oauth2?"
+ urlencode(params)
)
return redirect(url)
- This creates a Flask route, and I visit 'http://localhost:5000/quickbooks-login'
- The route name "login" can be slightly confusing. It does not log a user into my Flask application. Instead, it means "Start the QuickBooks authorisation login process."
- The Client ID identifies my application. My Flask application tells QuickBooks: "I am this registered application."
- Scope: The scope defines what my application is allowed to access. means my application requests permission to access accounting features, such as:
- Customers
- Items
- Accounts
- Sales Receipts
- Invoices
- Payments
- Redirect URI: This tells QuickBooks: "After the user approves access, send the user back to this URL."
This must exactly match the redirect URI registered in my Intuit Developer Dashboard.
- Response Type: This tells OAuth that you want an authorisation code. The authorisation code is temporary and cannot be used to directly call the API.
- State Parameter: The
stateparameter is used for security.It helps prevent CSRF attacks by allowing my application to verify that the callback request came from the authorisation process you started.
- This creates the final URL that the browser will visit.
urlencode()converts my dictionary: "client_id=abc123&scope=com.intuit.quickbooks.accounting"
Underlying logic
@quickbooks_bp.route("/quickbooks-callback")
def quickbooks_callback():
code = request.args.get("code")
realm_id = request.args.get("realmId")
response = requests.post(
"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
auth=HTTPBasicAuth(
current_app.config["QB_CLIENT_ID"],
current_app.config["QB_CLIENT_SECRET"]
),
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri":
current_app.config["QB_REDIRECT_URI"]
},
headers={
"Accept": "application/json"
}
)
tokens = response.json()
print(tokens)
print("Realm ID:", realm_id)
return "QuickBooks Connected"
1. Define the Callback Route - This route handles the callback from QuickBooks after the user clicks "Allow" on the authorisation page.
- Once permission is granted, QuickBooks redirects the browser to this URL.
- QuickBooks sends an authorisation code as part of the URL.
- The authorisation code is temporary and can only be used once.
- The Realm ID uniquely identifies the QuickBooks Online company that the user authorised.
- My application needs this value whenever it sends requests to the QuickBooks Accounting API.
- Instead of using the authorisation code directly, my application sends it to Intuit's OAuth server.
- In return, Intuit provides:
- Access Token
- Refresh Token
- Token expiry information
- Here, my application proves its identity.
- It sends:
- Client ID
- Client Secret
- These values come from my Intuit Developer account.
This tells Intuit: "I am the application that requested this authorisation code."
- The request body contains three important pieces of information.
- Grant Type—This tells Intuit: "I am exchanging an authorisation code for OAuth tokens."
- Authorisation Code—This is the code you extracted from the callback URL.
- Redirect URI—This must exactly match:
- the redirect URI registered in Intuit,
- the redirect URI used during the login step.
This prevents another application from stealing my authorisation code.
- This tells Intuit that my application expects the response in JSON format.
- If everything succeeds, Intuit returns something, and these values are converted into a Python dictionary.
- During development, this prints the token information in the terminal.
In a production application, you should not print or expose these tokens because they grant access to my QuickBooks account.
- Instead, store them securely (for example, in a database or encrypted configuration) and refresh them when the access token expires.
- This prints the QuickBooks company identifier.
- You will use this value when calling the QuickBooks API.
- If everything succeeds, the browser displays: QuickBooks Connected
- This confirms that the OAuth process has completed successfully.
- Copy the refresh_token, access_token and Realm ID into the .env file
QB_REALM_ID= paste it here
QB_ACCESS_TOKEN= 'paste it here'
QB_REFRESH_TOKEN='paste it here'import requests
from flask import current_app
def create_sales_receipt(
customer_id,
item_id,
amount):
access_token = current_app.config["QB_ACCESS_TOKEN"]
realm_id = current_app.config["QB_REALM_ID"]
# print("Token:", access_token[:20])
# print("Realm:", realm_id)
url = (
f"https://sandbox-quickbooks.api.intuit.com/v3/"
f"company/{realm_id}/salesreceipt"
)
payload = {
"CustomerRef": {
"value": customer_id
},
"Line": [
{
"Amount": amount,
"DetailType": "SalesItemLineDetail",
"SalesItemLineDetail": {
"ItemRef": {
"value": item_id
}
}
}
]
}
response = requests.post(
url,
json=payload,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json"
}
)
return response.json()
1. Function Definition- This function accepts three parameters:
-
customer_id– the QuickBooks Customer ID. item_id– the QuickBooks Item (product or service) being sold.amount– the total amount charged.- This tells QuickBooks:
- Create a £20 (or $20 if my QuickBooks company uses USD) Sales Receipt for Customer 1 using Item 2.
2. Retrieve the Access Token
- The Access Token proves that my Flask application has permission to use the QuickBooks API.
- The Realm ID uniquely identifies my QuickBooks company.
- It tells Intuit: "Create this Sales Receipt in this QuickBooks company."
4. Build the API Endpoint
- Sales are recorded on the Sales Transaction page in QuickBooks
- This constructs the URL for the Sales Receipt API.
- This dictionary represents the Sales Receipt that will be sent to QuickBooks
- Customer Reference - QuickBooks needs to know who made the purchase.
- Sales Line - A Sales Receipt can contain multiple products or services. In my booking system, there is only one: Court Booking
- Amount - This specifies the amount charged. Unlike Stripe, QuickBooks expects the normal currency amount, not the smallest currency unit.
- Detail Type - This tells QuickBooks that the line represents a sale of an item or service.
- Item Reference - Every sales line must reference an existing QuickBooks Item.
- This sends the Sales Receipt to QuickBooks.
- URL - The destination is the Sales Receipt API endpoint.
- JSON Data - The payload is automatically converted to JSON.
- Authorisation Header - The word "Bearer" tells QuickBooks that you're using an OAuth 2.0 access token. Without this header, QuickBooks would reject the request with an authentication error.
- Accept Header - This requests that QuickBooks return its response in JSON format.
- The API response is converted into a Python dictionary.
- My application can then:
- save the Sales Receipt ID,
- display the receipt number,
- link the booking to the accounting record,
- or log any errors returned by the API.

In this tutorial, you have built a complete payment and accounting workflow by integrating Stripe and QuickBooks Online with Flask. The system can securely process court booking payments, confirm reservations through Stripe webhooks, save booking records, and automatically create Sales Receipts in QuickBooks. By connecting these APIs, the Jersey City Pickleball Club can reduce manual work, minimise errors, improve financial tracking, and provide customers with a faster and more convenient booking experience. This approach demonstrates how modern web applications can automate real-world business processes by connecting payment platforms, accounting systems, and backend services together.
Published: July 2026
Last Updated: July 2026
---------------------------------------------------------------------------------------------------------------------------------------------------
.png)


.png)
.png)
.png)














Comments