How to Automate Invoice Generation with PDFKit After Successful Stripe Payments

The Problem

Manual invoice generation after payments is tedious, error-prone, and doesn’t scale. Customers expect instant invoices for tax compliance and record-keeping, but generating PDFs manually—or worse, using third-party services that charge per document—adds friction and cost. Stripe provides payment data but doesn’t offer customizable invoice PDFs that match your branding or include all required business details. You need to capture webhook events, extract payment metadata, generate professional PDFs with line items and tax calculations, and deliver them automatically via email or download. Miss a webhook, and customers don’t get invoices; botch the PDF layout, and it looks unprofessional. This integration solves all that: when Stripe confirms a payment, your system automatically generates a branded, tax-compliant PDF invoice and stores it for customer access—no manual work, no delays.

Tech Stack & Prerequisites

  • Node.js v20+ and npm/pnpm
  • TypeScript 5+ (recommended)
  • Stripe Account with API keys (test mode)
  • stripe npm package v14+
  • pdfkit v0.14+ for PDF generation
  • Express.js v4.18+ for webhook handling
  • nodemailer v6+ or SendGrid for email delivery
  • AWS S3 or local file storage for invoice storage
  • dotenv for environment variables

Step-by-Step Implementation

Step 1: Setup

Initialize your Node.js project:

bash
mkdir stripe-invoice-automation
cd stripe-invoice-automation
npm init -y
npm install stripe pdfkit express dotenv nodemailer
npm install -D typescript @types/node @types/express @types/pdfkit tsx nodemon

Initialize TypeScript:

bash
npx tsc --init

Update tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Create project structure:

bash
mkdir -p src invoices
touch src/index.ts src/config.ts src/stripe-client.ts src/pdf-generator.ts src/email-service.ts src/webhook.ts

Update package.json scripts:

json
{
  "scripts": {
    "dev": "nodemon --exec tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Step 2: Configuration

Create .env file:

bash
# .env
PORT=3000

# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here

# Company Information (for invoices)
COMPANY_NAME=Your Company Inc.
COMPANY_EMAIL=billing@yourcompany.com
COMPANY_ADDRESS=123 Business St
COMPANY_CITY=San Francisco, CA 94105
COMPANY_PHONE=+1-555-123-4567
COMPANY_WEBSITE=https://yourcompany.com
COMPANY_TAX_ID=12-3456789

# Email Configuration (using Gmail for example)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-app-specific-password

# Or use SendGrid
# SENDGRID_API_KEY=your_sendgrid_api_key

# Storage
INVOICE_STORAGE_PATH=./invoices

Add to .gitignore:

bash
echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore
echo "dist/" >> .gitignore
echo "invoices/*.pdf" >> .gitignore

Create src/config.ts:

typescript
// src/config.ts
import dotenv from 'dotenv';

dotenv.config();

export const config = {
  port: process.env.PORT || 3000,
  stripe: {
    secretKey: process.env.STRIPE_SECRET_KEY || '',
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
  },
  company: {
    name: process.env.COMPANY_NAME || '',
    email: process.env.COMPANY_EMAIL || '',
    address: process.env.COMPANY_ADDRESS || '',
    city: process.env.COMPANY_CITY || '',
    phone: process.env.COMPANY_PHONE || '',
    website: process.env.COMPANY_WEBSITE || '',
    taxId: process.env.COMPANY_TAX_ID || '',
  },
  email: {
    host: process.env.EMAIL_HOST || '',
    port: parseInt(process.env.EMAIL_PORT || '587'),
    user: process.env.EMAIL_USER || '',
    password: process.env.EMAIL_PASSWORD || '',
  },
  storage: {
    path: process.env.INVOICE_STORAGE_PATH || './invoices',
  },
} as const;

// Validate required environment variables
const requiredEnvVars = [
  'STRIPE_SECRET_KEY',
  'STRIPE_WEBHOOK_SECRET',
  'COMPANY_NAME',
  'EMAIL_USER',
  'EMAIL_PASSWORD',
];

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}

Step 3: Core Logic

3.1: Stripe Client

Create src/stripe-client.ts:

typescript
// src/stripe-client.ts
import Stripe from 'stripe';
import { config } from './config';

export const stripe = new Stripe(config.stripe.secretKey, {
  apiVersion: '2024-11-20.acacia',
  typescript: true,
});

/**
 * Retrieve payment intent with all necessary data
 */
export async function getPaymentDetails(paymentIntentId: string) {
  try {
    const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
      expand: ['customer', 'charges.data.balance_transaction'],
    });

    return paymentIntent;
  } catch (error) {
    console.error('Error retrieving payment intent:', error);
    throw error;
  }
}

