Skip to content

Turn a Stripe payment into an e-invoice (Supabase Edge Function)

Your app charges through Stripe Checkout, and you need an e-invoice under EN 16931 for every payment.

Steps

  1. 1

    When creating the checkout session, pass the invoice number as metadata.invoice_number and collect the billing address (billing_address_collection: required). Your app assigns the number, not Stripe and not the API.

  2. 2

    Register the function as the webhook target for checkout.session.completed and set three secrets: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, INVOICE_API_KEY.

  3. 3

    The function checks the Stripe signature, fetches the line items from Stripe and calls the endpoint shown below. The response contains the PDF base64-encoded.

  4. 4

    Store the PDF and send it to the buyer. Use payloadHash from the response as the key, so an event delivered twice does not create a second file.

The call

POST /api/v1/invoice/DE/zugferd/generate

bash
1curl -X POST https://service.invoice-api.xhub.io/api/v1/invoice/DE/zugferd/generate \
2 -H "Authorization: Bearer $INVOICE_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "invoice": {
6 "invoiceNumber": "RE-2026-0042",
7 "type": "invoice",
8 "issueDate": "2026-11-11",
9 "dueDate": "2026-11-11",
10 "currency": "EUR",
11 "seller": {
12 "name": "Muster GmbH",
13 "vatId": "DE811128135",
14 "street": "Musterstraße 1",
15 "postalCode": "10115",
16 "city": "Berlin",
17 "countryCode": "DE",
18 "email": "info@muster.de",
19 "bankAccount": {
20 "iban": "DE89370400440532013000",
21 "bic": "COBADEFFXXX"
22 }
23 },
24 "buyer": {
25 "name": "Beispiel AG",
26 "street": "Beispielweg 42",
27 "postalCode": "80331",
28 "city": "München",
29 "countryCode": "DE"
30 },
31 "items": [
32 {
33 "position": 1,
34 "description": "Jahreslizenz Team",
35 "quantity": 2,
36 "unit": "C62",
37 "unitPrice": 100,
38 "taxRate": 19,
39 "netAmount": 200,
40 "taxAmount": 38,
41 "grossAmount": 238
42 },
43 {
44 "position": 2,
45 "description": "Einrichtung",
46 "quantity": 1,
47 "unit": "C62",
48 "unitPrice": 50,
49 "taxRate": 19,
50 "netAmount": 50,
51 "taxAmount": 9.5,
52 "grossAmount": 59.5
53 }
54 ],
55 "taxSummary": [
56 {
57 "taxRate": 19,
58 "netAmount": 250,
59 "taxAmount": 47.5
60 }
61 ],
62 "subtotal": 250,
63 "total": 297.5,
64 "paymentTerms": {
65 "dueDays": 0,
66 "description": "Bereits bezahlt"
67 }
68 }
69}'

The edge function

