DEV Community

Michael Rice
Michael Rice

Posted on

Stripe webhook to branded invoice PDF: a Cloudflare Worker recipe that keeps the file on your side

A Stripe payment lands, and someone wants a PDF invoice that looks like your product: your logo, your brand color, your layout, saved where you keep records. Stripe already hosts its own invoice PDF (invoice_pdf on the invoice). This recipe is for when you want your own template and your own copy.

Status note: Slipmint is a side project in development. Free API keys go out by invite to waitlist members, there's no SLA, and payments aren't live.

Here's the whole flow:

Stripe ──invoice.paid──► your Worker: verify signature · map invoice to JSON · reply 200
                              │ (after the response)
                              └──► Slipmint POST /v1/pdf ──PDF──► your R2 bucket
Enter fullscreen mode Exit fullscreen mode

1. Verify the signature on the raw body

Stripe signs every webhook. Verify it before you trust a single field, using Stripe's own SDK. Workers give you Web Crypto (Node's crypto needs the nodejs_compat flag), so use the async verifier with the SubtleCrypto provider. Two details that bite people:

  • Pass the raw body (await request.text()). Parse JSON first and the signature check fails.
  • You only need the webhook signing secret (whsec_...). The handler never calls Stripe's API, so it doesn't need your Stripe secret key.

Stripe's docs say to return a 2xx quickly, before any complex logic, so the handler replies 200 right away and renders inside ctx.waitUntil().

2. Map the Stripe invoice onto the template

Slipmint's invoice template takes JSON: company_name, invoice_number, issue_date and customer_name are required, plus optional branding (logo_url, brand_color, font, page_size), a status badge, an items array of {description, quantity, unit_price} and a tax_rate percentage. You can't pass totals in. The template computes subtotal, tax and total from the items.

So the mapping has to be careful:

  • Units. Stripe amounts are integers in the smallest currency unit (cents), except for zero-decimal currencies like JPY. Divide accordingly.
  • Tax. Pass Stripe's exclusive tax back as a percentage of the net amount. The template turns it back into the same tax line.
  • Discounts become a negative Discount line.
  • A totals check. Before rendering, the code adds the lines up the way the template will and compares the result with Stripe's total. If they disagree, it throws. A wrong invoice is worse than a missing one. The tax math reads total_taxes, which only recent Stripe API versions send; on older versions a taxed invoice fails the totals check instead of rendering with the wrong tax.

It also refuses invoices whose lines.has_more is true, because then the event doesn't carry every line.

3. Render once, store it yourself

POST /v1/pdf with Authorization: Bearer <key> returns 200 application/pdf. Validation errors come back as a 422 that lists every problem, and failed renders aren't counted against your quota. The X-Slipmint-Usage header tells you where you are in the month.

Stripe can send the same event more than once, so the handler checks R2 for invoices/<invoice id>.pdf before rendering. Two deliveries landing at exactly the same moment could still both render. That's fine for a recipe, but use a queue or a lock if it matters to you.

One caveat about waitUntil: Stripe only retries deliveries that don't get a 2xx, so once it has the 200 it won't resend the event, even if the render fails afterward. Cloudflare also limits how long waitUntil work can keep running after the response (its docs say 30 seconds). Failures are logged with the invoice ID so you can re-run saveInvoicePdf for that invoice.

The handler

Secrets go in with wrangler secret put STRIPE_WEBHOOK_SECRET and wrangler secret put SLIPMINT_API_KEY, and you need an R2 bucket binding called INVOICES. The only dependency is stripe.

// Stripe `invoice.paid` webhook -> Slipmint branded invoice PDF -> your own R2 bucket.
// Secrets (wrangler secret put): STRIPE_WEBHOOK_SECRET (whsec_...), SLIPMINT_API_KEY.
// Optional vars: COMPANY_NAME, BRAND_COLOR (#rrggbb), LOGO_URL (https://...). R2 binding: INVOICES.
import Stripe from "stripe";

const SLIPMINT_URL = "https://slipmint-api.mike-tusa.workers.dev/v1/pdf";
const cryptoProvider = Stripe.createSubtleCryptoProvider(); // Workers have Web Crypto, not node:crypto
// Stripe amounts are integers in the smallest unit; these currencies have no minor unit.
const ZERO_DECIMAL = new Set(["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf"]);

