Zurück zur Anleitung

Rechnungen per API: vom Kunden zur versandten Rechnung

Der ganze Fakturierungsweg in REST-Aufrufen: Kunde, Vorlage, Rechnung, Finalisierung, Versand, PDF, dann Offerten und wiederkehrende Rechnungen.

Entwickler12 Min. Lesezeit
Inhaltsverzeichnis+

Diese Anleitung macht in curl nach, was der Fakturierungsbildschirm mit der Maus tut. Jeder Aufruf nutzt denselben Schlüssel und dieselbe Basis-URL wie Erste Schritte mit der API.

BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...

1. Voraussetzungen

Nehmen Sie aus GET /me die id einer Firma, bei der modules.invoicing aktiv ist, und halten Sie sie bereit:

COMPANY=<companyId>

2. Den Kunden anlegen

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Muster AG", "email": "billing@muster.example",
        "address": { "kind": "swiss", "country": "CH", "street": "Rue du Lac",
                     "streetNumber": "12", "postalCode": "1700", "city": "Fribourg" },
        "details": { "kind": "company", "contactPersons": [] } }' \
  $BASE/companies/$COMPANY/clients

Die Antwort 201 enthält den Kunden mit seiner id. name, address und details sind Pflicht: details.kind ist company (mit contactPersons, einer Liste, die leer sein darf, und optional ideNumber und vatNumber) oder individual. Ein Kunde gehört zu einer Firma: Den Kunden einer anderen Firma auf einer Rechnung zu verwenden, antwortet 403 ACCESS_DENIED. In Business sind Kunden unbegrenzt; 403 CLIENT_LIMIT_REACHED erscheint nur, wenn Ihr Abonnement das Modul Rechnungsstellung nicht abdeckt.

Kunden im Stapel importieren

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "rows": [
        { "name": "Muster AG", "email": "billing@muster.example",
          "address": { "kind": "swiss", "country": "CH", "city": "Fribourg" },
          "details": { "kind": "company", "contactPersons": [] } },
        { "name": "Anna Beispiel",
          "address": { "kind": "foreign", "country": "FR", "city": "Annemasse" },
          "details": { "kind": "individual" } }
      ] }' \
  $BASE/companies/$COMPANY/clients/batch

Der Stapel nimmt 1 bis 200 Kunden entgegen und ist atomar: Es wird alles geschrieben oder nichts, und ein Stapel, der das Kundenkontingent überschreiten würde, wird vollständig abgelehnt (403 CLIENT_LIMIT_REACHED). Eine Zeile, deren E-Mail-Adresse in der Firma bereits existiert oder weiter oben im Stapel vorkommt, wird ohne Fehler ignoriert; eine Zeile ohne E-Mail-Adresse gilt nie als Duplikat. Die Antwort 201 liefert die beiden Zähler inserted und skippedDuplicates. Das Einlesen der Datei (CSV, Tabellenkalkulation) bleibt auf Ihrer Seite.

3. Eine Vorlage wählen

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/invoice-templates

Die Vorlagenliste ist nicht paginiert und nimmt keinen Parameter. Die Vorlage mit isDefault: true ist die, auf die eine Rechnung zurückfällt, wenn Sie nichts angeben: templateId ist beim Erstellen optional. Im JSON ist hasLogo schreibgeschützt: Das Logo läuft über eine eigene Route, als rohe Bytes (PNG oder JPEG, höchstens 2 MB).

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: image/png" \
  --data-binary @logo.png $BASE/companies/$COMPANY/invoice-templates/<templateId>/logo

PUT /companies/{companyId}/logo macht dasselbe für das Logo der Firma (Rolle Inhaber oder Admin erforderlich); das Logo einer Vorlage ersetzt es auf den Dokumenten dieser Vorlage. DELETE auf diesen beiden Routen entfernt das Logo.

4. Die Rechnung erstellen

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "clientId": "<clientId>", "lang": "fr", "currency": "CHF",
        "issueDate": "2026-03-01", "dueDate": "2026-03-31",
        "items": [{ "description": "Design", "quantity": 2, "unitPrice": 150, "vatRate": 8.1 }] }' \
  $BASE/companies/$COMPANY/invoices

