Zum Inhalt springen

Aus einer Stripe-Zahlung eine E-Rechnung machen (Supabase Edge Function)

Ihre App kassiert über Stripe Checkout, und Sie brauchen zu jeder Zahlung eine E-Rechnung nach EN 16931.

Vorgehen

  1. 1

    Beim Anlegen der Checkout-Session die Rechnungsnummer als metadata.invoice_number mitgeben und die Rechnungsadresse abfragen (billing_address_collection: required). Die Nummer vergibt Ihre App, nicht Stripe und nicht die API.

  2. 2

    Die Function als Webhook-Ziel für checkout.session.completed eintragen und drei Secrets setzen: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, INVOICE_API_KEY.

  3. 3

    Die Function prüft die Stripe-Signatur, holt die Positionen bei Stripe und ruft den unten gezeigten Endpoint auf. Die Antwort enthält das PDF Base64-kodiert.

  4. 4

    PDF speichern und an den Käufer senden. payloadHash aus der Antwort als Schlüssel nehmen, damit ein wiederholt zugestelltes Ereignis keine zweite Datei erzeugt.

Der Aufruf

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}'

Die 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});

Geprüft am 11.11.2026: Die Function lief unverändert mit einem signierten Testereignis; der Aufruf an invoice-api.xhub ging an die echte API und lieferte eine ZUGFeRD-PDF. Die Antwort von Stripe kam dabei aus einer Testdatei, und ausgeführt wurde unter Node mit einem Deno-Ersatz, nicht auf Supabase selbst.

Wo es schiefgeht

Drei Stellen: Das Stripe-Ereignis enthält keine Positionen, sie müssen bei Stripe nachgeladen werden. Ohne vollständige Rechnungsadresse des Käufers fehlen Pflichtangaben. Und Stripe kann ein Ereignis mehrfach zustellen — ohne Schlüssel wie payloadHash entstehen doppelte Dateien.