/**
 * Retrieve customer details
 */
export async function getCustomerDetails(customerId: string) {
  try {
    const customer = await stripe.customers.retrieve(customerId);
    return customer;
  } catch (error) {
    console.error('Error retrieving customer:', error);
    throw error;
  }
}

/**
 * Create invoice metadata on payment intent
 */
export async function updatePaymentIntentMetadata(
  paymentIntentId: string,
  metadata: Record<string, string>
) {
  try {
    await stripe.paymentIntents.update(paymentIntentId, {
      metadata,
    });
  } catch (error) {
    console.error('Error updating payment intent metadata:', error);
    throw error;
  }
}

3.2: PDF Invoice Generator

Create src/pdf-generator.ts:

typescript
// src/pdf-generator.ts
import PDFDocument from 'pdfkit';
import fs from 'fs';
import path from 'path';
import { config } from './config';

export interface InvoiceData {
  invoiceNumber: string;
  invoiceDate: Date;
  dueDate?: Date;
  customer: {
    name: string;
    email: string;
    address?: string;
    city?: string;
  };
  items: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    amount: number;
  }>;
  subtotal: number;
  tax?: number;
  total: number;
  currency: string;
  paymentMethod?: string;
  notes?: string;
}

export class InvoiceGenerator {
  private doc: PDFKit.PDFDocument;
  private fileName: string;
  private filePath: string;

  constructor(invoiceNumber: string) {
    this.doc = new PDFDocument({ size: 'A4', margin: 50 });
    this.fileName = `invoice-${invoiceNumber}.pdf`;
    this.filePath = path.join(config.storage.path, this.fileName);

    // Ensure storage directory exists
    if (!fs.existsSync(config.storage.path)) {
      fs.mkdirSync(config.storage.path, { recursive: true });
    }
  }

  /**
   * Generate complete invoice PDF
   */
  async generate(data: InvoiceData): Promise<string> {
    return new Promise((resolve, reject) => {
      try {
        const stream = fs.createWriteStream(this.filePath);

        this.doc.pipe(stream);

        // Generate invoice sections
        this.generateHeader();
        this.generateCustomerInfo(data.customer);
        this.generateInvoiceDetails(data);
        this.generateItemsTable(data.items);
        this.generateTotal(data);
        this.generateFooter(data);

        this.doc.end();

        stream.on('finish', () => {
          console.log(`Invoice generated: ${this.filePath}`);
          resolve(this.filePath);
        });

        stream.on('error', reject);
      } catch (error) {
        reject(error);
      }
    });
  }

  /**
   * Generate invoice header with company logo and info
   */
  private generateHeader() {
    this.doc
      .fontSize(20)
      .font('Helvetica-Bold')
      .text(config.company.name, 50, 50);

    this.doc
      .fontSize(10)
      .font('Helvetica')
      .text(config.company.address, 50, 80)
      .text(config.company.city, 50, 95)
      .text(config.company.phone, 50, 110)
      .text(config.company.email, 50, 125)
      .text(`Tax ID: ${config.company.taxId}`, 50, 140);

    // Invoice title
    this.doc
      .fontSize(24)
      .font('Helvetica-Bold')
      .text('INVOICE', 400, 50, { align: 'right' });

    this.doc.moveDown(2);
  }

