Stripe & Chargebee

E-Rechnung aus Stripe oder Chargebee erzeugen: ZUGFeRD für deutsche B2B-Kunden

Kurze Antwort: Die PDF-Rechnung aus Stripe Billing oder Chargebee ist keine E-Rechnung im Sinne von § 14 UStG. Für deutsche Geschäftskunden brauchen Sie spätestens für Leistungen ab 2028 (bei mehr als 800.000 Euro Vorjahresumsatz schon ab 2027) eine strukturierte Rechnung nach EN 16931. Stripe und Chargebee lösen das über Partner-Apps bzw. Peppol. Wenn Sie lieber selbst steuern, hören Sie auf das Rechnungs-Event, übersetzen die Rechnung in JSON und lassen fakturai daraus eine geprüfte ZUGFeRD/Factur-X-PDF erzeugen. Unten finden Sie die Feldzuordnung, einen Webhook-Handler in TypeScript und einen Aufruf, den wir mit synthetischen Daten gegen die Produktions-API ausgeführt haben.

Warum das PDF aus dem Billing-Tool nicht mehr reicht

§ 14 Abs. 1 UStG unterscheidet seit 2025 zwischen der E-Rechnung (strukturiertes Format nach EN 16931, das eine elektronische Verarbeitung ermöglicht) und der sonstigen Rechnung (Papier oder ein anderes elektronisches Format, also auch PDF). Nach § 14 Abs. 2 UStG ist eine Rechnung an einen anderen inländischen Unternehmer als E-Rechnung auszustellen. Empfangen können müssen inländische Unternehmen E-Rechnungen bereits seit dem 1.1.2025.

Die Übergangsregeln in § 27 Abs. 38 UStG laufen aus:

Nach der BMF-FAQ erfüllen XRechnung und ZUGFeRD ab Version 2.0.1 (außer den Profilen MINIMUM und BASIC-WL) die Anforderungen. Bei hybriden Formaten wie ZUGFeRD ist der strukturierte XML-Teil maßgeblich, nicht das Bild der PDF. Mehr dazu: Warum ein PDF keine E-Rechnung ist, Übergangsfristen 2025 bis 2028 und Rechnung im Januar 2027 für eine Leistung aus 2026.

Was Stripe und Chargebee heute selbst anbieten

Stripe: Laut Stripe-Dokumentation erstellt Stripe Invoicing Rechnungsdatensätze und stellt sie über API und Webhooks bereit, erzeugt oder überträgt aber selbst keine rechtskonformen E-Rechnungsdateien. Stripe nennt zwei Wege: eine App aus dem Stripe App Marketplace (bevorzugter Partner ist Billit, es gibt weitere Anbieter) oder eine eigene Integration, die auf Rechnungs-Events hört und die Daten an einen Anbieter weitergibt.

Chargebee: Chargebee verfolgt einen partnerbasierten Ansatz und dokumentiert Integrationen mit Avalara ELR und Invopop. Zusätzlich gibt es ein eigenes E-Invoicing als kostenpflichtiges Add-on, das laut Doku derzeit nur bestehenden Kunden angeboten wird. Für Deutschland nutzt es das Format Peppol BIS V3 über das Peppol-Netz (B2B und B2G); Kunden ohne Peppol-Registrierung erhalten die E-Rechnung je nach Einstellung per E-Mail.

Beide Wege sind legitim. Eine eigene API-Integration passt, wenn Sie den Datenfluss im eigenen Backend behalten, Ihren Kunden eine lesbare PDF mit eingebettetem XML schicken und jede Datei vor dem Versand technisch prüfen lassen wollen. Genau dafür ist fakturai gebaut: Aus JSON entsteht eine ZUGFeRD/Factur-X-PDF (EN 16931, B2B), die vor der Rückgabe gegen den KoSIT-Validator und Mustang geprüft wird. fakturai versendet nicht über Peppol und erzeugt noch keine XRechnung für Behörden.

Der Ablauf in vier Schritten

  1. Event empfangen: Bei Stripe invoice.finalized (Rechnung ist final, Positionen und Steuern stehen fest), bei Chargebee invoice_generated. Stripe nennt in seiner E-Invoicing-Anleitung auch invoice.created, weil finalisierte Rechnungen nicht mehr bearbeitbar sind. Da fakturai synchron prüft und Fehler sofort als JSON zurückgibt, sehen Sie Probleme direkt; testen Sie die Zuordnung trotzdem zuerst im Testmodus.
  2. Felder zuordnen: Positionen, Steuersatz, Leistungszeitraum und Kundenadresse in den fakturai-Body übersetzen (Tabelle unten).
  3. E-Rechnung erzeugen: POST /api/v1/invoices mit Ihrem API-Key. Antwort bei Erfolg: HTTP 201 mit der PDF/A-3, die Rechnungsnummer steht im Header X-Facturai-Invoice-Number.
  4. Speichern und versenden: fakturai versendet nichts. Sie legen die PDF ab, schicken sie Ihrem Kunden und notieren die Zuordnung zur Stripe- bzw. Chargebee-Rechnung.