export default {
  async fetch(request, env, ctx) {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    let event;
    try {
      event = await Stripe.webhooks.constructEventAsync(
        await request.text(), // the raw body: don't parse it before verifying
        request.headers.get("stripe-signature"),
        env.STRIPE_WEBHOOK_SECRET,
        undefined, // default timestamp tolerance
        cryptoProvider,
      );
    } catch {
      return new Response("Invalid signature", { status: 400 });
    }
    if (event.type === "invoice.paid") {
      ctx.waitUntil(saveInvoicePdf(event.data.object, env).catch((e) => console.error(`invoice ${event.data.object.id}: ${e.message}`)));
    }
    return Response.json({ received: true }); // acknowledge fast, render after the response
  },
};

export async function saveInvoicePdf(invoice, env) {
  const key = `invoices/${invoice.id}.pdf`;
  if (await env.INVOICES.head(key)) return; // Stripe can deliver an event twice
  const res = await fetch(SLIPMINT_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${env.SLIPMINT_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(toSlipmintInvoice(invoice, env)),
  });
  if (!res.ok) throw new Error(`Slipmint ${res.status}: ${await res.text()}`); // a 422 lists every bad field
  await env.INVOICES.put(key, await res.arrayBuffer(), { httpMetadata: { contentType: "application/pdf" } });
  console.log(`stored ${key} (Slipmint usage ${res.headers.get("X-Slipmint-Usage")})`);
}