  /**
   * Generate customer information section
   */
  private generateCustomerInfo(customer: InvoiceData['customer']) {
    const startY = 180;

    this.doc
      .fontSize(12)
      .font('Helvetica-Bold')
      .text('Bill To:', 50, startY);

    this.doc
      .fontSize(10)
      .font('Helvetica')
      .text(customer.name, 50, startY + 20)
      .text(customer.email, 50, startY + 35);

    if (customer.address) {
      this.doc.text(customer.address, 50, startY + 50);
    }

    if (customer.city) {
      this.doc.text(customer.city, 50, startY + 65);
    }
  }

  /**
   * Generate invoice details (number, date, etc.)
   */
  private generateInvoiceDetails(data: InvoiceData) {
    const startY = 180;

    this.doc
      .fontSize(10)
      .font('Helvetica-Bold')
      .text('Invoice Number:', 350, startY)
      .font('Helvetica')
      .text(data.invoiceNumber, 470, startY);

    this.doc
      .font('Helvetica-Bold')
      .text('Invoice Date:', 350, startY + 20)
      .font('Helvetica')
      .text(this.formatDate(data.invoiceDate), 470, startY + 20);

    if (data.dueDate) {
      this.doc
        .font('Helvetica-Bold')
        .text('Due Date:', 350, startY + 40)
        .font('Helvetica')
        .text(this.formatDate(data.dueDate), 470, startY + 40);
    }

    if (data.paymentMethod) {
      this.doc
        .font('Helvetica-Bold')
        .text('Payment Method:', 350, startY + 60)
        .font('Helvetica')
        .text(data.paymentMethod, 470, startY + 60);
    }
  }

  /**
   * Generate line items table
   */
  private generateItemsTable(items: InvoiceData['items']) {
    const tableTop = 280;
    const descriptionX = 50;
    const quantityX = 280;
    const priceX = 350;
    const amountX = 450;

    // Table header
    this.doc
      .fontSize(10)
      .font('Helvetica-Bold')
      .text('Description', descriptionX, tableTop)
      .text('Qty', quantityX, tableTop)
      .text('Unit Price', priceX, tableTop)
      .text('Amount', amountX, tableTop);

    // Draw header line
    this.doc
      .strokeColor('#aaaaaa')
      .lineWidth(1)
      .moveTo(50, tableTop + 15)
      .lineTo(550, tableTop + 15)
      .stroke();

    // Table rows
    let currentY = tableTop + 25;

    items.forEach((item, index) => {
      this.doc
        .fontSize(9)
        .font('Helvetica')
        .text(item.description, descriptionX, currentY, { width: 200 })
        .text(item.quantity.toString(), quantityX, currentY)
        .text(this.formatCurrency(item.unitPrice), priceX, currentY)
        .text(this.formatCurrency(item.amount), amountX, currentY);

      currentY += 25;

      // Add page if needed
      if (currentY > 700) {
        this.doc.addPage();
        currentY = 50;
      }
    });

    return currentY;
  }

  /**
   * Generate totals section
   */
  private generateTotal(data: InvoiceData) {
    const startY = 500;

    // Draw separator line
    this.doc
      .strokeColor('#aaaaaa')
      .lineWidth(1)
      .moveTo(350, startY - 10)
      .lineTo(550, startY - 10)
      .stroke();

    // Subtotal
    this.doc
      .fontSize(10)
      .font('Helvetica')
      .text('Subtotal:', 350, startY)
      .text(this.formatCurrency(data.subtotal), 450, startY, { align: 'right' });

    // Tax (if applicable)
    if (data.tax && data.tax > 0) {
      this.doc
        .text('Tax:', 350, startY + 20)
        .text(this.formatCurrency(data.tax), 450, startY + 20, { align: 'right' });
    }

    // Total
    this.doc
      .fontSize(12)
      .font('Helvetica-Bold')
      .text('Total:', 350, startY + 45)
      .text(
        this.formatCurrency(data.total),
        450,
        startY + 45,
        { align: 'right' }
      );

    // Payment status
    this.doc
      .fontSize(14)
      .fillColor('#27ae60')
      .text('PAID', 350, startY + 70);

    this.doc.fillColor('#000000'); // Reset color
  }