clientId, lang, issueDate, dueDate und currency sind Pflicht; templateId, discount, shippingCosts, notes, paymentTerms, showQrBill und items sind optional. Die Nummer kommt vom Zähler der Firma, und die Rechnung startet als Entwurf (draft).

In Business sind Rechnungen unbegrenzt, und andere Währungen als CHF sind freigeschaltet. 403 INVOICE_LIMIT_REACHED und 403 CURRENCY_PRO_ONLY erscheinen nur, wenn Ihr Abonnement das Modul Rechnungsstellung nicht abdeckt: Dann gelten die Limiten des Plans Starter.

Den Produktkatalog wiederverwenden

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Design-Beratung", "unitPrice": 150, "currency": "CHF", "vatRate": 8.1,
        "unit": "h", "reference": "DES-01" }' \
  $BASE/companies/$COMPANY/products

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Startpaket", "items": [{ "productId": "<productId>", "quantity": 2 }] }' \
  $BASE/companies/$COMPANY/product-lists

Ein Produkt verlangt name, unitPrice, currency und vatRate; description, unit und reference sind optional. Eine Liste behält ihre Produkte in der Reihenfolge des Arrays (höchstens 500, jedes nur einmal, sonst 422 PRODUCT_LIST_DUPLICATE_PRODUCT). Beide Ressourcen lassen sich paginiert auflisten, einzeln lesen, mit PATCH ändern (items ersetzt die ganze Liste) und löschen; ein gelöschtes Produkt verschwindet aus den Listen, die es enthielten.

Die API löst eine Liste nicht in Rechnungspositionen auf: Senden Sie auf jeder Position immer description, quantity, unitPrice und vatRate und fügen Sie productId hinzu, um die Verknüpfung mit dem Produkt zu behalten. Ist der Katalog in der Firma deaktiviert, antworten diese Routen 403 PRODUCT_CATALOG_DISABLED: Wie Sie ihn aktivieren, steht in der Anleitung zum Produktkatalog.

5. Finalisieren und versenden

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "event": "FINALIZE" }' $BASE/companies/$COMPANY/invoices/<invoiceId>/transition

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "kind": "invoice" }' $BASE/companies/$COMPANY/invoices/<invoiceId>/send

Der Lebenszyklus ist der der App: draftready (FINALIZE)sentpaid (MARK_PAID, mit optionalem paidDate). CANCEL storniert aus draft, ready, sent oder overdue; REOPEN holt eine stornierte Rechnung in den Entwurf zurück. Jedes andere Paar antwortet 409 INVALID_TRANSITION, mit dem abgelehnten Paar in message.

Der E-Mail-Versand läuft über POST …/send: kind: "invoice" nur aus ready (sonst 409 BILL_EMAIL_INVALID_STATUS), kind: "reminder" nur aus overdue (sonst 409 BILL_REMINDER_INVALID_STATUS). Das PDF wird angehängt, und die Mail geht in der Rechnungssprache raus; ein Kunde ohne E-Mail-Adresse antwortet 422 CLIENT_EMAIL_MISSING. Das Ereignis SEND von …/transition gibt es ebenfalls, es markiert aber lediglich eine Rechnung im Status ready als sent, ohne E-Mail.

Eine Zahlung stornieren

curl -X POST -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/cancel-payment

Ohne Body, Antwort 204: Das Zahlungsdatum wird gelöscht, und die Rechnung kehrt in ihren vorherigen Status zurück (sent oder overdue). Eine nicht bezahlte Rechnung antwortet 409 BILL_NOT_PAID. Eine Zahlung, die der Bankabgleich gesetzt hat, antwortet 409 BILL_PAYMENT_RECONCILED: Sie wird storniert, indem Sie den Abgleich in der App aufheben.

6. Das PDF herunterladen

curl -L -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/pdf -o invoice.pdf

curl -L -H "Authorization: Bearer $KEY" \
  $BASE/companies/$COMPANY/invoices/<invoiceId>/qr-bill-pdf -o qr-bill.pdf