export function toSlipmintInvoice(inv, env = {}) {
  if (inv.lines.has_more) throw new Error("invoice has more lines than the event carries; list them via the API first");
  const unit = ZERO_DECIMAL.has(inv.currency) ? 1 : 100;
  const day = (s) => (s ? new Date(s * 1000).toLocaleDateString("en-US", { dateStyle: "medium", timeZone: "UTC" }) : "");
  const items = inv.lines.data.map((l) => {
    const q = l.quantity || 1, desc = l.description || "Item";
    return l.amount % q === 0
      ? { description: desc, quantity: q, unit_price: l.amount / q / unit }
      : { description: `${desc} (x${q})`, quantity: 1, unit_price: l.amount / unit };
  });
  const discount = (inv.total_discount_amounts ?? []).reduce((s, d) => s + d.amount, 0);
  if (discount) items.push({ description: "Discount", quantity: 1, unit_price: -discount / unit });
  const tax = (inv.total_taxes ?? []).filter((t) => t.tax_behavior === "exclusive").reduce((s, t) => s + t.amount, 0);
  const net = items.reduce((s, i) => s + Math.round(i.quantity * i.unit_price * unit), 0);
  // The template computes subtotal, tax and total itself, so refuse to render a PDF that disagrees with Stripe.
  if (net + tax !== inv.total) throw new Error(`computed ${net + tax} but Stripe total is ${inv.total}`);
  const a = inv.customer_address;
  return {
    template_id: "invoice",
    filename: `${inv.number ?? inv.id}.pdf`.replace(/[^\w.-]/g, "_").slice(-80),
    data: {
      company_name: env.COMPANY_NAME || inv.account_name || "Your company",
      brand_color: env.BRAND_COLOR || undefined,
      logo_url: env.LOGO_URL || undefined,
      invoice_number: inv.number ?? inv.id,
      issue_date: day(inv.effective_at ?? inv.created),
      due_date: day(inv.due_date),
      status: "PAID",
      customer_name: inv.customer_name || inv.customer_email || "Customer",
      customer_address: a ? [a.line1, a.line2, [a.city, a.state, a.postal_code].filter(Boolean).join(" "), a.country].filter(Boolean).join("\n") : "",
      customer_email: inv.customer_email ?? "",
      reference: inv.id,
      items,
      currency: inv.currency.toUpperCase(),
      tax_rate: tax ? (tax / net) * 100 : undefined, // a percentage; the template turns it back into the tax line
      payment_details: `Paid in full on ${day(inv.status_transitions?.paid_at)}.`,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Test it without touching Stripe

I built the fixture from the documented shape of Stripe's invoice object, with obviously fake data (in_FAKE..., "Test Customer (fake)", example.com). I signed it with the SDK's own test helper (Stripe.webhooks.generateTestHeaderStringAsync) and a made-up whsec_ secret, then sent it through the Worker's fetch handler in Node 20 with a small in-memory stand-in for R2. A bad signature and a tampered body both got a 400. The valid event got a 200 and produced a one-page A4 PDF. A duplicate delivery didn't trigger a second render.

To check the Slipmint call by itself, save the mapped JSON and use curl (keep the key in an env var, never in a file you commit):

curl -sS -X POST "https://slipmint-api.mike-tusa.workers.dev/v1/pdf" \
  -H "Authorization: Bearer $SLIPMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d @slipmint-request.json \
  -o FAKE-0001.pdf -w "%{http_code}\n"
Enter fullscreen mode Exit fullscreen mode

where slipmint-request.json is what toSlipmintInvoice() produced for the fixture:

{
  "template_id": "invoice",
  "filename": "FAKE-0001.pdf",
  "data": {
    "company_name": "Example Widgets Co", "brand_color": "#0f766e",
    "invoice_number": "FAKE-0001", "issue_date": "Sep 21, 2026", "due_date": "", "status": "PAID",
    "customer_name": "Test Customer (fake)",
    "customer_address": "123 Example Street\nSuite 0\nSpringfield ZZ 00000\nUS",
    "customer_email": "test.customer@example.com",
    "reference": "in_FAKE000000000000000001",
    "items": [
      { "description": "Pro plan (monthly), sample data", "quantity": 1, "unit_price": 49 },
      { "description": "Extra seats, sample data", "quantity": 3, "unit_price": 5 }
    ],
    "currency": "USD", "tax_rate": 8.25,
    "payment_details": "Paid in full on Sep 21, 2026."
  }
}
Enter fullscreen mode Exit fullscreen mode

The PDF shows Subtotal $64.00, Tax $5.28 and a total of $69.28, matching the fixture's total of 6928. While you're iterating on the mapping, add ?preview=html to the URL. You get the filled HTML back, and it isn't counted.

Using Checkout instead?

On a checkout.session.completed event, the session's invoice field is only an ID, and it's only set if an invoice was created. The simpler route is to create the session with invoice_creation[enabled]=true. Stripe then generates a paid invoice that you can pick up from invoice.paid, so the same handler covers both cases. Stripe prices Checkout invoice creation separately.

What Slipmint keeps (and doesn't)

This is the part I care about most, because these PDFs are full of customers' names and addresses. Straight from the privacy policy:

  • The JSON you send is processed transiently. The PDF is streamed back in the response with Cache-Control: no-store, and nothing is written to a database, object storage or a persistent disk.
  • The renderer (Gotenberg/Chromium on Google Cloud Run, us-central1, USA) writes per-request working files to Cloud Run's in-memory filesystem, and they're removed when the request finishes.
  • Request bodies, rendered documents and API keys aren't logged. Slipmint stores a SHA-256 hash of your key (plus metadata such as plan and label) and a document count per month. Cloudflare and Google Cloud record standard request metadata (time, IP address, user agent, path, status code, latency).

In this setup, the only lasting copy of the PDF is the one in your bucket. Its access rules and retention are yours to set.

What it doesn't do

  • No webhook-on-ready or async rendering. Sending webhook_url returns 501, because it's planned, not built. That's why the Worker waits for the PDF in the response.
  • No storage or hosted links. Slipmint won't keep the file for you (that's the point).
  • Fixed labels. The totals row reads "Total due" even with a PAID badge, and saved brand profiles don't exist yet, so branding is sent with every request.
  • Inclusive tax isn't broken out on its own line. It stays inside the line amounts, as Stripe reports it.

Want to try it?

Join the Slipmint waitlist on the home page. Free keys go out by invite to waitlist members, in batches. Each invite includes a personal, single-use link to create a free key (50 documents per month). The link expires after 7 days, and the key is shown once. Payments aren't live yet. Questions: digitalpromohub.support+slipmint@gmail.com.

Top comments (0)