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
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
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
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
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
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": 23842 },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.553 }54 ],55 "taxSummary": [56 {57 "taxRate": 19,58 "netAmount": 250,59 "taxAmount": 47.560 }61 ],62 "subtotal": 250,63 "total": 297.5,64 "paymentTerms": {65 "dueDays": 0,66 "description": "Bereits bezahlt"67 }68 }69}'Die Edge Function
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 help126 }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 repeated130 // 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.
Weitere Rezepte
- Rechnung an eine Behörde (XRechnung mit Leitweg-ID)
- ZUGFeRD-PDF erzeugen und versenden
- Bestehende Rechnung prüfen, bevor sie rausgeht
- Gutschrift und Storno korrekt abbilden
- Eingehende E-Rechnung auslesen
- XRechnung in ZUGFeRD umwandeln
- Leistungsnachweis an die Rechnung hängen
- Fehlerbehandlung und Retry-Strategie in der Produktion