  /**
   * Generate footer with notes and thank you message
   */
  private generateFooter(data: InvoiceData) {
    const footerY = 680;

    if (data.notes) {
      this.doc
        .fontSize(10)
        .font('Helvetica-Bold')
        .text('Notes:', 50, footerY);

      this.doc
        .fontSize(9)
        .font('Helvetica')
        .text(data.notes, 50, footerY + 15, { width: 500 });
    }

    // Thank you message
    this.doc
      .fontSize(12)
      .font('Helvetica-Bold')
      .text('Thank you for your business!', 50, 750, {
        align: 'center',
        width: 500,
      });

    // Footer info
    this.doc
      .fontSize(8)
      .font('Helvetica')
      .text(
        `${config.company.website} | ${config.company.email} | ${config.company.phone}`,
        50,
        770,
        { align: 'center', width: 500 }
      );
  }

  /**
   * Format currency
   */
  private formatCurrency(amount: number, currency: string = 'USD'): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: currency,
    }).format(amount / 100); // Stripe amounts are in cents
  }

  /**
   * Format date
   */
  private formatDate(date: Date): string {
    return new Intl.DateTimeFormat('en-US', {
      year: 'numeric',
      month: 'long',
      day: 'numeric',
    }).format(date);
  }

  /**
   * Get file path
   */
  getFilePath(): string {
    return this.filePath;
  }

  /**
   * Get file name
   */
  getFileName(): string {
    return this.fileName;
  }
}

3.3: Email Service

Create src/email-service.ts:

typescript
// src/email-service.ts
import nodemailer from 'nodemailer';
import { config } from './config';

export class EmailService {
  private transporter: nodemailer.Transporter;

  constructor() {
    this.transporter = nodemailer.createTransport({
      host: config.email.host,
      port: config.email.port,
      secure: false, // true for 465, false for other ports
      auth: {
        user: config.email.user,
        pass: config.email.password,
      },
    });
  }

  /**
   * Send invoice email with PDF attachment
   */
  async sendInvoice(
    recipientEmail: string,
    recipientName: string,
    invoiceNumber: string,
    invoicePath: string,
    amount: number
  ): Promise<void> {
    try {
      const mailOptions = {
        from: `"${config.company.name}" <${config.email.user}>`,
        to: recipientEmail,
        subject: `Invoice ${invoiceNumber} from ${config.company.name}`,
        html: this.generateEmailTemplate(recipientName, invoiceNumber, amount),
        attachments: [
          {
            filename: `invoice-${invoiceNumber}.pdf`,
            path: invoicePath,
            contentType: 'application/pdf',
          },
        ],
      };

      const info = await this.transporter.sendMail(mailOptions);
      console.log('Invoice email sent:', info.messageId);
    } catch (error) {
      console.error('Error sending invoice email:', error);
      throw error;
    }
  }

