Zum Inhalt springen

REST-API ​

openbill hat unter /api eine REST-API. Die Oberfläche, KI-Agenten und das Template-Toolkit nutzen dieselben Endpunkte, und jede Änderung läuft durch dieselben Regeln: Ein abgeschlossenes Dokument lässt sich nicht mehr ändern, eigene Felder werden geprüft, jeder Schritt wird protokolliert.

Die interaktive Referenz (Swagger UI) liegt unter /api/docs, das OpenAPI-Dokument unter /api/docs.jsonopenapi. Beide funktionieren ohne Token.

Anmeldung ​

Ein Token wird im Header Authorization mitgeschickt:

bash
export OPENBILL_URL=https://openbill.ddev.site   # deine Instanz
export OPENBILL_TOKEN='01J…|openbill_…'

curl -H "Authorization: Bearer $OPENBILL_TOKEN" -H 'Accept: application/json' "$OPENBILL_URL/api/documents"

Tokens entstehen in der App unter Einstellungen → API & MCP (siehe MCP) oder auf der Kommandozeile. Der Token wird nur einmal angezeigt:

bash
php artisan app:create-token admin@example.com --name=agent --ability=api:read --ability=api:write
# in DDEV: ddev artisan app:create-token …
FähigkeitErlaubt
api:readalle Ressourcen lesen, PDF/XML herunterladen, Positionsvorschläge, Vorschau
api:writealles aus api:read und zusätzlich anlegen, ändern, löschen
templates:renderVorschau: POST /api/render, GET /api/render/sources, GET /api/render/schema. Das ist lesender Zugriff auf alle Dokumente: Der Token kann jedes Dokument gerendert sehen (Kunde, Adresse, Positionen, Beträge), und render/sources listet die letzten Dokumente mit Nummer und Kundenname. Das Toolkit braucht das, um Templates mit echten Dokumenten anzusehen.
templates:writeein Template lesen und schreiben (--template=<id>): GET /api/templates/{id}, GET /api/templates/{id}/package, PUT /api/templates/{id}/files. Der Download des Template-Toolkits legt so einen Token zusammen mit templates:render an; so ein gebundener Token rendert (POST /api/render) nur mit seinem eigenen Template, das er als template_id nennt (fehlend oder fremd → 403).
mcpnur der MCP-Server (kein REST-Endpunkt)

Ohne gültigen Token antwortet die API mit 401, mit einem Token ohne passende Fähigkeit mit 403. Ein widerrufener oder abgelaufener Token bekommt 401.

Die openbill-Oberfläche selbst nutzt die API mit ihrer Anmeldung (Session), im Browser ist kein Token nötig.

Formate ​

  • Accept: application/json – einfaches JSON. Listen sind Arrays der aktuellen Seite.
  • Accept: application/ld+json (Standard) – JSON-LD. Listen sind Objekte mit member, totalItems und view (Links auf erste, letzte, nächste und vorige Seite).
  • Feldnamen in snake_case. Beträge und Mengen sind Strings ("1130.50", "10.0000"); Zahlen mit Nachkommastellen in einer Anfrage werden abgelehnt. Datumswerte YYYY-MM-DD, IDs sind ULIDs.
  • Jedes Feld einer Ressource ist immer vorhanden; Felder ohne Wert sind null (zum Beispiel "number": null eines Entwurfs).
  • Kunden- und Dokumentlisten sind seitenweise: ?page=2&itemsPerPage=50 (bis 100, Standard 30).
  • PATCH ändert nur die mitgeschickten Schlüssel. groups eines Dokuments und contacts eines Kunden ersetzen immer die ganze Liste (mit der id einer Gruppe, Position oder eines Ansprechpartners bleibt er erhalten).
  • Mit Accept-Language: de kommen Meldungen auf Deutsch.

Fehler ​

Jeder Fehler ist application/problem+json:

json
{
  "type": "urn:openbill:problem:document_locked",
  "title": "Konflikt",
  "status": 409,
  "detail": "INV-2026-0007 ist abgeschlossen und lässt sich nicht mehr ändern.",
  "reason": "document_locked",
  "document_id": "01J…",
  "number": "INV-2026-0007"
}

reason ist stabil und für Programme gedacht:

StatusreasonZusätzliche Felder
400bad_request (Body ist kein JSON-Objekt)
401unauthenticated
403forbidden
404not_found
409document_lockeddocument_id, number
409status_transition, conversion, duplicate_cancellation, cancellation_needs_invoice, cancellation_source, cancellation_customer, no_e_invoice, customer_has_finalized_documents, template_built_in, template_in_use
422validation_failedviolations: [{path, message}]
422compliance_failed (Abschließen)violations: [{path, message, code}]
422template_render_failed (Abschließen)errors: [{part, file, line, message}]
422einvoice_invaliderrors: [Meldung]
429too_many_requests
503pdf_render_failed (PDF-Dienst nicht erreichbar, später erneut versuchen)

Pfade gibt es in zwei Formen. Die Validierung nutzt die Stelle in der Anfrage: groups.0.positions.1.custom.ticket ist das eigene Feld ticket der zweiten Position in der ersten Gruppe. Die Prüfung (issues eines Entwurfs, compliance_failed) nutzt IDs, weil sie das gespeicherte Dokument beschreibt: positions.01J….unit, document.custom.project_name, customer.address.

Ressourcen ​

RessourceEndpunkte
KundenGET/POST /api/customers (?q=), GET/PATCH/DELETE /api/customers/{id}
DokumenteGET/POST /api/documents (?type= &status= &customer_id= &q= &date_from= &date_to= &sort=), GET/PATCH/DELETE /api/documents/{id}
Dokument-AktionenPOST /api/documents/{id}/finalize, …/status ({"status": "accepted"}), …/convert ({"type": "invoice"}), …/cancel, …/duplicate
DownloadsGET /api/documents/{id}/pdf (?inline=1), GET /api/documents/{id}/xml
PositionsvorschlägeGET /api/position-suggestions (?q= &limit=)
Eigene FelderGET/POST /api/field-definitions (?scope= &doc_type=), GET/PATCH/DELETE /api/field-definitions/{id}
Steuersätze, EinheitenGET/POST /api/tax-rates, /api/units (?include_archived=1), GET/PATCH/DELETE …/{id} – DELETE archiviert
TextbausteineGET/POST /api/text-blocks (?kind=), GET/PATCH/DELETE /api/text-blocks/{id}
DokumenttypenGET /api/document-types, GET/PATCH /api/document-types/{type} (Profil, Template, Texte, Fristen, Nummernkreis)
OrganisationGET/PATCH /api/organization
KundennummernGET/PATCH /api/customer-numbering (pattern, reset_yearly, next_value; next_number nur lesend)
TemplatesGET /api/templates, POST /api/templates (Paket-ZIP als Multipart-Feld package), GET/DELETE /api/templates/{id}, PUT /api/templates/{id}/files, GET /api/templates/{id}/package
VorschauPOST /api/render, GET /api/render/sources, GET /api/render/schema

Zusätzliche Endpunkte:

EndpunktWas er tut
POST /api/templates/{id}/duplicatebearbeitbare Kopie eines Templates (auch eines mitgelieferten), 201
POST /api/field-definitions/reorderReihenfolge aller Felder einer Ebene: {"scope", "ids"}
POST /api/uploads, GET /api/uploads/{name}Bild für ein Bildfeld (multipart file, höchstens 1 MB); path der Antwort ist der Feldwert
GET, POST, DELETE /api/organization/logoLogo der Organisation (multipart logo)
GET /api/document-types/{type}/number-preview?pattern=…&reset_yearly=1&next_value=…nächste Nummer für ein noch nicht gespeichertes Muster
GET /api/customer-numbering/preview?pattern=…&reset_yearly=1&next_value=…dasselbe für Kundennummern
POST /api/tax-rates/{id}/restore, POST /api/units/{id}/restoremacht das archivierende DELETE rückgängig