Feldzuordnung: Stripe Invoice zu fakturai

Die Feldnamen gelten für Stripe-API-Versionen ab 2025-03-31.basil, in denen pricing und taxes die früheren Felder price, tax_amounts und tax_rates der Rechnungspositionen ersetzt haben. Verkäuferdaten (Name, Adresse, USt-IdNr., Bankverbindung) kommen aus Ihrem fakturai-Profil, die Rechnungsnummer vergibt fakturai fortlaufend.

Stripe (Invoice)fakturai (POST /api/v1/invoices)Hinweis
lines.data[].descriptionlines[].namePflichtfeld, z. B. „Pro-Plan, 3 Nutzer (November 2026)“
lines.data[].quantity_decimallines[].quantityals Dezimalstring; quantity ist bei Stripe als veraltet markiert
lines.data[].pricing.unit_amount_decimallines[].unit_priceStripe liefert Cent: „4900“ wird zu „49.00“ (Nettopreis)
lines.data[].taxes[0].tax_rate_details.tax_ratelines[].vat_rateID des Steuersatzes; percentage aus dem TaxRate-Objekt, z. B. „19.00“ oder „7.00“
lines.data[].period.start / .endservice_period.start / .endUnix-Zeit in ISO-Datum (Berliner Zeit); Ende inklusiv, siehe Code
customer_namebuyer.nameFirmenname des Kunden
customer_address.line1, line2, postal_code, city, countrybuyer.address.line_one, postcode, city, countryline1 und line2 zusammengefasst
customer_tax_ids[] mit type eu_vatbuyer.vat_idUSt-IdNr. des Kunden (BT-48), optional
customer_emailbuyer.contact_emailelektronische Adresse des Käufers (BT-49), optional
idHeader Idempotency-Keyverhindert eine zweite E-Rechnung, wenn Stripe ein Event erneut zustellt
effective_at (sonst status_transitions.finalized_at)invoice_dateRechnungsdatum
due_datedue_datebei automatischem Einzug oft leer; dann Rechnungsdatum
currencycurrency„eur“ wird zu „EUR“

Die USt-IdNr. des Kunden (customer_tax_ids mit Typ eu_vat) übernehmen Sie als buyer.vat_id, die E-Mail-Adresse als buyer.contact_email. fakturai schreibt beide ins XML (BT-48 und BT-49), die USt-IdNr. erscheint auch auf der PDF. Für Rechnungen im Inland ist die USt-IdNr. des Kunden keine Pflichtangabe. Verlangt Ihr Kunde eine Bestellnummer oder Käuferreferenz, nutzen Sie buyer.buyer_reference.

Chargebee: die entsprechenden Felder

Bei Chargebee steckt die Rechnung im Event invoice_generated unter content.invoice. Beträge sind ebenfalls in Cent, Zeitstempel in Unix-Sekunden. Prüfen Sie price_type: Das Beispiel setzt tax_exclusive (Nettopreise) voraus.

Chargebee (Invoice)fakturai
line_items[].descriptionlines[].name
line_items[].quantitylines[].quantity (als String)
line_items[].unit_amount (Cent)lines[].unit_price (Dezimalstring)
line_items[].tax_ratelines[].vat_rate
line_items[].date_from / date_toservice_period.start / end
date / due_dateinvoice_date / due_date
billing_address.company, line1, zip, city, countrybuyer.name, buyer.address

Webhook-Handler in TypeScript

Das Beispiel ist ein Next.js-Route-Handler und nutzt nur das offizielle stripe-Paket und fetch. Wir haben es mit stripe-node 22 (API-Version 2026-03-25.dahlia) im Strict-Modus typgeprüft und die Funktion toFakturaiInvoice mit einer synthetischen Stripe-Rechnung ausgeführt; das Ergebnis ist der JSON-Body weiter unten.

// app/api/stripe-webhook/route.ts (Next.js App Router, Node.js Runtime)
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Kalenderdatum in Berlin (YYYY-MM-DD) aus einem Unix-Zeitstempel
const berlinDate = (unix: number) =>
  new Date(unix * 1000).toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" });