  /**
   * Generate HTML email template
   */
  private generateEmailTemplate(
    recipientName: string,
    invoiceNumber: string,
    amount: number
  ): string {
    const formattedAmount = new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD',
    }).format(amount / 100);

    return `
      <!DOCTYPE html>
      <html>
      <head>
        <style>
          body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
          .container { max-width: 600px; margin: 0 auto; padding: 20px; }
          .header { background-color: #4CAF50; color: white; padding: 20px; text-align: center; }
          .content { padding: 20px; background-color: #f9f9f9; }
          .invoice-details { background-color: white; padding: 15px; margin: 20px 0; border-left: 4px solid #4CAF50; }
          .footer { text-align: center; padding: 20px; font-size: 12px; color: #666; }
          .button { display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px; }
        </style>
      </head>
      <body>
        <div class="container">
          <div class="header">
            <h1>${config.company.name}</h1>
            <p>Payment Receipt</p>
          </div>
          <div class="content">
            <p>Dear ${recipientName},</p>
            <p>Thank you for your payment. Your transaction has been completed successfully.</p>
            
            <div class="invoice-details">
              <h3>Invoice Details</h3>
              <p><strong>Invoice Number:</strong> ${invoiceNumber}</p>
              <p><strong>Amount Paid:</strong> ${formattedAmount}</p>
              <p><strong>Payment Status:</strong> <span style="color: #4CAF50;">PAID</span></p>
            </div>

            <p>Please find your invoice attached to this email. Keep this for your records.</p>
            
            <p>If you have any questions about this invoice, please contact us at ${config.company.email}.</p>
          </div>
          <div class="footer">
            <p>${config.company.name}<br>
            ${config.company.address}<br>
            ${config.company.city}<br>
            ${config.company.phone} | ${config.company.email}</p>
          </div>
        </div>
      </body>
      </html>
    `;
  }

  /**
   * Test email connection
   */
  async testConnection(): Promise<boolean> {
    try {
      await this.transporter.verify();
      console.log('Email service is ready');
      return true;
    } catch (error) {
      console.error('Email service error:', error);
      return false;
    }
  }
}

3.4: Webhook Handler

Create src/webhook.ts:

typescript
// src/webhook.ts
import { Request, Response } from 'express';
import Stripe from 'stripe';
import { stripe, getPaymentDetails } from './stripe-client';
import { InvoiceGenerator, InvoiceData } from './pdf-generator';
import { EmailService } from './email-service';
import { config } from './config';

const emailService = new EmailService();

/**
 * Process successful payment and generate invoice
 */
async function processPaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
  try {
    console.log(`Processing payment: ${paymentIntent.id}`);

    // Generate unique invoice number
    const invoiceNumber = generateInvoiceNumber(paymentIntent.id);

    // Extract customer information
    const customer = paymentIntent.customer as Stripe.Customer;
    const customerName = customer.name || customer.email || 'Valued Customer';
    const customerEmail = customer.email || '';

    // Parse line items from metadata (you should set this when creating payment intent)
    const items = parseLineItems(paymentIntent.metadata);

    // Calculate amounts
    const subtotal = paymentIntent.amount;
    const tax = 0; // Add tax calculation if needed
    const total = paymentIntent.amount;

    // Prepare invoice data
    const invoiceData: InvoiceData = {
      invoiceNumber,
      invoiceDate: new Date(),
      customer: {
        name: customerName,
        email: customerEmail,
        address: customer.address?.line1,
        city: `${customer.address?.city}, ${customer.address?.state} ${customer.address?.postal_code}`,
      },
      items,
      subtotal,
      tax,
      total,
      currency: paymentIntent.currency.toUpperCase(),
      paymentMethod: getPaymentMethodDescription(paymentIntent),
      notes: 'Payment received. Thank you for your business!',
    };

    // Generate PDF invoice
    const invoiceGenerator = new InvoiceGenerator(invoiceNumber);
    const invoicePath = await invoiceGenerator.generate(invoiceData);

    console.log(`Invoice generated: ${invoicePath}`);

    // Send invoice via email
    if (customerEmail) {
      await emailService.sendInvoice(
        customerEmail,
        customerName,
        invoiceNumber,
        invoicePath,
        total
      );

      console.log(`Invoice emailed to: ${customerEmail}`);
    }

    // Update payment intent metadata with invoice number
    await stripe.paymentIntents.update(paymentIntent.id, {
      metadata: {
        ...paymentIntent.metadata,
        invoice_number: invoiceNumber,
        invoice_generated: 'true',
      },
    });

    return { success: true, invoiceNumber, invoicePath };
  } catch (error) {
    console.error('Error processing payment:', error);
    throw error;
  }
}

/**
 * Generate unique invoice number
 */
