đ Executive Summary
TL;DR: This tutorial guides SysAdmins, Developers, and DevOps Engineers through automating PDF invoice generation in real-time. It leverages Stripe webhooks, a Python Flask server, and the fpdf2 library to create professional invoices immediately after a payment succeeds, reducing manual effort and errors.
đŻ Key Takeaways
- Stripe webhook signature verification using
stripe.Webhook.construct\_eventis crucial for securing the endpoint against malicious requests and ensuring payload integrity. - The
invoice.payment\_succeededevent is the most appropriate trigger for generating final, paid PDF invoices, allowing comprehensive extraction of customer and payment details. - For production, generated PDFs should be uploaded to cloud storage (e.g., AWS S3), emailed to customers, and the Flask application deployed with a production-ready WSGI server behind a reverse proxy for security and scalability.
Automating Invoice Generation: Stripe Webhooks to PDF Invoice
Welcome, fellow SysAdmins, Developers, and DevOps Engineers! At TechResolve, we understand that efficiency is paramount.
Are you tired of the tedious, manual dance of creating invoices after every successful transaction?
Does the thought of stitching together customer data, payment details, and product information into a presentable PDF
fill you with dread, or worse, tempt you to subscribe to an expensive SaaS solution that barely fits your needs?
The good news is, thereâs a better way. In this comprehensive tutorial, weâll guide you through automating your invoice generation process.
Leveraging the power of Stripe webhooks, weâll build a robust, real-time system that automatically generates a
professional PDF invoice the moment a payment succeeds, saving you time, reducing errors, and ensuring your customers
receive timely documentation. Weâll use Python and a lightweight Flask server for our webhook endpoint, and the fpdf2 library for PDF creation.
Prerequisites
Before we dive in, ensure you have the following tools and knowledge:
- Stripe Account: An active Stripe account with access to your API keys (secret and publishable) and webhook secrets.
- Python 3.8+: Our solution will be built using Python.
-
pip: Pythonâs package installer, usually bundled with Python. -
fpdf2Library: A powerful Python library for PDF generation. -
FlaskWeb Framework: A micro-framework for Python, perfect for handling webhook requests. -
Stripe Python Library: To interact with the Stripe API and verify webhook signatures. -
Ngrok(or similar): For exposing your local development server to the internet, allowing Stripe to send webhooks to your machine. - Basic understanding: Familiarity with Python, REST APIs, and the concept of webhooks.
Step-by-Step Guide: Building Your Automated Invoice System
Step 1: Set Up Your Python Environment and Install Dependencies
First, letâs create a dedicated virtual environment to keep our project dependencies isolated and clean.
Open your terminal and execute the following commands:
python3 -m venv venv
source venv/bin/activate # On Windows, use `venv\Scripts\activate`
pip install Flask fpdf2 stripe
This sequence creates a virtual environment named venv, activates it, and then installs our required Python packages:
Flask for the web server, fpdf2 for PDF generation, and stripe for interacting with Stripe APIs and webhook verification.
Step 2: Create Your Flask Webhook Handler
Next, weâll write the Python script that listens for Stripe webhook events. Weâll specifically target the invoice.payment_succeeded
event, which fires after a Stripe invoice has been successfully paid. This is often the most appropriate trigger for generating a final invoice.
Create a file named app.py and add the following code:
import os
import json
import datetime
from flask import Flask, request, abort
import stripe
from fpdf import FPDF # fpdf2 is imported as FPDF
app = Flask(__name__)
# Load Stripe API key and webhook secret from environment variables
# IMPORTANT: Never hardcode these in production!
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
webhook_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
if not stripe.api_key or not webhook_secret:
print("Error: STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET environment variable not set.")
print("Please set them before running the application.")
exit(1)
def generate_pdf_invoice(invoice_data):
"""
Generates a PDF invoice using fpdf2 based on the provided data.
"""
pdf = FPDF()
pdf.add_page()
pdf.set_font("Arial", size=12)
# Invoice Header
pdf.cell(200, 10, txt="Invoice", ln=True, align="C")
pdf.ln(10) # Add some space
# Company Details (Placeholder)
pdf.set_font("Arial", 'B', size=10)
pdf.cell(200, 7, txt="TechResolve Inc.", ln=True)
pdf.set_font("Arial", size=10)
pdf.cell(200, 7, txt="123 DevOps Drive, Innovation City, IT 98765", ln=True)
pdf.cell(200, 7, txt="Email: billing@techresolve.com | Phone: (555) 123-4567", ln=True)
pdf.ln(10)
# Customer Details
pdf.set_font("Arial", 'B', size=12)
pdf.cell(200, 7, txt="Bill To:", ln=True)
pdf.set_font("Arial", size=10)
pdf.cell(200, 7, txt=f"{invoice_data.get('customer_name', 'N/A')}", ln=True)
# Add customer address if available
customer_address = invoice_data.get('customer_address')
if customer_address:
pdf.cell(200, 7, txt=f"{customer_address.get('line1', '')} {customer_address.get('line2', '')}".strip(), ln=True)
pdf.cell(200, 7, txt=f"{customer_address.get('city', '')}, {customer_address.get('state', '')} {customer_address.get('postal_code', '')}", ln=True)
pdf.cell(200, 7, txt=f"{customer_address.get('country', '')}", ln=True)
pdf.ln(5)
# Invoice Information
pdf.set_font("Arial", 'B', size=12)
pdf.cell(0, 7, txt=f"Invoice ID: {invoice_data['id']}", ln=True)
pdf.cell(0, 7, txt=f"Invoice Date: {invoice_data['date']}", ln=True)
pdf.ln(10)
# Line Items Table Header
pdf.set_font("Arial", 'B', size=10)
pdf.cell(100, 10, "Description", 1, 0, 'L')
pdf.cell(30, 10, "Quantity", 1, 0, 'C')
pdf.cell(30, 10, "Unit Price", 1, 0, 'R')
pdf.cell(30, 10, "Total", 1, 1, 'R')
pdf.set_font("Arial", size=10)
# Line Items (Example - you'd fetch actual line items from Stripe event data)
# For invoice.payment_succeeded, you typically need to retrieve the full Invoice object
# to get line items. For simplicity, we'll mock one or use a generic one.
line_items = invoice_data.get('line_items', [])
if not line_items:
# Fallback for simple cases or if line items aren't fully expanded
line_items = [{
'description': 'Payment for Services/Products',
'quantity': 1,
'unit_amount': float(invoice_data['amount_total']),
'total_amount': float(invoice_data['amount_total'])
}]
for item in line_items:
pdf.cell(100, 10, item.get('description', 'Item'), 1, 0, 'L')
pdf.cell(30, 10, str(item.get('quantity', 1)), 1, 0, 'C')
pdf.cell(30, 10, f"{item.get('unit_amount', 0):.2f}", 1, 0, 'R')
pdf.cell(30, 10, f"{item.get('total_amount', 0):.2f}", 1, 1, 'R')
pdf.ln(10)
# Total Amount
pdf.set_font("Arial", 'B', size=12)
pdf.cell(160, 10, "Total Due:", 1, 0, 'R')
pdf.cell(30, 10, f"{invoice_data['amount_total']} {invoice_data['currency'].upper()}", 1, 1, 'R')
# Output the PDF
filename = f"invoice_{invoice_data['id']}.pdf"
pdf.output(filename)
print(f"Generated {filename}")
return filename
@app.route('/stripe-webhook', methods=['POST'])
def stripe_webhook():
payload = request.get_data()
sig_header = request.headers.get('stripe-signature')
event = None
try:
# Verify the webhook signature for security
event = stripe.Webhook.construct_event(
payload, sig_header, webhook_secret
)
except ValueError as e:
# Invalid payload
print(f"Webhook Error: Invalid payload - {e}")
abort(400)
except stripe.error.SignatureVerificationError as e:
# Invalid signature
print(f"Webhook Error: Invalid signature - {e}")
abort(400)
# Handle the event type
if event['type'] == 'invoice.payment_succeeded':
invoice = event['data']['object']
print(f"Received invoice.payment_succeeded event for invoice ID: {invoice.id}")
# Ensure the invoice is not a draft or uncollectible
if invoice.get('status') == 'paid':
customer_id = invoice.get('customer')
customer_name = invoice.get('customer_name', 'N/A')
customer_address = invoice.get('customer_address') # This can be a dict
amount_paid_cents = invoice.get('amount_paid', 0)
currency = invoice.get('currency', 'usd')
invoice_id = invoice.get('id')
created_timestamp = invoice.get('created')
invoice_date = datetime.datetime.fromtimestamp(created_timestamp).strftime('%Y-%m-%d') if created_timestamp else 'N/A'
# Fetch line items more comprehensively
line_items_data = []
# To get full line item details (description, quantity, unit price)
# you usually need to retrieve the Invoice object with line_items expanded.
# For simplicity here, we'll try to extract from the event if possible or mock.
if 'lines' in invoice and 'data' in invoice['lines']:
for item in invoice['lines']['data']:
line_items_data.append({
'description': item.get('description', 'Service/Product'),
'quantity': item.get('quantity', 1),
'unit_amount': (item.get('amount', 0) / item.get('quantity', 1)) / 100 if item.get('quantity', 0) > 0 else 0,
'total_amount': item.get('amount', 0) / 100
})
else:
# Fallback if line items are not directly available or expanded
line_items_data.append({
'description': 'Payment for Services/Products',
'quantity': 1,
'unit_amount': amount_paid_cents / 100,
'total_amount': amount_paid_cents / 100
})
invoice_data = {
'id': invoice_id,
'customer_name': customer_name,
'customer_address': customer_address,
'amount_total': f"{(amount_paid_cents / 100):.2f}", # Convert cents to dollars
'currency': currency,
'date': invoice_date,
'line_items': line_items_data
}
try:
invoice_filename = generate_pdf_invoice(invoice_data)
print(f"Successfully generated invoice: {invoice_filename}")
# In a production setup, you would now upload this PDF to cloud storage (e.g., S3),
# send it to the customer via email, or integrate it with an accounting system.
except Exception as e:
print(f"Error generating PDF for invoice {invoice_id}: {e}")
# Consider logging this error to a monitoring system
# and implementing retry mechanisms or alerts.
else:
print(f"Invoice {invoice.id} is not 'paid' (status: {invoice.get('status')}). Skipping PDF generation.")
elif event['type'] == 'checkout.session.completed':
session = event['data']['object']
print(f"Received checkout.session.completed event for session ID: {session.id}")
# For checkout.session.completed, if you want an invoice, you might
# retrieve the associated invoice if one was created, or generate a simpler receipt.
# This tutorial focuses on invoice.payment_succeeded for full invoice detail.
# You could adapt generate_pdf_invoice here with session details if no invoice object is linked.
else:
print(f"Unhandled event type: {event['type']}")
return {'status': 'success'}, 200 # Stripe expects a 2xx response
if __name__ == '__main__':
# Flask defaults to port 5000, but 4242 is common for Stripe examples.
app.run(port=4242, debug=True) # debug=True for development, disable in production!
Explanation:
- The script initializes a Flask app and loads your Stripe API key and webhook secret from environment variables for security. Never hardcode these directly in your code, especially for production!
- The
/stripe-webhookendpoint is a POST route. Stripe will send event data to this URL. - The
stripe.Webhook.construct_eventfunction is crucial for verifying the authenticity and integrity of the incoming webhook payload using thestripe-signatureheader and your webhook secret. This protects your endpoint from malicious requests. - We specifically listen for the
invoice.payment_succeededevent. When this event occurs, we extract relevant information like the customerâs name, amount paid, currency, invoice ID, and date. We also include basic logic to extract line items. For more complex invoice details (e.g., detailed line items from a subscription), you might need to use the Stripe API to retrieve the full Invoice object (stripe.Invoice.retrieve(invoice.id, expand=['lines'])). - The
generate_pdf_invoicefunction takes this data and usesfpdf2to construct a PDF. It includes placeholders for company details, customer information, invoice specifics, and a simple table for line items. The generated PDF is saved locally. In a production environment, you would typically upload this PDF to an object storage service like AWS S3, Google Cloud Storage, or Azure Blob Storage, and then email it to the customer. - We also include a basic handler for
checkout.session.completed, although our primary focus for comprehensive invoice generation isinvoice.payment_succeeded. - Finally, the app runs on port
4242(or5000by default if not specified).
Step 3: Configure Environment Variables and Run Locally with Ngrok
Before running your Flask app, you need to set your Stripe API Key and Webhook Secret as environment variables.
First, obtain your Stripe Secret API Key from your Stripe Dashboard
(under Developers > API keys).
Next, create a webhook endpoint in your Stripe Dashboard
(under Developers > Webhooks). Click âAdd endpointâ, provide a placeholder URL for now (e.g., http://localhost:4242/stripe-webhook),
and select the invoice.payment_succeeded event. After creation, youâll see a âWebhook secretâ for this endpoint. Copy it.
Now, set these in your terminal:
# For Linux/macOS
export STRIPE_SECRET_KEY='sk_test_YOUR_STRIPE_SECRET_KEY'
export STRIPE_WEBHOOK_SECRET='whsec_YOUR_WEBHOOK_SECRET'
# For Windows (Command Prompt)
set STRIPE_SECRET_KEY=sk_test_YOUR_STRIPE_SECRET_KEY
set STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
# For Windows (PowerShell)
$env:STRIPE_SECRET_KEY='sk_test_YOUR_STRIPE_SECRET_KEY'
$env:STRIPE_WEBHOOK_SECRET='whsec_YOUR_WEBHOOK_SECRET'
Replace YOUR_STRIPE_SECRET_KEY and YOUR_WEBHOOK_SECRET with your actual keys.
Next, run your Flask application:
python3 app.py
You should see output similar to * Running on http://127.0.0.1:4242/ (Press CTRL+C to quit).
With your Flask app running, open another terminal and start Ngrok to expose your local server to the internet:
./ngrok http 4242
Ngrok will provide a public URL (e.g., https://RANDOM_SUBDOMAIN.ngrok-free.app). Copy this URL.
Go back to your Stripe Webhooks dashboard, edit the endpoint you created, and update its URL to the Ngrok URL, appending /stripe-webhook
(e.g., https://RANDOM_SUBDOMAIN.ngrok-free.app/stripe-webhook).
Step 4: Test Your Webhook Integration
Now itâs time to test! You can trigger a test invoice.payment_succeeded event directly from the Stripe Dashboard:
- Navigate to Developers > Webhooks.
- Find your webhook endpoint and click on it.
- In the âEventsâ section, click âSend test eventâ.
- From the dropdown, select
invoice.payment_succeededand click âSend test eventâ.
Alternatively, if you have a test mode checkout flow, complete a test payment that generates an invoice.
Observe your Flask terminal. You should see logs indicating the webhook was received, verified, and the PDF invoice generated.
A new PDF file (e.g., invoice_in_xxxxxxxxxxxxxx.pdf) should appear in the same directory as your app.py.
Step 5: Production Considerations (Briefly)
For a production deployment, consider the following:
- Hosting: Deploy your Flask application using a production-ready WSGI server like Gunicorn, behind a reverse proxy like Nginx or Apache, or as a serverless function (e.g., AWS Lambda, Google Cloud Functions).
- Security: Always use HTTPS for your webhook endpoint. Store API keys and secrets securely using environment variables or a secret management service. Implement proper error logging and monitoring.
- PDF Storage: Instead of saving locally, upload generated PDFs to cloud storage (e.g., AWS S3, Google Cloud Storage, Azure Blob Storage) and store the link in your database.
- Emailing: Integrate with an email service (SendGrid, Mailgun, AWS SES) to automatically send the generated PDF invoices to customers.
- Scalability: For high-volume environments, consider message queues (e.g., RabbitMQ, SQS, Kafka) to decouple webhook processing from PDF generation, allowing asynchronous and scalable handling.
Common Pitfalls
-
Webhook Signature Verification Failure: This is a common issue. Ensure your
STRIPE_WEBHOOK_SECRETenvironment variable exactly matches the secret in your Stripe Dashboard for the specific webhook endpoint. Also, confirm that your Flask app isnât performing any intermediary parsing of the raw request body before Stripeâs verification step, as this can alter the payload. -
Missing Environment Variables: If
STRIPE_SECRET_KEYorSTRIPE_WEBHOOK_SECRETare not set correctly, your application will fail to initialize or verify webhooks. Double-check your terminalâs environment variable setup. - Ngrok URL Mismatch: After restarting Ngrok, your public URL might change. Always update your webhook endpoint URL in the Stripe Dashboard with the new Ngrok URL.
-
Event Data Structure Changes: Stripeâs API can evolve. Always refer to the official Stripe Event types documentation to ensure youâre accessing the correct fields within the
event['data']['object']. Implement robust error handling and default values (e.g.,.get('key', 'default_value')) to gracefully handle missing fields. - Timeouts: Stripe expects a 2xx response from your webhook endpoint within a reasonable timeframe (typically 3-15 seconds). If PDF generation or other post-processing takes too long, offload it to a background job or queue.
Conclusion
Congratulations! Youâve just built a powerful automation for generating PDF invoices using Stripe webhooks and Python.
By eliminating manual effort, youâve not only saved valuable time but also enhanced the accuracy and professionalism
of your financial documentation. This setup provides a solid foundation for a more efficient operational workflow.
From here, the possibilities are vast. You can expand this system to automatically email invoices,
integrate with your existing accounting software, generate more complex invoice templates,
or even trigger different actions based on other Stripe events. The world of DevOps and automation
is yours to explore. Keep innovating, and keep resolving those tech challenges with TechResolve!
đ Read the original article on TechResolve.blog
â Support my work
If this article helped you, you can buy me a coffee:

Top comments (0)