// Cent-Dezimalstring in Euro-Dezimalstring, ohne Gleitkomma: "4900" -> "49.00"
const centsToDecimal = (cents: string) => {
  const [int, frac = ""] = cents.split(".");
  const padded = int.padStart(3, "0");
  return `${padded.slice(0, -2)}.${padded.slice(-2)}${frac}`;
};

async function vatRate(client: Stripe, line: Stripe.InvoiceLineItem): Promise<string> {
  const taxes = line.taxes ?? [];
  const tax = taxes[0];
  if (taxes.length !== 1 || !tax.tax_rate_details) throw new Error(`${line.id}: genau ein Steuersatz erwartet`);
  if (tax.tax_behavior === "inclusive") throw new Error(`${line.id}: Bruttopreise deckt dieses Beispiel nicht ab`);
  const rate = await client.taxRates.retrieve(tax.tax_rate_details.tax_rate); // ohne expand: ID des Steuersatzes
  return rate.percentage.toFixed(2); // 19 -> "19.00"
}

export async function toFakturaiInvoice(client: Stripe, invoice: Stripe.Invoice) {
  const lines = [];
  let start = Infinity;
  let end = 0;
  for await (const line of client.invoices.listLineItems(invoice.id!)) {
    if (line.discount_amounts?.some((d) => d.amount > 0)) throw new Error(`${line.id}: Rabatte als allowances abbilden`);
    const unit = line.pricing?.unit_amount_decimal;
    if (unit == null) throw new Error(`${line.id}: kein Stückpreis`);
    lines.push({
      name: line.description ?? "Leistung",
      quantity: String(line.quantity_decimal ?? line.quantity ?? 1),
      unit: "C62",
      unit_price: centsToDecimal(String(unit)),
      vat_rate: await vatRate(client, line),
    });
    start = Math.min(start, line.period.start);
    end = Math.max(end, line.period.end);
  }
  const address = invoice.customer_address;
  if (!invoice.customer_name || !address?.line1 || !address.postal_code || !address.city) {
    throw new Error(`${invoice.id}: Firmenname oder Rechnungsadresse fehlt`);
  }
  const issued = invoice.effective_at ?? invoice.status_transitions.finalized_at ?? invoice.created;
  return {
    invoice_date: berlinDate(issued),
    due_date: berlinDate(invoice.due_date ?? issued),
    // Fakturai erwartet ein inklusives Enddatum: ein Tag vor Beginn der Folgeperiode
    service_period: { start: berlinDate(start), end: berlinDate(Math.max(start, end - 86400)) },
    currency: invoice.currency.toUpperCase(),
    buyer: {
      name: invoice.customer_name,
      address: {
        line_one: [address.line1, address.line2].filter(Boolean).join(", "),
        postcode: address.postal_code,
        city: address.city,
        country: address.country ?? "DE",
      },
      // optional: USt-IdNr. (BT-48) und E-Mail als elektronische Adresse (BT-49)
      vat_id: invoice.customer_tax_ids?.find((t) => t.type === "eu_vat")?.value,
      contact_email: invoice.customer_email ?? undefined,
    },
    lines,
  };
}

export async function POST(req: Request) {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      await req.text(),
      req.headers.get("stripe-signature") ?? "",
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("invalid signature", { status: 400 });
  }
  if (event.type !== "invoice.finalized") return new Response(null, { status: 200 });

  const invoice = event.data.object;
  // Nur inländische B2B-Kunden; alles andere läuft weiter über den bisherigen Weg
  if (invoice.customer_address?.country !== "DE") {
    return new Response(null, { status: 200 });
  }

  const resp = await fetch("https://fakturai.de/api/v1/invoices", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.FAKTURAI_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/pdf",
      // gleiche Stripe-Rechnung, keine zweite E-Rechnung: Wiederholung liefert 409
      "Idempotency-Key": invoice.id!,
    },
    body: JSON.stringify(await toFakturaiInvoice(stripe, invoice)),
  });
  if (resp.status === 409) return new Response(null, { status: 200 }); // bereits erzeugt
  if (resp.status !== 201) {
    console.error("Fakturai", resp.status, await resp.text()); // JSON-Fehler, keine Rechnung
    // 4xx: Daten korrigieren statt wiederholen; 5xx/429: Stripe stellt das Event erneut zu
    return new Response(null, { status: resp.status >= 500 || resp.status === 429 ? 503 : 200 });
  }

  const pdf = Buffer.from(await resp.arrayBuffer()); // ZUGFeRD/Factur-X-PDF mit CII-XML
  const number = resp.headers.get("x-facturai-invoice-number");
  // pdf in Ihrem Speicher ablegen (S3, Datenbank) und number zur Stripe-ID invoice.id notieren
  console.log("E-Rechnung", number, "für", invoice.id, pdf.length, "Bytes");
  return new Response(null, { status: 200 });
}