function generateInvoiceNumber(paymentIntentId: string): string {
  const timestamp = new Date().getTime();
  const random = Math.floor(Math.random() * 1000);
  return `INV-${timestamp}-${random}`;
}

/**
 * Parse line items from payment intent metadata
 */
function parseLineItems(metadata: Stripe.Metadata): InvoiceData['items'] {
  // If items are stored in metadata as JSON
  if (metadata.items) {
    try {
      return JSON.parse(metadata.items);
    } catch (error) {
      console.error('Error parsing items from metadata:', error);
    }
  }

  // Default fallback item
  return [
    {
      description: metadata.description || 'Service/Product',
      quantity: 1,
      unitPrice: parseInt(metadata.amount || '0'),
      amount: parseInt(metadata.amount || '0'),
    },
  ];
}

/**
 * Get payment method description
 */
function getPaymentMethodDescription(paymentIntent: Stripe.PaymentIntent): string {
  const charge = paymentIntent.charges?.data[0];
  if (!charge || !charge.payment_method_details) {
    return 'Card';
  }

  const details = charge.payment_method_details;

  if (details.card) {
    return `${details.card.brand?.toUpperCase()} •••• ${details.card.last4}`;
  }

  return details.type || 'Card';
}

/**
 * Handle Stripe webhook
 */
export async function handleWebhook(req: Request, res: Response) {
  const sig = req.headers['stripe-signature'];

  if (!sig) {
    return res.status(400).send('Missing stripe-signature header');
  }

  let event: Stripe.Event;

  try {
    // Verify webhook signature
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      config.stripe.webhookSecret
    );
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the event
  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        const paymentIntent = event.data.object as Stripe.PaymentIntent;
        await processPaymentSuccess(paymentIntent);
        break;

      case 'charge.succeeded':
        console.log('Charge succeeded:', event.data.object.id);
        break;

      case 'payment_intent.payment_failed':
        console.log('Payment failed:', event.data.object.id);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    res.json({ received: true });
  } catch (error) {
    console.error('Error handling webhook:', error);
    res.status(500).json({ error: 'Webhook handler failed' });
  }
}

3.5: Main Application

Create src/index.ts:

typescript
// src/index.ts
import express from 'express';
import { config } from './config';
import { handleWebhook } from './webhook';
import { EmailService } from './email-service';

const app = express();

// Stripe webhook endpoint - MUST use raw body
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  handleWebhook
);

// Other routes use JSON parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Health check
app.get('/', (req, res) => {
  res.json({
    name: 'Stripe Invoice Automation',
    version: '1.0.0',
    status: 'running',
  });
});

// Serve invoice files (for testing/download)
app.use('/invoices', express.static(config.storage.path));

// Start server
async function start() {
  // Test email service on startup
  const emailService = new EmailService();
  const emailReady = await emailService.testConnection();

  if (!emailReady) {
    console.warn('Warning: Email service not configured properly');
  }

  app.listen(config.port, () => {
    console.log(`Server running on http://localhost:${config.port}`);
    console.log(`Webhook endpoint: http://localhost:${config.port}/webhook`);
    console.log(`Invoices stored in: ${config.storage.path}`);
  });
}

start();

Step 4: Testing

4.1: Start the Server

Run the development server:

bash
npm run dev
```

Expected output:
```
Email service is ready
Server running on http://localhost:3000
Webhook endpoint: http://localhost:3000/webhook
Invoices stored in: ./invoices

4.2: Set Up Stripe Webhook

Install Stripe CLI:

bash
# macOS
brew install stripe/stripe-cli/stripe

# Windows/Linux - download from stripe.com/docs/stripe-cli

Login to Stripe CLI:

bash
stripe login

Forward webhooks to your local server:

bash
stripe listen --forward-to localhost:3000/webhook
```

This will output a webhook signing secret like:
```
whsec_xxxxxxxxxxxxxxxxxxxxx

Copy this to your .env file as STRIPE_WEBHOOK_SECRET.

4.3: Create Test Payment

Create a test script src/test-payment.ts:

typescript
// src/test-payment.ts
import { stripe } from './stripe-client';

async function createTestPayment() {
  try {
    // Create a customer
    const customer = await stripe.customers.create({
      name: 'John Doe',
      email: 'john.doe@example.com',
      address: {
        line1: '123 Test Street',
        city: 'San Francisco',
        state: 'CA',
        postal_code: '94105',
        country: 'US',
      },
    });

    console.log('Customer created:', customer.id);

    // Create payment intent with line items in metadata
    const paymentIntent = await stripe.paymentIntents.create({
      amount: 5000, // $50.00 in cents
      currency: 'usd',
      customer: customer.id,
      payment_method_types: ['card'],
      metadata: {
        description: 'Monthly Subscription',
        items: JSON.stringify([
          {
            description: 'Pro Plan Subscription',
            quantity: 1,
            unitPrice: 3000,
            amount: 3000,
          },
          {
            description: 'Additional Storage (10GB)',
            quantity: 2,
            unitPrice: 1000,
            amount: 2000,
          },
        ]),
      },
    });

    console.log('Payment Intent created:', paymentIntent.id);

    // Confirm payment with test card
    const confirmed = await stripe.paymentIntents.confirm(paymentIntent.id, {
      payment_method: 'pm_card_visa', // Test card
    });

    console.log('Payment confirmed:', confirmed.status);
    console.log('Check your webhook listener and email for invoice!');
  } catch (error) {
    console.error('Test payment error:', error);
  }
}

createTestPayment();

Run the test:

bash
npx tsx src/test-payment.ts
```

Expected output:
```
Customer created: cus_xxxxx
Payment Intent created: pi_xxxxx
Payment confirmed: succeeded
Check your webhook listener and email for invoice!
```

#### 4.4: Verify Invoice Generation

Check the console output of your webhook listener:
```
Processing payment: pi_xxxxx
Invoice generated: ./invoices/invoice-INV-1234567890-123.pdf
Invoice emailed to: john.doe@example.com

Check the invoices/ directory for the generated PDF.

Check your email inbox for the invoice email with PDF attachment.

4.5: Test with Stripe Dashboard

  1. Go to Stripe Dashboard
  2. Create a new payment manually
  3. Use test card: 4242 4242 4242 4242
  4. Complete payment
  5. Check webhook logs and invoice generation

4.6: Download Invoice via API

Add route to src/index.ts:

typescript
// Get invoice by payment intent ID
app.get('/invoice/:paymentIntentId', async (req, res) => {
  try {
    const { paymentIntentId } = req.params;
    
    const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
    const invoiceNumber = paymentIntent.metadata.invoice_number;
    
    if (!invoiceNumber) {
      return res.status(404).json({ error: 'Invoice not found' });
    }
    
    const filePath = path.join(config.storage.path, `invoice-${invoiceNumber}.pdf`);
    
    if (!fs.existsSync(filePath)) {
      return res.status(404).json({ error: 'Invoice file not found' });
    }
    
    res.download(filePath);
  } catch (error) {
    res.status(500).json({ error: 'Failed to retrieve invoice' });
  }
});

Test download:

bash
curl http://localhost:3000/invoice/pi_xxxxx --output invoice.pdf

Common Errors & Troubleshooting

1. Error: “No signatures found matching the expected signature for payload”

Cause: Webhook signature verification failed because the signing secret is incorrect or the raw request body was modified by middleware.

Fix: Ensure webhook endpoint receives raw body BEFORE JSON parser:

typescript
// WRONG - JSON parser runs before webhook
app.use(express.json());
app.post('/webhook', handleWebhook);

// CORRECT - Webhook gets raw body
app.post('/webhook', express.raw({ type: 'application/json' }), handleWebhook);
app.use(express.json()); // Other routes after webhook

Verify your webhook secret matches:

bash
# Get webhook secret from Stripe CLI
stripe listen --print-secret

# Or from Stripe Dashboard
# Developers → Webhooks → Add endpoint → Get signing secret

