Recipe · Node.js

Generate invoice PDFs from Stripe webhooks with Node.js

This recipe shows a webhook handler and its error handling around the same endpoint documented on the main API page — read that first for the full field reference. When Stripe reports a completed Checkout Session, this handler maps it into an InvoiceCraftly invoice, generates a PDF, and saves it to disk.

What you'll need

  • An InvoiceCraftly API key (see the API reference).
  • Node.js 18 or later.
  • A Stripe account (test mode is fine) with a webhook endpoint configured for checkout.session.completed, and its signing secret.
  • The official stripe npm package: npm install stripe.

Map the Stripe session into an invoice

This pure function has no Stripe or network dependency, so it's easy to unit test on its own before wiring it into a webhook handler:

// map-stripe-session.js
function addressLinesFrom(address) {
  const stateZip = [address.state, address.postal_code].filter(Boolean).join(' ');
  const cityLine = [address.city, stateZip].filter(Boolean).join(', ');
  return [address.line1, address.line2, cityLine, address.country].filter((line) => line && line.trim().length > 0);
}

export function mapStripeSessionToInvoiceDocument(session, { seller, number, issueDate, dueDate }) {
  const document = {
    type: 'invoice',
    number,
    issueDate,
    currency: session.currency.toUpperCase(),
    seller,
    buyer: {
      name: session.customer_details.name,
      addressLines: addressLinesFrom(session.customer_details.address)
    },
    items: session.line_items.data.map((item) => ({
      description: item.description,
      quantity: item.quantity,
      unitPrice: item.price.unit_amount / 100,
      taxRate: 0
    }))
  };
  if (dueDate) document.dueDate = dueDate;
  return document;
}

The webhook handler

// npm install stripe
import { createServer } from 'node:http';
import { mkdir, writeFile } from 'node:fs/promises';
import Stripe from 'stripe';
import { mapStripeSessionToInvoiceDocument } from './map-stripe-session.js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const invoiceCraftlyApiKey = process.env.INVOICECRAFTLY_API_KEY;

const seller = { name: 'Your Company Name', addressLines: ['Your Company Address'] };

await mkdir('invoices', { recursive: true });

createServer(async (request, response) => {
  const chunks = [];
  for await (const chunk of request) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks);

  let event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, request.headers['stripe-signature'], endpointSecret);
  } catch (error) {
    response.writeHead(400);
    return response.end(`Webhook signature verification failed: ${error.message}`);
  }

  if (event.type === 'checkout.session.completed') {
    const session = await stripe.checkout.sessions.retrieve(event.data.object.id, { expand: ['line_items'] });
    const invoice = mapStripeSessionToInvoiceDocument(session, {
      seller,
      number: session.id,
      issueDate: new Date().toISOString().slice(0, 10)
    });

    const pdfResponse = await fetch('https://invoicecraftly.com/api/v1/documents/pdf', {
      method: 'POST',
      headers: { Authorization: `Bearer ${invoiceCraftlyApiKey}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(invoice)
    });

    if (pdfResponse.ok) {
      const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
      await writeFile(`invoices/${session.id}.pdf`, pdfBuffer);
    } else {
      console.error(`InvoiceCraftly render failed: ${pdfResponse.status}`);
    }
  }

  response.writeHead(200);
  response.end('ok');
}).listen(3000);

Checkout Sessions don't include line items in the webhook payload — line_items is an expandable field, so event.data.object.line_items is undefined. Retrieving the session with expand: ['line_items'] is what populates them, and it's the most common gotcha when wiring Stripe Checkout to any downstream system, not just this one.

One currency caveat before you go live: for zero-decimal currencies (JPY, KRW, VND and friends) Stripe's unit_amount is already in whole units, so the mapper's unit_amount / 100 is only correct for standard two-decimal currencies like USD or EUR.

What happens on error

Handle a failed render without losing the payment

A non-200 InvoiceCraftly response above still means Stripe already has your payment — the handler above logs the failure and returns 200 to Stripe regardless, so Stripe doesn't retry the whole webhook. Check for AUTHENTICATION_FAILED (bad key), RATE_LIMITED (retry after the header's delay), INVALID_REQUEST (a 400 — malformed or missing fields in the JSON body), and TARGET_NOT_SUPPORTED (a 422 — the requested document type or template isn't supported, not a general body-shape problem) from the full error table, and queue a retry of just the PDF render, not the whole webhook.

Start building

Get an API key and try this recipe.

Free during the beta, 100 renders included per month.

Get your API key