Beide Routen antworten 302 auf eine kurzlebige URL (15 Minuten): Folgen Sie der Weiterleitung immer mit -L und cachen Sie das Location nie. Die Dateien werden bei Bedarf erzeugt, der erste Aufruf nach einer Änderung rendert das Dokument. 503 PDF_UNAVAILABLE heisst, das Rendern ist fehlgeschlagen: erneut versuchen. Eine aus einer anderen Software importierte Rechnung wird nie gerendert: …/pdf liefert ihre Originaldatei, die auch ein Bild sein kann, prüfen Sie also den Content-Type.

7. Eine ausgestellte Rechnung ändern

Hat eine Rechnung draft verlassen, ändert sie sich nur mit Begründung: Jede Kopf- oder Positionsänderung muss einen nicht leeren modificationReason tragen, der im Protokoll der Rechnung festgehalten wird. Ohne ihn lautet die Antwort 422 MODIFICATION_REASON_REQUIRED, und nichts wird geschrieben.

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "dueDate": "2026-04-30", "modificationReason": "Client asked for a longer term" }' \
  $BASE/companies/$COMPANY/invoices/<invoiceId>

Positionen haben eigene Routen (POST …/items, PATCH …/items/{itemId}, DELETE …/items/{itemId}), alle geben die ganze Rechnung mit neu berechneten Summen zurück. Da ein DELETE keinen Body hat, reist die Begründung als Query-Parameter:

curl -X DELETE -H "Authorization: Bearer $KEY" \
  "$BASE/companies/$COMPANY/invoices/<invoiceId>/items/<itemId>?modificationReason=Line%20billed%20twice"

Eine bezahlte Rechnung lehnt jede Änderung des Betrags ab (409 BILL_PAID_LOCKED), eine importierte Rechnung jede Änderung an den gedruckten Angaben (409 BILL_IMPORTED_LOCKED).

Alles auf einmal speichern

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 1772323200000,
        "header": { "clientId": "<clientId>", "lang": "fr", "currency": "CHF",
                    "issueDate": "2026-03-01", "dueDate": "2026-04-30" },
        "items": [
          { "id": "<itemId>", "description": "Design", "quantity": 3, "unitPrice": 150, "vatRate": 8.1 },
          { "description": "Support", "quantity": 1, "unitPrice": 90, "vatRate": 8.1 }
        ],
        "modificationReason": "Scope extended" }' \
  $BASE/companies/$COMPANY/invoices/<invoiceId>

PUT speichert den Kopf und alle Positionen in einer einzigen Transaktion und antwortet mit der ganzen Rechnung. expectedVersion ist das updatedAt Ihrer letzten Abfrage: Hat sich die Rechnung inzwischen geändert, lautet die Antwort 409 BILL_VERSION_CONFLICT, und nichts wird geschrieben. header übernimmt die Pflichtfelder der Erstellung; ohne discount oder shippingCosts bleiben die gespeicherten Werte erhalten, senden Sie 0, um sie zu löschen.

items ist die vollständige Liste, in der gewünschten Reihenfolge: Eine fehlende Position wird gelöscht. Eine id, die nicht zur Rechnung gehört oder doppelt vorkommt, antwortet 422 BILL_ITEM_NOT_FOUND. Die Kennungen der Positionen ändern sich, wenn sich der Inhalt der Rechnung ändert: Lesen Sie sie aus der Antwort neu ein, ebenso das neue updatedAt. Bei einer Rechnung, die draft verlassen hat, ist modificationReason Pflicht, sobald sich etwas an den gedruckten Angaben ändert.

8. Offerten und wiederkehrende Rechnungen

Offerten (/companies/{companyId}/quotes) haben die Form von Rechnungen, mit validUntil statt dueDate und einer zusätzlichen Regel: Eine Offerte lässt sich nur im Status draft oder expired ändern. Eine versandte Offerte antwortet 409 QUOTE_NOT_EDITABLE; setzen Sie sie mit REVERT_TO_DRAFT in den Entwurf zurück oder duplizieren Sie sie. Für Offerten gilt kein Kontingent, nur die Umwandlung verbraucht eine Rechnung aus dem Kontingent:

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/convert

