API-Referenz
prekonto ist headless benutzbar: Stammdaten hinterlegen, Rechnungen einliefern, Buchungsvorschläge lesen und korrigieren, DATEV-Buchungsstapel ziehen — alles über eine REST-API. Diese Seite beschreibt sie vollständig. Maßgeblich ist immer das Maschinendokument unter /api/v1/openapi.json.
Grundlagen: Basis-URL, Formate, Mandantenscope
Basis-URL ist https://prekonto.de, alle Pfade beginnen mit /api/v1. Die Version steigt nur bei Brüchen; ein /api/v2 wäre ein zweiter Pfad und keine stille Änderung an diesem.
- Geld ist immer Integer-Cent. Felder tragen dafür das Suffix
Cent—umsatzCent: 142800sind 1.428,00 Euro. Es gibt keine Fließkommabeträge in dieser API. - Kalendertage sind
YYYY-MM-DD, Zeitpunkte ISO-8601 mit Zeitzone. - Mandantenscope: Jeder fachliche Pfad trägt eine Mandanten-ID, und jede Abfrage filtert auf das Konto des Credentials. Ein Mandant eines anderen Kontos ergibt
404, nicht403— fremde Existenz wird nicht bestätigt. - Fehler tragen neben dem deutschen
error-Text ein stabiles, maschinenlesbarescode-Feld. Aufcodedarf ein Client sich verlassen, auf den Text nicht.
202 und einer statusUrl. Extraktion, Kreditor-Matching und Kontierung laufen danach weiter — beim PDF-Weg ruft das ein Sprachmodell und dauert zehn Sekunden und mehr. Den Fortschritt entweder über getInvoice abfragen oder den Webhook invoice.processed abonnieren.Authentifizierung: API-Key oder OAuth 2.0
Beide Wege laufen über denselben Header: Authorization: Bearer …. Am Präfix des Werts entscheidet der Server, worum es sich handelt — ein unbekanntes Präfix wird abgewiesen, ohne dass überhaupt in der Datenbank gesucht wird.
API-Key — für eigene Skripte und Server
Ein API-Key beginnt mit pk_live_. Angelegt wird er im angemeldeten Bereich unter API-Keys (Seite „API-Zugänge“); dort werden auch die Scopes angehakt, die der Key tragen soll. Der Klartext erscheint genau einmal, direkt nach dem Anlegen: gespeichert wird nur sein SHA-256-Hash. Verloren heißt neu erzeugen, nicht nachschauen. Widerrufen ist ein Zeitstempel und kein Löschen — die Frage, wer wann mit welchem Key zugegriffen hat, bleibt beantwortbar. Pro Konto sind höchstens 20 aktive Keys erlaubt.
curl https://prekonto.de/api/v1 \ -H "Authorization: Bearer pk_live_…"
GET /api/v1 ist der Endpunkt, mit dem man einen Key prüft: Er kostet nichts und antwortet mit den effektiven Scopes des Credentials. Ein 200 heißt „der Key trägt“.
OAuth 2.0 mit PKCE — für Anwendungen fremder Nutzer
Wer eine Anwendung baut, die im Namen eines prekonto-Nutzers arbeitet, nimmt OAuth. Unterstützt wird ausschließlich der Authorization-Code-Flow, PKCE ist Pflicht und nur S256 erlaubt — plain wird abgelehnt, weil es nichts schützt. Redirect-URIs werden exakt verglichen, ohne Wildcards. Codes sind einmalig, Refresh-Tokens rotieren.
| Was | Wert |
|---|---|
| Autorisierung | https://prekonto.de/api/oauth/authorize |
| Token und Refresh | https://prekonto.de/api/oauth/token |
| Widerruf | https://prekonto.de/api/oauth/revoke |
| Metadaten des Authorization Servers (RFC 8414) | https://prekonto.de/.well-known/oauth-authorization-server |
| Metadaten der geschützten Ressource (RFC 9728) | https://prekonto.de/.well-known/oauth-protected-resource |
| Access-Token-Präfix, Lebensdauer | pat_… · 1 Stunde |
| Refresh-Token-Präfix, Lebensdauer | prt_… · 180 Tage |
| Authorization-Code-Präfix, Lebensdauer | pac_… · 5 Minuten |
| PKCE-Verfahren | S256 (ausschließlich) |
Ein OAuth-Client wird ebenfalls unter API-Keys registriert — mit Name und Redirect-URIs. Vertrauliche Clients (etwa eine Server-Integration) bekommen zusätzlich ein client_secret mit Präfix pcs_, öffentliche Clients (native Apps, MCP-Clients) weisen sich allein über PKCE aus.
401 nennt der WWW-Authenticate-Header die Metadaten-URL. Ein OAuth-fähiger Client — Claude und ChatGPT gehören dazu — findet den Authorization Server damit selbst und startet den Ablauf, statt den Nutzer nach einem API-Key zu fragen.401 und 403 sind sauber getrennt: 401 heißt „weise dich (anders) aus“, 403 heißt „dein Credential trägt, darf das aber nicht“ und nennt in fehlendeScopes genau die Scopes, die fehlen. Wer beides vermischt, lässt Clients endlos neue Tokens holen, die genauso wenig dürfen.
Scopes und was sie erlauben
Fünf Scopes, bewusst grob und an den vier Anwendungsfällen orientiert. Feingranulare Rechte, die niemand versteht, führen in der Praxis dazu, dass alle alles anhaken.
| Scope | Beschriftung im Zustimmungs-Dialog | Was das praktisch heißt |
|---|---|---|
| stammdaten:write | Kreditoren- und Gegenkontenlisten hochladen und aktualisieren | Darf Listen einliefern und Webhook-Abos verwalten. Manuell gepflegte Konten werden dabei nicht überschrieben, gelöscht wird nie. |
| invoices:write | Rechnungen zur Verarbeitung einliefern | Darf Belege hochladen. Enthält ausdrücklich KEIN Leserecht — ein Einlieferer muss die Buchungsvorschläge nicht sehen können. |
| buchungssaetze:read | Buchungssätze und ihren Status lesen | Der Lese-Scope für alles Fachliche: Buchungssätze, Rechnungen samt Verarbeitungsstand, Kreditoren- und Gegenkontenliste. |
| buchungssaetze:write | Buchungssätze korrigieren und bestätigen | Darf Kontierungen ändern und freigeben. Freigegebene Sätze gehen in den nächsten Export — das ist der Scope mit den weitesten Folgen. |
| export:read | DATEV-Buchungsstapel (EXTF) herunterladen | Darf Stapel erzeugen, die Stapelhistorie lesen und Dateien herunterladen. |
Zwei Eigenheiten, die man kennen muss. invoices:write enthält kein Leserecht — ein Dienst, der nur Belege einliefert, muss die Buchungsvorschläge nicht sehen können. Und Webhook-Abos hängen an stammdaten:write: Ein Abo ist eine Konfigurationsänderung am Mandanten, kein Lesevorgang.
Meldet sich die eigene Oberfläche mit ihrem Session-Cookie an, trägt sie alle Scopes. Das ist kein Loch: Scopes begrenzen, was ein Nutzer einer fremden Anwendung erlaubt — ihn am eigenen Konto zusätzlich einzuschränken würde nur die eigene Oberfläche kaputtmachen.
Alle Endpunkte
Gruppiert wie in der Spec. Die Scope-Spalte gibt die security-Angabe der jeweiligen Operation wieder. „Jeder Scope“ heißt: jedes gültige Credential genügt — ohne Mandantenliste geht kein einziger anderer Aufruf, ein eigener Scope dafür hätte niemanden geschützt.
Meta
Auskunft über die Schnittstelle und über das vorgelegte Credential. Der erste Aufruf einer neuen Integration gehört hierher: er kostet nichts und beantwortet, ob der Key trägt und welche Scopes er hat.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| GET | /api/v1 | 200 | jeder Scope | Version, Issuer, Discovery-URLs und die effektiven Scopes des Credentials. Der Endpunkt zum Prüfen eines Keys. operationId: getApiInfo |
| GET | /api/v1/openapi.json | 200 | keiner | Das OpenAPI-3.1-Dokument, ohne Authentifizierung. Ein Client soll die Schnittstelle lesen können, bevor er ein Credential hat. operationId: getOpenApiDocument |
Mandanten
Ein Mandant ist eine Firma mit ihren DATEV-Kopfdaten. Jeder fachliche Pfad trägt eine Mandanten-ID, und jede Abfrage filtert auf das Konto des Credentials. Angelegt werden Mandanten in der Oberfläche, nicht über die API — die Kopfdaten sind eine Angabe des Steuerberaters.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| GET | /api/v1/mandanten | 200 | jeder Scope | Alle Mandanten des Kontos mit ihren Kopfdaten. Der Einstieg jeder Integration: ohne Mandanten-ID geht kein weiterer Aufruf. operationId: listMandanten |
Stammdaten
Kreditorenliste (Lieferanten-Personenkonten) und Gegenkontenliste (Sachkonten). Die Gegenkontenliste ist gleichzeitig die abschließende Auswahl, aus der die Kontierung wählen darf. Beide Endpunkte nehmen entweder eine Datei (multipart) oder ein JSON-Array — fachlich ist es dieselbe Operation: „diese Liste ist jetzt der Stand“.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| PUT | /api/v1/mandanten/{id}/kreditoren | 200 | stammdaten:write | Upsert über (Mandant, Kontonummer). Manuell gepflegte Sätze werden nicht überschrieben, sondern als Duplikat gemeldet; nicht mehr gelieferte Konten werden nicht gelöscht. operationId: replaceKreditoren |
| GET | /api/v1/mandanten/{id}/kreditoren | 200 | buchungssaetze:read | Kreditoren nach Kontonummer sortiert, optional gefiltert über `suche` (Name, Kontonummer oder Namens-Normalform). operationId: listKreditoren |
| PUT | /api/v1/mandanten/{id}/gegenkonten | 200 | stammdaten:write | Wie die Kreditorenliste, aber für die Sachkonten. Diese Liste ist das Enum, aus dem die Kontierung wählt — freie Kontonummern gibt es nicht. operationId: replaceGegenkonten |
| GET | /api/v1/mandanten/{id}/gegenkonten | 200 | buchungssaetze:read | Die gültigen Gegenkonten. `automatikkonto: true` heißt: dieses Konto darf keinen BU-Schlüssel tragen. operationId: listGegenkonten |
Rechnungen
Belege einliefern und ihren Verarbeitungsstand abfragen. Der Upload ist asynchron: Extraktion, Kreditor-Matching und Kontierung laufen nach der Antwort weiter.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| POST | /api/v1/mandanten/{id}/invoices | 202 | invoices:write | Nimmt PDF, Scan, XRechnung-XML oder ZUGFeRD an und antwortet mit 202 samt `invoiceId` und `statusUrl`. Synchron wäre falsch: der PDF-Weg ruft ein Sprachmodell und braucht zehn Sekunden und mehr. operationId: uploadInvoice |
| GET | /api/v1/mandanten/{id}/invoices | 200 | buchungssaetze:read | Rechnungen, neueste zuerst (höchstens 200), optional gefiltert über `status` (`neu`, `extrahiert`, `kontiert`, `fehler`). operationId: listInvoices |
| GET | /api/v1/mandanten/{id}/invoices/{invoiceId} | 200 | buchungssaetze:read | Die `statusUrl` aus dem Upload: extrahiertes Rechnungsmodell, Prüfungen und der entstandene Buchungssatz. `neu` heißt „läuft noch“, `kontiert` heißt fertig, `fehler` nennt den Grund im Feld `fehler`. operationId: getInvoice |
Buchungssätze
Der Kontierungsvorschlag zu einer Rechnung: lesen, korrigieren, bestätigen. Der Status ist die Ampel — `auto` (alle Prüfungen grün), `review` (braucht eine menschliche Entscheidung), `bestaetigt` (freigegeben). Exportiert werden nur `auto` und `bestaetigt`.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| GET | /api/v1/mandanten/{id}/buchungssaetze | 200 | buchungssaetze:read | Nach Belegdatum absteigend (höchstens 500). Filter: `status` sowie `von`/`bis` über das Belegdatum, jeweils inklusive. operationId: listBookingRecords |
| GET | /api/v1/mandanten/{id}/buchungssaetze/{satzId} | 200 | buchungssaetze:read | Ein Satz mit allen Prüfungen und Feld-Confidences — die Begründung, warum er im Review liegt. operationId: getBookingRecord |
| PATCH | /api/v1/mandanten/{id}/buchungssaetze/{satzId} | 200 | buchungssaetze:write | Korrigiert Kontierungsfelder. Umsatz und Belegdatum sind gesperrt und werden abgewiesen, nicht ignoriert. Mit `status: "bestaetigt"` im Körper wird in einem Schritt korrigiert und freigegeben. operationId: updateBookingRecord |
| POST | /api/v1/mandanten/{id}/buchungssaetze/{satzId}/bestaetigen | 200 | buchungssaetze:write | Gibt den Satz unverändert frei — „der Vorschlag stimmt“. Eigener Endpunkt, weil der Unterschied zur Korrektur in `korrigiertVonUser` landet und die Grundlage der Qualitätsmetrik ist. Idempotent. operationId: confirmBookingRecord |
Export
DATEV-Buchungsstapel im EXTF-Format erzeugen, protokollieren und herunterladen. Der Zeitraum darf das Wirtschaftsjahr des Mandanten nicht überschreiten.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| POST | /api/v1/mandanten/{id}/exports | 201 | export:read | Erzeugt aus allen exportierbaren Sätzen des Zeitraums eine EXTF-700-Datei, protokolliert sie als Stapel und löst den Webhook `export.created` aus. Körper: `zeitraumVon`, `zeitraumBis`. operationId: createExport |
| GET | /api/v1/mandanten/{id}/exports | 200 | export:read | Erzeugte Stapel, neueste zuerst — jeder mit seinem Schreibprotokoll (gekürzte Texte, ersetzte Zeichen). operationId: listExports |
| GET | /api/v1/mandanten/{id}/exports/{batchId}/download | 200 | export:read | Die Datei byteweise so, wie sie erzeugt wurde: ISO-8859-1, Semikolon, CRLF. Nicht nach UTF-8 umkodieren. operationId: downloadExport |
Webhooks
Ereignis-Abos. Sie ersparen das Pollen der `statusUrl`: prekonto meldet sich, wenn ein Beleg durch ist. Verwaltet werden Abos über den Stammdaten-Scope — es ist eine Konfigurationsänderung am Mandanten.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| GET | /api/v1/mandanten/{id}/webhooks | 200 | stammdaten:write | Die Abos des Mandanten — ohne Signatur-Secret. Das gibt es nur einmal, beim Anlegen. operationId: listWebhooks |
| POST | /api/v1/mandanten/{id}/webhooks | 201 | stammdaten:write | Legt ein Abo an (https-URL, mindestens ein Event) und antwortet einmalig mit dem `secret`, mit dem jede Zustellung signiert wird. operationId: createWebhook |
| DELETE | /api/v1/mandanten/{id}/webhooks/{webhookId} | 204 | stammdaten:write | Löscht das Abo. Danach wird an diese URL nichts mehr zugestellt. operationId: deleteWebhook |
MCP
Der Model-Context-Protocol-Endpunkt für KI-Clients. Kein REST — er steht in der Spec, damit die Beschreibung vollständig ist, nicht damit ein generierter REST-Client ihn benutzt.
| Methode | Pfad | Erfolg | Scope | Was sie tut |
|---|---|---|---|---|
| POST | /api/v1/mcp | 200 | jeder Scope | JSON-RPC 2.0 über Streamable HTTP: `initialize`, `tools/list`, `tools/call`, `ping`. Den Scope verlangt das einzelne Werkzeug, nicht der Transport — Einzelheiten unter /mcp. operationId: callMcp |
Die operationId ist der Name, unter dem generierte Clients eine Operation führen. Sie ist deshalb stabil: Ein Umbenennen wäre für jeden generierten Client ein Bruch, auch wenn sich am Pfad nichts ändert.
Der Hauptfluss mit curl
Sechs Schritte, von der leeren Mandantenakte bis zur DATEV-Datei. Der Key in den Beispielen braucht alle fünf Scopes.
0. Credential prüfen und Mandanten-ID holen
export PK="pk_live_…"
curl -s https://prekonto.de/api/v1 -H "Authorization: Bearer $PK"
curl -s https://prekonto.de/api/v1/mandanten \
-H "Authorization: Bearer $PK"
# → { "mandanten": [ { "id": "…", "name": "Muster GmbH", … } ] }
export M="<mandanten-uuid>"1. Stammdaten hochladen (Kreditoren, dann Gegenkonten)
# DATEV-KRexport-Datei: wird am Dateinamen bzw. an der Header-Signatur
# erkannt und ohne Sprachmodell geparst.
curl -s -X PUT "https://prekonto.de/api/v1/mandanten/$M/kreditoren" \
-H "Authorization: Bearer $PK" \
-F "file=@KRExport_KRED_12345_20260101.csv"
curl -s -X PUT "https://prekonto.de/api/v1/mandanten/$M/gegenkonten" \
-H "Authorization: Bearer $PK" \
-F "file=@KRExport_KTO_12345_20260101.csv"
# Wer die Liste schon strukturiert hat, schickt JSON statt einer Datei:
curl -s -X PUT "https://prekonto.de/api/v1/mandanten/$M/gegenkonten" \
-H "Authorization: Bearer $PK" \
-H "Content-Type: application/json" \
-d '{"saetze":[
{"kontoNr":"4930","bezeichnung":"Bürobedarf","erwarteterSteuersatz":19},
{"kontoNr":"4980","bezeichnung":"Sonstiger Betriebsbedarf"}
]}'Die Antwort ist ein Importbericht: format nennt das erkannte Quellformat, deterministisch: true heißt „ohne KI geparst“, und bericht.duplikate listet die Konten, die nicht angefasst wurden, weil ein Mensch sie gepflegt hat.
2. Rechnung einreichen
curl -s -X POST "https://prekonto.de/api/v1/mandanten/$M/invoices" \
-H "Authorization: Bearer $PK" \
-F "file=@rechnung.pdf"
# → 202
# { "invoiceId": "…", "status": "neu",
# "statusUrl": "/api/v1/mandanten/…/invoices/…" }
export INV="<invoice-uuid>"3. Status abfragen
curl -s "https://prekonto.de/api/v1/mandanten/$M/invoices/$INV" \ -H "Authorization: Bearer $PK" # status: "neu" → läuft noch, in einigen Sekunden erneut fragen # "kontiert" → fertig, buchungssatz steht in der Antwort # "fehler" → Grund im Feld fehler
Wer nicht pollen will, abonniert invoice.processed (siehe Webhooks).
4. Buchungssätze lesen, korrigieren, bestätigen
# Was braucht einen Menschen?
curl -s "https://prekonto.de/api/v1/mandanten/$M/buchungssaetze?status=review" \
-H "Authorization: Bearer $PK"
export SATZ="<satz-uuid>"
# Gegenkonto korrigieren (gültige Werte über listGegenkonten)
curl -s -X PATCH \
"https://prekonto.de/api/v1/mandanten/$M/buchungssaetze/$SATZ" \
-H "Authorization: Bearer $PK" \
-H "Content-Type: application/json" \
-d '{"gegenkonto":"4930"}'
# Vorschlag stimmt: unverändert freigeben
curl -s -X POST \
"https://prekonto.de/api/v1/mandanten/$M/buchungssaetze/$SATZ/bestaetigen" \
-H "Authorization: Bearer $PK"Korrigieren und Bestätigen sind zwei Handlungen und deshalb zwei Aufrufe. Der PATCH sagt „das ist falsch, so ist es richtig“, der zweite Aufruf sagt „der Vorschlag stimmt“. Der Unterschied landet in korrigiertVonUser und ist die Grundlage der Qualitätsmessung; eine API, die beides über einen Aufruf abbildet, macht die Fälle im Nachhinein ununterscheidbar. Für Programme gibt es den Ein-Schritt-Weg trotzdem: {"gegenkonto":"4930","status":"bestaetigt"} korrigiert und gibt frei. Die Antwort trägt dann zusätzlich lernen — das Ergebnis der Lern-Regel.
umsatzCent und belegdatum sind nicht korrigierbar und werden mit 400 feld_nicht_korrigierbar abgewiesen — nicht stillschweigend ignoriert. Sie stehen so auf der Rechnung. Stimmt der Betrag nicht, ist die Extraktion falsch und der Beleg muss neu verarbeitet werden. Eine 200 auf ein verworfenes umsatzCent würde vortäuschen, der Betrag sei geändert.5. Export ziehen und herunterladen
curl -s -X POST "https://prekonto.de/api/v1/mandanten/$M/exports" \
-H "Authorization: Bearer $PK" \
-H "Content-Type: application/json" \
-d '{"zeitraumVon":"2026-07-01","zeitraumBis":"2026-07-31"}'
# → 201 { "id": "…", "dateiname": "EXTF_….csv", "anzahlBuchungen": 42,
# "summeCent": 1234500, "warnungen": [],
# "downloadUrl": "/api/v1/mandanten/…/exports/…/download" }
export BATCH="<batch-uuid>"
# Wichtig: --output, kein Umkodieren. Die Datei ist ISO-8859-1.
curl -s "https://prekonto.de/api/v1/mandanten/$M/exports/$BATCH/download" \
-H "Authorization: Bearer $PK" \
--output EXTF_Juli-2026.csvDer Zeitraum darf das Wirtschaftsjahr des Mandanten nicht überschreiten: Das Belegdatum trägt im EXTF-Format kein Jahr, DATEV leitet es aus dem Header ab. Ein Stapel über die Wirtschaftsjahr-Grenze wäre nicht auflösbar — deshalb antwortet die API mit 422 zeitraum_ausserhalb_wirtschaftsjahr statt eine Datei zu erzeugen, die später im DATEV-Import scheitert. Mehr dazu in der Doku zum DATEV-Export.
Fehler: die Statuscode-Regel
Die Statuscodes folgen einer Regel, auf die man sich verlassen kann. Das Unterscheidungskriterium ist die Handlung, die ein Code verlangt — nicht die Stelle, an der die Prüfung sitzt. Ob eine Ablehnung aus einem Schema oder aus einer Datenbankabfrage kommt, ist für den Aufrufer ein Implementierungsdetail.
| Status | Bedeutung | Was der Aufrufer tun muss |
|---|---|---|
| 400 | Die Eingabe ist unbrauchbar — entscheidbar ohne die Stammdaten des Mandanten: Pflichtfeld fehlt, Zeitraum verdreht, unbekannter Statuswert, gesperrtes Korrekturfeld. | Anderen Request schicken. |
| 422 | Die Eingabe ist verstanden, das Ergebnis für diesen Mandanten aber nicht buchbar: Konto nicht in der Kontenliste, Kontolänge widerspricht der Sachkontenlänge, Zeitraum verlässt das Wirtschaftsjahr. | Stammdaten ändern oder andere Werte wählen. Denselben Request zu wiederholen hilft nicht. |
| 409 | Der Zustand passt nicht: kein exportierbarer Buchungssatz im Zeitraum, Obergrenze erreicht. | Eine andere Handlung vornehmen — nicht andere Werte schicken. |
| 404 | Nicht gefunden oder Mandant eines fremden Kontos. Beides ist absichtlich nicht unterscheidbar: fremde Existenz wird nicht bestätigt. | Mandanten-ID und Konto prüfen. |
| 410 | Der Export-Stapel ist protokolliert, die Datei aber nicht mehr in der Ablage. | Stapel neu erzeugen. |
Der Unterschied zwischen 400 und 422 ist damit operativ: Bei 400 ist der Request falsch gebaut, bei 422 ist er richtig gebaut und passt nicht zu diesem Mandanten. Ein Retry hilft im zweiten Fall nie.
Maschinenlesbare Codes
Fachliche Ablehnungen tragen zusätzlich einen code. Kriterium für einen eigenen Code ist das Gegenmittel des Clients: Wo eine Anwendung eine Auswahlliste anbieten kann — kreditor_unbekannt, gegenkonto_unbekannt — gibt es einen eigenen Code; wo nichts anzubieten ist, bleibt es buchung_ungueltig mit Einzelmeldungen in probleme.
| code | Status | Fall |
|---|---|---|
| validation_error | 400 | Feldvalidierung; Details in `feldFehler`. |
| status_unbekannt | 400 | Unbekannter Statuswert in einem Filter. |
| feld_nicht_korrigierbar | 400 | Ein Feld der Korrektur ist gesperrt (`umsatzCent`, `belegdatum`, `confidence`, `checks`). |
| kreditor_unbekannt | 422 | Zum korrigierten `konto` gibt es keinen Kreditor des Mandanten. Kreditoren werden nie automatisch angelegt. |
| gegenkonto_unbekannt | 422 | Das Konto steht nicht in der Gegenkontenliste. Gültige Werte über `listGegenkonten`. |
| buchung_ungueltig | 422 | Widerspruch zu den Stammdaten (Kontolänge, BU-Schlüssel auf Automatikkonto); Einzelmeldungen in `probleme`. |
| zeitraum_ausserhalb_wirtschaftsjahr | 422 | Der Export-Zeitraum verlässt das Wirtschaftsjahr; die Antwort nennt `wjBeginn` und `wjEnde`. |
| keine_saetze | 409 | Kein exportierbarer Buchungssatz im Zeitraum. |
| limit_erreicht | 409 | Eine Obergrenze ist erreicht. Die Statuscode-Regel gilt für /api/v1 und für die Session-Routen der Oberfläche gleichermaßen; dieser Code entsteht derzeit dort — etwa bei den Anweisungen eines Mandanten (100) oder den aktiven API-Keys eines Kontos (20). |
| datei_verschwunden | 410 | Die Exportdatei liegt nicht mehr in der Ablage. |
| not_found | 404 | Nicht gefunden oder fremder Mandant. |
| url_nicht_erlaubt | 400 | Die Webhook-URL zeigt auf eine interne Adresse (privat, Loopback, Link-Local, CGNAT). |
Bei Authentifizierungsfehlern gelten zusätzlich auth_required (401, gar kein Credential), invalid_token (401, Credential unbekannt, widerrufen oder abgelaufen) und insufficient_scope (403, mit fehlendeScopes).
Webhooks: Events, Signatur, Retry
Ein Webhook erspart das Pollen. prekonto meldet sich, wenn ein Beleg durch die Pipeline ist. Angelegt wird ein Abo mit einer https-URL und mindestens einem Event; die Antwort enthält einmalig das Secret, mit dem signiert wird.
Abo anlegen
curl -s -X POST "https://prekonto.de/api/v1/mandanten/$M/webhooks" \
-H "Authorization: Bearer $PK" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.org/haken",
"events":["invoice.processed","buchungssatz.review_needed"]}'
# → 201 { "id": "…", "secret": "…", … } ← das Secret gibt es nur hierDie drei Ereignisse
| Event | Wann | Felder in daten |
|---|---|---|
| invoice.processed | Eine Rechnung ist durch die Pipeline. Geht IMMER heraus — auch bei `status: "fehler"`. | `invoiceId`, `uploadId`, `status`, `fehler`, `buchungssatzId`, `buchungssatzStatus` |
| buchungssatz.review_needed | Dabei entstand ein Buchungssatz im Status `review`. Das Ereignis, an dem eine Automatisierung eine Aufgabe erzeugen will. | `invoiceId`, `buchungssatzId`, `gruende`, `confidence`, `offeneChecks` |
| export.created | Ein EXTF-Stapel wurde erzeugt. | Kennzahlen des Stapels |
Dass invoice.processed auch bei status: "fehler" herausgeht, ist Absicht: Ein Empfänger, der nur Erfolge erfährt, kann einen hängenden Beleg nicht von einem gescheiterten unterscheiden und müsste doch pollen — genau das sollen Webhooks ersparen.
Nutzlast und Header
| Header | Inhalt |
|---|---|
| X-Prekonto-Signature | sha256=<HMAC-SHA256 über den Rohkörper> |
| X-Prekonto-Event | Event-Name — Routen ohne den Body zu parsen |
| X-Prekonto-Timestamp | Zeitpunkt (ISO-8601), für das Replay-Fenster |
| X-Prekonto-Delivery | Event-ID — dieselbe wie id im Körper, für Idempotenz |
| Content-Type | application/json |
| User-Agent | prekonto-webhooks/1 |
{
"id": "…", // Event-ID, für Idempotenz beim Empfänger
"event": "invoice.processed",
"mandantId": "…",
"zeitpunkt": "2026-08-06T09:12:44.000Z",
"daten": { "invoiceId": "…", "status": "kontiert",
"buchungssatzId": "…", "buchungssatzStatus": "review" }
}Signatur prüfen
// Node.js / Express — rohen Körper behalten, nicht geparst signieren
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post(
"/haken",
express.raw({ type: "application/json" }), // req.body ist ein Buffer
(req, res) => {
const erwartet =
"sha256=" +
createHmac("sha256", process.env.PREKONTO_WEBHOOK_SECRET)
.update(req.body) // die Rohbytes
.digest("hex");
const gegeben = req.header("X-Prekonto-Signature") ?? "";
const a = Buffer.from(erwartet, "utf8");
const b = Buffer.from(gegeben, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end(); // 4xx: wird NICHT wiederholt
}
const ereignis = JSON.parse(req.body.toString("utf8"));
// Sofort quittieren, dann selbst weiterarbeiten — das Zeitlimit ist 10 s.
res.status(202).end();
verarbeite(ereignis); // eigene Warteschlange
}
);Zustellversuche
- Zeitlimit je Versuch: 10 Sekunden. Der Empfänger soll quittieren, nicht verarbeiten — wer länger braucht, antwortet
202und arbeitet selbst weiter. - Höchstens drei Versuche: der erste, dann nach 1 Sekunde und nach 5 Sekunden.
- Wiederholt wird nur, wo eine Wiederholung Sinn hat: Netzwerkfehler, Zeitüberschreitung, 5xx und 429. Ein 4xx außer 429 wird nicht wiederholt — ein 401 oder 404 ist beim dritten Mal genauso 401.
- Nach 20 aufeinanderfolgenden Fehlversuchen wird das Abo stillgelegt und muss neu angelegt werden. Ohne diese Grenze zahlt jeder Beleg die Timeouts einer URL mit, die es nicht mehr gibt.
- Eine gescheiterte Zustellung lässt niemals die auslösende Handlung scheitern. Ein Webhook ist eine Benachrichtigung, kein Teil der Transaktion.
Das Zustellziel wird zweimal geprüft: beim Anlegen des Abos (sonst 400 url_nicht_erlaubt) und erneut unmittelbar vor jeder Zustellung. Das ist keine Doppelung — der DNS-Eintrag gehört dem Empfänger, und ein Hostname, der gestern öffentlich auflöste, kann heute auf eine interne Adresse zeigen. Zugestellt wird nur an öffentlich erreichbare Ziele; ein verbotenes Ziel wird nicht wiederholt, weil es kein vorübergehender Zustand ist.
MCP-Server
Unter POST /api/v1/mcp liegt ein Model-Context-Protocol-Server: derselbe Funktionsumfang, aber als Werkzeuge für KI-Clients statt als REST-Aufrufe. Er benutzt dieselbe Fachschicht und dieselbe Authentifizierung. Elf Werkzeuge, ihre Scopes, die Einbindung in Claude und die Grenzen stehen auf der MCP-Seite.
Grenzen und Obergrenzen
| Was | Grenze |
|---|---|
| Datei-Upload (Beleg und Stammdatenliste) | 40 MB → sonst 413 |
| Sätze je JSON-Stammdaten-Import | 20 000 |
| Rechnungsliste je Abfrage | 200 (neueste zuerst) |
| Buchungssatzliste je Abfrage | 500 (Belegdatum absteigend) |
| Aktive API-Keys je Konto | 20 |
| Redirect-URIs je OAuth-Client | 10 |
| Anweisungen je Mandant | 100 → sonst 409 limit_erreicht |
| Länge einer Anweisung | 2000 Zeichen |
| Webhook-Zustellung: Zeitlimit / Versuche | 10 s / 3 |
Nicht über die API möglich: einen Mandanten anlegen (die DATEV-Kopfdaten sind eine Angabe des Steuerberaters und werden in der Oberfläche erfasst) und Kreditoren automatisch erzeugen. Wer auf ein Konto korrigiert, zu dem es keinen Kreditor gibt, bekommt 422 kreditor_unbekannt — die Kreditorenliste gehört dem Mandanten, nicht dem Zufall eines Belegs.
Weiterlesen
- MCP-Server — die elf Werkzeuge und die Einbindung in Claude.
- Dokumentation — was hinter den Endpunkten passiert: Matching, Confidence, Lernen.
- DATEV-Export — das EXTF-Format und seine Fallstricke.
- Häufige Fragen — Datenschutz, Haftung, Abgrenzung.