Kunden haben supply_type (goods oder services, Standard services) und customer_kind (business oder consumer, Standard business); bei Kunden außerhalb der EU entscheiden sie zwischen Steuerkategorie G, O (Dienstleistungen an Unternehmen) und dem normalen Satz (Dienstleistungen an Privatpersonen) (siehe E-Rechnung). Die Benutzerverwaltung (/api/users, /api/users/{id}/disable|enable, /api/invitations) und das Ändern des eigenen Passworts (PUT /api/account/password) stehen nur der Sitzung der App offen, nie Tokens; deaktivierte Konten bekommen keine API-Tokens, ihre vorhandenen funktionieren nicht mehr.

Ein Dokument in einer Liste hat die Kurzform (Nummer, Status, Kunde, Summen, Datumswerte). Abgeschlossene Rechnungen haben zusätzlich open_amount: die Bruttosumme abzüglich abgeschlossener (Teil-)Stornos, also was noch geschuldet ist, solange die Rechnung offen ist (null bei anderen Dokumenten und Entwürfen). Ein Storno muss mehr als 0 und höchstens diesen Betrag gutschreiben; sonst antwortet das Abschließen mit compliance_failed und dem Code cancellation_not_credit bzw. cancellation_exceeds_invoice am Pfad positions. GET /api/documents/{id} ergänzt Texte, eigene Felder, groups mit ihren Positionen, totals (netto, Steuer, brutto, Steueraufschlüsselung, Rechtshinweise) und bei Entwürfen issues: alles, was das Abschließen noch bemängeln würde.

Der Filter status von GET /api/documents kennt zusätzlich overdue: offene Rechnungen, deren Fälligkeit überschritten ist. Überfällig ist kein eigener Status; Dokumente tragen ihn nie.

Der Hauptablauf mit curl ​

bash
H=(-H "Authorization: Bearer $OPENBILL_TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json')

# ein Kunde
CUSTOMER=$(curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/customers" -d '{
  "name": "Hafenkontor Lindqvist GmbH", "street": "Kaistraße 4", "zip": "20457", "city": "Hamburg", "country": "DE"
}' | jq -r .id)

UNIT=$(curl -s "${H[@]}" "$OPENBILL_URL/api/units" | jq -r '.[] | select(.code=="HUR") | .id')
VAT=$(curl -s "${H[@]}" "$OPENBILL_URL/api/tax-rates" | jq -r '.[] | select(.percent=="19.00") | .id')

# ein Angebot mit einer Gruppe
OFFER=$(curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents" -d "{
  \"type\": \"offer\", \"customer_id\": \"$CUSTOMER\",
  \"groups\": [{\"title\": \"Workshop\", \"positions\": [
    {\"kind\": \"item\", \"title\": \"Discovery-Workshop\", \"quantity\": \"8\", \"unit_price\": \"120\", \"unit_id\": \"$UNIT\", \"tax_rate_id\": \"$VAT\"}
  ]}]
}" | jq -r .id)

