DEV Community

Cover image for Solved: Automating Invoice Generation: Stripe Webhooks to PDF Invoice
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: Automating Invoice Generation: Stripe Webhooks to PDF Invoice

🚀 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\_event is crucial for securing the endpoint against malicious requests and ensuring payload integrity.
  • The invoice.payment\_succeeded event 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.
  • fpdf2 Library: A powerful Python library for PDF generation.
  • Flask Web 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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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-webhook endpoint is a POST route. Stripe will send event data to this URL.
  • The stripe.Webhook.construct_event function is crucial for verifying the authenticity and integrity of the incoming webhook payload using the stripe-signature header and your webhook secret. This protects your endpoint from malicious requests.
  • We specifically listen for the invoice.payment_succeeded event. 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_invoice function takes this data and uses fpdf2 to 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 is invoice.payment_succeeded.
  • Finally, the app runs on port 4242 (or 5000 by 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'
Enter fullscreen mode Exit fullscreen mode

Replace YOUR_STRIPE_SECRET_KEY and YOUR_WEBHOOK_SECRET with your actual keys.

Next, run your Flask application:

python3 app.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_succeeded and 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_SECRET environment 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_KEY or STRIPE_WEBHOOK_SECRET are 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!


Darian Vance

👉 Read the original article on TechResolve.blog


☕ Support my work

If this article helped you, you can buy me a coffee:

👉 https://buymeacoffee.com/darianvance

Top comments (0)