2. Error: “ENOENT: no such file or directory” when generating PDF

Cause: Storage directory doesn’t exist or path is incorrect.

Fix: Ensure directory creation in PDF generator:

typescript
constructor(invoiceNumber: string) {
  this.fileName = `invoice-${invoiceNumber}.pdf`;
  this.filePath = path.join(config.storage.path, this.fileName);

  // Create directory if it doesn't exist
  if (!fs.existsSync(config.storage.path)) {
    fs.mkdirSync(config.storage.path, { recursive: true });
  }
}

For production using absolute paths:

typescript
// In config.ts
storage: {
  path: process.env.NODE_ENV === 'production' 
    ? '/var/app/invoices' 
    : path.join(__dirname, '../invoices'),
}

3. Error: Email not sending or “Authentication failed”

Cause: Email credentials are incorrect, Gmail blocking less secure apps, or 2FA is enabled without app-specific password.

Fix: For Gmail, use app-specific password:

  1. Go to Google Account → Security
  2. Enable 2-Step Verification
  3. Generate App Password
  4. Use app password in .env instead of regular password
bash
EMAIL_PASSWORD=your-16-character-app-password

For SendGrid alternative:

typescript
// Install SendGrid
npm install @sendgrid/mail

// src/email-service.ts
import sgMail from '@sendgrid/mail';

export class EmailService {
  constructor() {
    sgMail.setApiKey(process.env.SENDGRID_API_KEY || '');
  }

  async sendInvoice(...) {
    const msg = {
      to: recipientEmail,
      from: config.email.user,
      subject: `Invoice ${invoiceNumber}`,
      html: this.generateEmailTemplate(...),
      attachments: [{
        content: fs.readFileSync(invoicePath).toString('base64'),
        filename: `invoice-${invoiceNumber}.pdf`,
        type: 'application/pdf',
        disposition: 'attachment',
      }],
    };
    
    await sgMail.send(msg);
  }
}

Security Checklist

  • Verify webhook signatures using stripe.webhooks.constructEvent() to prevent spoofed events
  • Never commit .env files, Stripe keys, or email passwords to version control
  • Use HTTPS in production to protect webhook data in transit
  • Implement rate limiting on webhook endpoint to prevent abuse
  • Validate payment amounts match expected values before generating invoices
  • Store invoices securely with proper file permissions (chmod 600) or use S3 with private buckets
  • Use environment-specific Stripe keys for development, staging, and production
  • Sanitize customer data before inserting into PDFs to prevent injection attacks
  • Implement access control on invoice download endpoints to prevent unauthorized access
  • Log all invoice generation events for audit trails and compliance
  • Set invoice retention policies and auto-delete old invoices per GDPR requirements
  • Use app-specific passwords for email services, never regular account passwords
  • Encrypt invoices at rest if storing sensitive tax or payment information
  • Validate email addresses before sending to prevent email abuse
  • Implement webhook replay protection by storing processed event IDs
  • Monitor webhook failures and implement retry mechanisms for critical events
  • Use signed URLs for invoice downloads with expiration times
  • Restrict file access by validating payment intent ownership before serving PDFs

Related Resources

If you’re building a complete payment infrastructure, check out our guide on integrating Stripe usage-based billing with Next.js to handle recurring subscriptions and metered pricing alongside one-time payments.

For developers working with calendar integrations to schedule payment reminders or billing cycles, our tutorial on integrating Google Calendar API with Node.js provides essential patterns for time-based automation.

When building notification systems to alert customers about payment status or invoice availability, explore our guide on Twilio and Node.js for SMS notifications.

For teams managing vector data or AI-powered invoice processing, our article on Pinecone and vector databases covers advanced search and retrieval patterns.

Looking to implement secure authentication for your payment dashboard? Read our comprehensive guide on securing APIs with OAuth 2.0 and JWT to protect sensitive financial data.

Leave a Comment

Your email address will not be published. Required fields are marked *

banner
Scroll to Top