# abschließen, annehmen, in eine Rechnung umwandeln
curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$OFFER/finalize" | jq -r .number
curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$OFFER/status" -d '{"status": "accepted"}' > /dev/null
INVOICE=$(curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$OFFER/convert" -d '{"type": "invoice"}' | jq -r .id)

# was fehlt noch? (leere Liste = bereit)
curl -s "${H[@]}" "$OPENBILL_URL/api/documents/$INVOICE" | jq .issues

# Rechnung abschließen, PDF laden, als bezahlt markieren
curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$INVOICE/finalize" | jq -r .number
curl -s -H "Authorization: Bearer $OPENBILL_TOKEN" -o rechnung.pdf "$OPENBILL_URL/api/documents/$INVOICE/pdf"
curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$INVOICE/status" -d '{"status": "paid"}' > /dev/null

# stornieren: legt den Storno-Entwurf an (alle Positionen negiert; für ein Teilstorno verringern),
# sein Abschluss storniert die Rechnung, sobald nichts mehr von ihr übrig ist
CANCELLATION=$(curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$INVOICE/cancel" | jq -r .id)
curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/documents/$CANCELLATION/finalize" | jq -r .status

Entwürfe speichern ​

PATCH /api/documents/{id} speichert einen Entwurf und antwortet mit den neuen Summen und offenen Punkten – genau das nutzt das automatische Speichern im Editor. Der Body nimmt date, customer_id, contact_id, service_date, service_date_end, due_date, valid_until, intro, closing, template_id, locale, custom und groups:

json
{
  "groups": [
    {"id": "01J…", "title": "Workshop", "positions": [
      {"id": "01J…", "kind": "item", "title": "Discovery-Workshop", "description": "", "quantity": "8",
       "unit_price": "120", "unit_id": "01J…", "tax_rate_id": "01J…", "custom": {"ticket": "OPS-12"}}
    ]},
    {"title": null, "positions": [{"kind": "text", "title": "Reisekosten werden gesondert berechnet."}]}
  ]
}

Eine Gruppe mit "title": null enthält lose Positionen (ohne Überschrift und Zwischensumme). kind ist item, text oder optional (nur in Angeboten). Ein abgeschlossenes Dokument antwortet mit 409 document_locked.

Vorschau ​

POST /api/render rendert ein gespeichertes Dokument, ungespeicherte Änderungen eines Entwurfs (draft, gleicher Body wie bei PATCH) oder Beispieldaten (scenario), optional mit ungespeicherten Template-Teilen (template). Die Antwort enthält header, body, footer (vollständige HTML-Dokumente), paper, totals, issues und errors. Fehler im Template sind kein HTTP-Fehler: Sie stehen mit Bereich, Datei und Zeile in errors, die Anfrage antwortet trotzdem mit 200.

bash
curl -s "${H[@]}" -X POST "$OPENBILL_URL/api/render" -d '{"scenario": "long_invoice", "locale": "de"}' | jq .errors

GET /api/render/sources listet die Beispielszenarien und die letzten Dokumente, GET /api/render/schema die Felder, die ein Template je Ebene nutzen kann (?document_type=invoice filtert die eigenen Felder).

Templates ​

PUT /api/templates/{id}/files schreibt die mitgeschickten Teile eines importierten Templates – so arbeitet push im Toolkit:

json
{
  "header": "…", "body": "…", "footer": "…", "css": "…",
  "manifest": {"name": "Fjord", "version": "1.1.0"},
  "assets": {"assets/logo.svg": "<base64>"}
}

assets ist, wenn mitgeschickt, die vollständige Liste. Mitgelieferte Templates (Klar, Kante, Leise) sind schreibgeschützt: 409 template_built_in. GET /api/templates/{id}/package lädt das Template als Paket-ZIP, POST /api/templates importiert eines (-F package=@template.zip).

Downloads für andere Programme ​

Der MCP-Server gibt signierte Download-Links für PDF und XML aus. Sie funktionieren 15 Minuten ohne Token; danach oder verändert antworten sie mit 403. Ein abgeschlossenes Dokument liefert seine Archivdatei. Ein Entwurf wird live mit Wasserzeichen gerendert. Sein Link ist an den Inhalt des Entwurfs gebunden (Felder, Texte, Kunde und Ansprechpartner, Template-Wahl, Gruppen und Positionen in ihrer Reihenfolge): Nach jeder Änderung daran antwortet er mit 403, dann einen neuen Link anfordern. Stammdaten werden live gelesen, eine geänderte Kundenadresse oder ein geändertes Template erscheint also auch über einen Entwurfs-Link. Nach dem Abschließen liefert der Link die Archivdatei.

Grenzen ​

600 Anfragen pro Minute je Token (ohne Token je IP). Darüber antwortet die API mit 429 und Retry-After.