Rechnungen im Headless-Shop: Webhooks rein, Rechnungsnummern zurück
cmssy überlässt die Rechnungsstellung deinem eigenen System. Hier ist der Webhook-Kreislauf, der bei Zahlung die Rechnung ausstellt, die Nummer an der Bestellung erfasst und bei Erstattung eine Korrekturrechnung ergänzt - sicher, auch wenn eine Zustellung wiederholt wird.
Jeder Shop braucht Rechnungen, und fast jeder Shop hat schon ein System, das sie ausstellt: eine Buchhaltungssoftware, ein nationales E-Rechnungs-Portal, einen Anbieter, auf dem deine Steuerberatung besteht. cmssy versucht nicht, es zu ersetzen. Es macht drei kleinere Dinge: Es sagt deinem Rechnungssystem, wann es handeln soll, es speichert an der Bestellung, was dieses System ausgestellt hat, und es zeigt dem Käufer das Ergebnis.
Dieser Beitrag baut genau diesen Kreislauf: die Rechnung ausstellen, wenn eine Bestellung bezahlt ist, die Nummer zurückschreiben und eine Korrekturrechnung ausstellen, wenn Geld erstattet wird.

Das Modell: eine Rechnung, dann Korrekturen
Jede Bestellung hat eine Liste invoices, die älteste zuerst. Jeder Eintrag hat einen kind (invoice oder correction), eine number, optional eine url zum PDF, optional einen provider und einen Zeitstempel issuedAt. Die Regeln sind kurz:
- Eine Rechnung pro Bestellung. Dieselbe Nummer noch einmal zu erfassen ändert nichts. Eine andere Nummer wird abgelehnt, denn eine Bestellung, die plötzlich zwei Rechnungen hat, ist ein Buchhaltungsfehler, kein Update.
- Eine stornierte Bestellung kann nicht abgerechnet werden. Wer zuerst storniert, hat nichts mehr abzurechnen.
- Korrekturen brauchen eine Rechnung. Eine Korrekturrechnung (Gutschrift) ist auch bei stornierten und erstatteten Bestellungen erlaubt, ihre Nummer muss für die Bestellung neu sein, und eine Wiederholung ändert nichts.
- Höchstens 20 Einträge pro Bestellung, Korrekturen eingeschlossen.