Die Umwandlung erstellt eine Rechnung im Entwurf und antwortet 201 mit invoiceId und invoiceNumber. Eine Offerte wird einmal umgewandelt (409 QUOTE_ALREADY_CONVERTED), eine abgelehnte oder abgelaufene gar nicht (409 QUOTE_NOT_CONVERTIBLE).

Um eine Offerte an den Kunden zu senden, nimmt POST …/send keinen Body entgegen und antwortet 204:

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/quotes/<quoteId>/send

Das PDF geht an die Adresse des Kunden, in der Sprache der Offerte, danach wechselt die Offerte auf sent. Der Versand ist nur aus draft möglich (409 QUOTE_EMAIL_INVALID_STATUS), vor dem Gültigkeitsdatum (422 VALID_UNTIL_IN_PAST) und mit einer E-Mail-Adresse des Kunden (422 CLIENT_EMAIL_MISSING). 502 QUOTE_PDF_GENERATION_FAILED und 502 QUOTE_EMAIL_SEND_FAILED bedeuten, dass nichts versandt wurde: erneut versuchen.

Aus sent oder expired akzeptiert POST …/transition die Ereignisse MARK_ACCEPTED (mit optionalem acceptedAt) und MARK_DECLINED. Eine versandte Offerte wechselt am Gültigkeitsdatum von selbst auf expired; ein PATCH, der validUntil in die Zukunft verschiebt, reaktiviert sie.

PUT …/quotes/{quoteId} speichert ebenfalls den Kopf und die Positionen auf einmal, mit denselben Regeln für expectedVersion (409 QUOTE_VERSION_CONFLICT) und für items (422 QUOTE_ITEM_NOT_FOUND). Zwei Unterschiede: header darf nur die zu ändernden Felder enthalten, und es gibt kein modificationReason.

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 1772323200000,
        "header": { "validUntil": "2026-06-30" },
        "items": [{ "id": "<itemId>", "description": "Design", "quantity": 3, "unitPrice": 150, "vatRate": 8.1 }] }' \
  $BASE/companies/$COMPANY/quotes/<quoteId>

Eine wiederkehrende Rechnung (/companies/{companyId}/recurring-invoices) ist kein Dokument, sondern Kopf, Positionen und Uhr: startDate setzt den nächsten Lauf, endDate oder maxOccurrences beenden ihn, und mit autoSend wird jede erzeugte Rechnung finalisiert und an den Kunden versandt. Es gibt keine Route pro Position, PUT …/items ersetzt den ganzen Satz:

curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "items": [
        { "description": "Abonnement", "quantity": 1, "unitPrice": 180, "vatRate": 8.1 },
        { "description": "Support", "quantity": 2, "unitPrice": 90, "vatRate": 8.1 }
      ] }' \
  $BASE/companies/$COMPANY/recurring-invoices/<scheduleId>/items

9. Rechnungen und Offerten löschen

DELETE …/invoices/{invoiceId} und DELETE …/quotes/{quoteId} löschen ein Dokument und antworten 204. Für mehrere Dokumente senden Sie 1 bis 100 Kennungen:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["<invoiceId1>", "<invoiceId2>"] }' \
  $BASE/companies/$COMPANY/invoices/bulk-delete

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "ids": ["<quoteId1>", "<quoteId2>"] }' \
  $BASE/companies/$COMPANY/quotes/bulk-delete

Die Antwort liefert affected und skipped; Letzteres zählt die wiederholten Kennungen. Wird die aus einer Offerte entstandene Rechnung gelöscht, lässt sich diese Offerte wieder umwandeln.

Für Lohn und Buchhaltung geht es weiter mit Lohn, Ausgaben und Buchhaltung per API; zu Paginierung, Daten und Fehlercodes lesen Sie Konventionen und Fehler. Jedes Feld ist in der Swagger UI Ihres Deployments beschrieben (…/api/docs).