Was der Code bewusst einfach hält:

Der erzeugte Body und der Aufruf mit curl

So sieht der Body aus, den toFakturaiInvoice aus einer synthetischen Stripe-Rechnung macht: drei Nutzer im Pro-Plan zu 49 Euro netto plus ein API-Zusatzpaket, Leistungszeitraum November 2026. Als invoice.json gespeichert, können Sie denselben Aufruf ohne Stripe testen:

{
  "invoice_date": "2026-11-01",
  "due_date": "2026-11-15",
  "service_period": {
    "start": "2026-11-01",
    "end": "2026-11-30"
  },
  "currency": "EUR",
  "buyer": {
    "name": "Beispiel Kunde GmbH",
    "address": {
      "line_one": "Musterstraße 12",
      "postcode": "10115",
      "city": "Berlin",
      "country": "DE"
    },
    "vat_id": "DE999999999",
    "contact_email": "rechnung@example.com"
  },
  "lines": [
    {
      "name": "Pro-Plan, 3 Nutzer (November 2026)",
      "quantity": "3",
      "unit": "C62",
      "unit_price": "49.00",
      "vat_rate": "19.00"
    },
    {
      "name": "API-Zusatzpaket 10.000 Aufrufe (November 2026)",
      "quantity": "1",
      "unit": "C62",
      "unit_price": "19.90",
      "vat_rate": "19.00"
    }
  ]
}
curl -X POST https://fakturai.de/api/v1/invoices \
  -H "Authorization: Bearer $FAKTURAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/pdf" \
  -H "Idempotency-Key: in_1Example" \
  --data @invoice.json \
  --dump-header headers.txt \
  --output invoice.pdf

Geprüft am 04.11.2026: Genau diesen Body haben wir mit einem neuen Testkonto und synthetischen Verkäuferdaten an https://fakturai.de/api/v1/invoices geschickt. Antwort: HTTP 201, application/pdf, eine PDF mit eingebettetem factur-x.xml (Profil EN 16931). Im XML stehen der Leistungszeitraum 01.11.2026 bis 30.11.2026, netto 166,90 Euro, 31,71 Euro Umsatzsteuer und 198,61 Euro Gesamtbetrag; der PDF-Kopf zeigt denselben Leistungszeitraum. Die Datei haben wir anschließend im kostenlosen Validator geprüft: KoSIT-Ergebnis „accept“, PDF/A konform, 0 harte Fehler und 6 Hinweise. Die Hinweise stammen aus Peppol- und XRechnung-Regeln (etwa BR-DE-15 zur Käuferreferenz, die bei Rechnungen an Behörden Pflicht ist) und wurden vom Validator nicht als Fehler gewertet.

Fehlerantworten sind JSON und keine Rechnung, zum Beispiel HTTP 422 mit dem Feld, das nicht passt. Alle Felder stehen in der API-Dokumentation.

Für Agenten: dieselbe Logik per MCP

Wenn ein KI-Agent Rechnungen vorbereitet, kann er den MCP-Server unter https://fakturai.de/mcp nutzen, mit demselben API-Key als Bearer-Token. Mehr dazu im Beitrag E-Rechnung API für Agenten, Backends und MCP-Clients.

So starten Sie

Legen Sie unter /de/signup kostenlos ein Konto an, hinterlegen Sie Ihr Verkäuferprofil und erzeugen Sie einen API-Key. Der kostenlose Plan enthält 5 erzeugte Rechnungen pro Monat; bezahlte Pläne sind noch nicht buchbar. Bereits erzeugte Dateien, auch aus anderen Tools, prüfen Sie ohne Konto im Validator.

Kostenlos starten

Was fakturai nicht ersetzt

Diese Seite ist eine technische Anleitung mit Einordnung der Rechtslage und keine Rechts- oder Steuerberatung. fakturai ist keine Buchhaltungssoftware. Es erzeugt und prüft die technische Struktur nach EN 16931; Inhalt, steuerliche Beurteilung, Versand und Aufbewahrung Ihrer Rechnungen bleiben in Ihrer Verantwortung bzw. der Ihrer Steuerberatung. Es gibt keine Rechts-, Steuer- oder Zustellungsgarantie. Angaben zu Stripe und Chargebee geben deren Dokumentation zum Abrufdatum wieder.

Quellen

Alle Quellen abgerufen am 04.11.2026.

Weiterlesen