Das Team kann beides über die Bestellseite im Admin erfassen, über die GraphQL-Mutationen order.recordInvoice und order.recordInvoiceCorrection oder mit den MCP-Tools record_order_invoice und record_order_invoice_correction. Alle brauchen die Berechtigung orders:manage.
Schritt 1: die richtigen Events abonnieren
Lege einen Webhook-Endpunkt für deinen Handler an und abonniere damit:
order.paid- die Bestellung ist vollständig bezahlt; Zeit, die Rechnung auszustellen.order.refundedundorder.partially_refunded- Geld ging zurück; Zeit für eine Korrekturrechnung.- optional
order.invoicedundorder.invoice_corrected- ausgelöst, nachdem eine Nummer erfasst wurde, nützlich, um das PDF per E-Mail zu verschicken oder ein Hauptbuch zu synchronisieren.
Um order.*-Events zu abonnieren, brauchst du orders:view zusätzlich zu webhooks:manage. Jeder Payload enthält die Bestellung mit ihren invoices, total, tax, currency, taxTreatment (mit dem Rechtshinweis, der auf eine Reverse-Charge- oder Ausfuhrrechnung gehört), den Käufer und seine Firma.
Schritt 2: der Handler
Hier eine Next.js-Route. issueInvoice und issueCorrection stehen für die API deines Anbieters; alles andere ist cmssy.
// app/api/invoicing/route.ts
import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";
import { issueInvoice, issueCorrection } from "@/lib/invoicing";
const RECORD = `mutation ($input: RecordOrderInvoiceInput!) {
order { recordInvoice(input: $input) { id } }
}`;
const CORRECT = `mutation ($input: RecordOrderInvoiceInput!) {
order { recordInvoiceCorrection(input: $input) { id } }
}`;
const CURRENT = `query ($id: ID!) {
order { get(id: $id) { invoices { kind number } } }
}`;
async function cmssy(query: string, variables: object) {
const res = await fetch("https://api.cmssy.io/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.CMSSY_API_TOKEN}`,
"x-workspace-id": process.env.CMSSY_WORKSPACE_ID!,
},
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data;
}
export async function POST(request: Request) {
const body = await request.text();
let event;
try {
event = await verifyCmssyWebhook({
body,
signatureHeader: request.headers.get("x-cmssy-signature"),
secret: process.env.CMSSY_WEBHOOK_SECRET!,
});
} catch (error) {
if (error instanceof CmssyWebhookError) {
return new Response(null, { status: 400 });
}
throw error;
}
const order = event.data.order;
const { order: { get: current } } = await cmssy(CURRENT, { id: order.id });
const invoice = current.invoices.find((i) => i.kind === "invoice");
if (event.event === "order.paid" && !invoice) {
const issued = await issueInvoice(order, { idempotencyKey: order.id });
await cmssy(RECORD, {
input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
});
}
if (event.event.endsWith("refunded") && invoice) {
const key = `${order.id}:${order.refundedAmount}`;
const issued = await issueCorrection(order, invoice, {
refundedTotal: order.refundedAmount,
idempotencyKey: key,
});
await cmssy(CORRECT, {
input: { orderId: order.id, number: issued.number, url: issued.pdfUrl, provider: "my-invoicing" },
});
}
return new Response(null, { status: 204 });
}Der Token ist ein Workspace-API-Token (cs_…), erstellt von jemandem, dessen Rolle orders:manage hat. Halte ihn serverseitig; er kann alles, was diese Person kann.
Schritt 3: doppelte Ausführung absichern
Webhooks werden mindestens einmal und nicht in Reihenfolge zugestellt. Eine Zustellung kann nach einem Timeout wiederholt werden, den dein Handler nicht bemerkt hat, und eine langsame Wiederholung kann nach einem neueren Event ankommen. Drei Dinge im Handler oben gibt es genau dafür:
- Lies die aktuelle Bestellung, nicht den Payload. Der Payload ist eine Momentaufnahme vom Zeitpunkt des Events. Zwei Zustellungen desselben
order.paidsagen beide „noch keine Rechnung“; erst ein frischer Lesezugriff verrät der zweiten, dass die erste schon fertig ist. - Gib deinem Anbieter einen Idempotenzschlüssel. Die Seite von cmssy ist sicher - dieselbe Nummer zweimal erfasst nichts doppelt -, aber dass der Anbieter keine zwei Rechnungen ausstellt, liegt auf deiner Seite. Nutze für die Rechnung die Bestell-ID als Schlüssel und für die Korrekturrechnung den erstatteten Betrag, wenn dein Anbieter Schlüssel unterstützt; wenn nicht, suche vor dem Ausstellen nach einem vorhandenen Dokument mit deiner eigenen Referenz.
- Behandle eine Ablehnung als Information. Antwortet
recordInvoice, dass die Bestellung bereits mit einer anderen Nummer abgerechnet ist, haben sich zwei Handler ein Rennen geliefert, und beim Anbieter liegt ein Duplikat. Storniere es dort; cmssy hat die erste behalten.
Gib erst dann 2xx zurück, wenn die Nummer erfasst ist. Ein fehlgeschlagener Versuch wird etwa 27 Stunden lang wiederholt, ein kurzer Ausfall bei deinem Anbieter heilt sich also von selbst.
Was der Käufer sieht
Der Shop liest dieselbe Liste an der Bestellung des Käufers, sodass eine Bestellseite die Rechnung und jede Korrekturrechnung verlinken kann, sobald sie erfasst sind - für angemeldete Kunden und für Gäste, die ihre Bestellung mit dem Token aus dem Checkout lesen. Es gibt nichts zu synchronisieren.
Löschanfragen
Wenn das Team eine abgeschlossene Bestellung anonymisiert, löscht cmssy die personenbezogenen Daten des Käufers und die Links in seinen Rechnungen und behält die Rechnungsnummern, damit die Bücher weiter aufgehen. Dein Rechnungssystem hat seine eigene Kopie; der Webhook order.anonymized ist das Signal, sie dort nach deinen Aufbewahrungsregeln zu behandeln.
Ohne Code
Nicht jeder Shop braucht einen Handler. Werden Rechnungen von Hand ausgestellt, erfasse die Nummer auf der Bestellseite oder bitte einen über MCP verbundenen Agenten: „erfasse Rechnung FV/2026/1042 an Bestellung 1042 mit diesem PDF-Link“. Die Regeln sind dieselben, und der ausgelöste Webhook auch.
Die Referenz steht in den Commerce-Docs, die Event-Liste und Details zu Signaturen in den Webhook-Docs, und der Webhook-Beitrag geht tiefer auf Wiederholungen und Verifizierung ein.