typescript
1// Supabase Edge Function: Stripe "checkout.session.completed" -> ZUGFeRD invoice.
2// Secrets: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, INVOICE_API_KEY.
3 
4const API = "https://service.invoice-api.xhub.io/api/v1/invoice/DE/zugferd/generate";
5 
6// Your company. These values never come from the payment.
7const SELLER = {
8 name: "Muster GmbH",
9 vatId: "DE811128135",
10 street: "Musterstraße 1",
11 postalCode: "10115",
12 city: "Berlin",
13 countryCode: "DE",
14 email: "info@muster.de",
15 bankAccount: { iban: "DE89370400440532013000", bic: "COBADEFFXXX" },
16};
17 
18const cents = (value: number) => Math.round(value) / 100;
19 
20// Stripe signs "<timestamp>.<raw body>" with HMAC-SHA256 (header "Stripe-Signature").
21async function isFromStripe(body: string, header: string | null, secret: string) {
22 const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=")));
23 if (!parts.t || !parts.v1) return false;
24 const key = await crypto.subtle.importKey(
25 "raw",
26 new TextEncoder().encode(secret),
27 { name: "HMAC", hash: "SHA-256" },
28 false,
29 ["sign"],
30 );
31 const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${parts.t}.${body}`));
32 const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
33 // Reject old events as well: five minutes is Stripe's own default tolerance.
34 return hex === parts.v1 && Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
35}
36 
37Deno.serve(async (req) => {
38 const body = await req.text();
39 const signed = await isFromStripe(
40 body,
41 req.headers.get("stripe-signature"),
42 Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
43 );
44 if (!signed) return new Response("bad signature", { status: 400 });
45 
46 const event = JSON.parse(body);
47 if (event.type !== "checkout.session.completed") return new Response("ignored");
48 const session = event.data.object;
49 
50 // The invoice number is yours: set it as metadata when you create the session.
51 const invoiceNumber = session.metadata?.invoice_number;
52 if (!invoiceNumber) return new Response("metadata.invoice_number missing", { status: 422 });
53 
54 // The event carries no line items; fetch them from Stripe.
55 const stripe = await fetch(
56 `https://api.stripe.com/v1/checkout/sessions/${session.id}/line_items?limit=100`,
57 { headers: { Authorization: `Bearer ${Deno.env.get("STRIPE_SECRET_KEY")}` } },
58 );
59 if (!stripe.ok) return new Response("stripe line items failed", { status: 502 });
60 const lines = (await stripe.json()).data;
61 
62 const items = lines.map((line: any, index: number) => {
63 const net = cents(line.amount_subtotal);
64 const tax = cents(line.amount_tax);
65 return {
66 position: index + 1,
67 description: line.description,
68 quantity: line.quantity,
69 unit: "C62", // "piece" (UN/ECE Rec. 20)
70 unitPrice: Math.round((net / line.quantity) * 100) / 100,
71 // Derived from what Stripe charged. With several rates on one line, use Stripe Tax.
72 taxRate: Math.round((tax / net) * 100),
73 netAmount: net,
74 taxAmount: tax,
75 grossAmount: cents(line.amount_total),
76 };
77 });
78 
79 const byRate = new Map<number, { taxRate: number; netAmount: number; taxAmount: number }>();
80 for (const item of items) {
81 const sum = byRate.get(item.taxRate) ?? { taxRate: item.taxRate, netAmount: 0, taxAmount: 0 };
82 sum.netAmount = Math.round((sum.netAmount + item.netAmount) * 100) / 100;
83 sum.taxAmount = Math.round((sum.taxAmount + item.taxAmount) * 100) / 100;
84 byRate.set(item.taxRate, sum);
85 }
86 
87 const paidOn = new Date(session.created * 1000).toISOString().slice(0, 10);
88 const address = session.customer_details.address;
89 const invoice = {
90 invoiceNumber,
91 type: "invoice",
92 issueDate: paidOn,
93 dueDate: paidOn,
94 currency: session.currency.toUpperCase(),
95 seller: SELLER,
96 buyer: {
97 name: session.customer_details.name,
98 street: [address.line1, address.line2].filter(Boolean).join(", "),
99 postalCode: address.postal_code,
100 city: address.city,
101 countryCode: address.country,
102 },
103 items,
104 taxSummary: [...byRate.values()],
105 subtotal: cents(session.amount_subtotal),
106 total: cents(session.amount_total),
107 paymentTerms: { dueDays: 0, description: "Bereits bezahlt" },
108 };
109 
110 const res = await fetch(API, {
111 method: "POST",
112 headers: {
113 Authorization: `Bearer ${Deno.env.get("INVOICE_API_KEY")}`,
114 "Content-Type": "application/json",
115 },
116 body: JSON.stringify({ invoice }),
117 });
118 // 5xx is worth a retry: answering 500 makes Stripe send the event again.
119 if (res.status >= 500) return new Response("try again", { status: 500 });
120 const result = await res.json();
121 // 429 without a quota object is the rate limit: retry. With one, the monthly quota is used up.
122 if (res.status === 429 && !result.quota) return new Response("try again", { status: 500 });
123 if (!res.ok) {
124 console.error(invoiceNumber, res.headers.get("x-request-id"), JSON.stringify(result));
125 return new Response("invoice rejected", { status: 200 }); // fix the data; a retry will not help
126 }
127 
128 // result.data is the PDF, base64-encoded; result.filename its name.
129 // Store it where you keep documents, keyed by result.payloadHash, so a repeated
130 // delivery of the same event does not create a second file.
131 const pdf = Uint8Array.from(atob(result.data), (c) => c.charCodeAt(0));
132 console.log(invoiceNumber, result.filename, pdf.length, result.payloadHash);
133 return new Response("ok");
134});

Checked on 11 November 2026: the function ran unchanged with a signed test event; the call to invoice-api.xhub went to the real API and returned a ZUGFeRD PDF. Stripe's response came from a test file, and it ran under Node with a Deno stand-in, not on Supabase itself.

Where this goes wrong

Three places: the Stripe event carries no line items, they have to be fetched from Stripe. Without the buyer's full billing address, mandatory details are missing. And Stripe may deliver an event more than once — without a key such as payloadHash you get duplicate files.