prekontoDokumentationAPIMCPFAQBlog

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.

Asynchron, wo es nötig ist. Ein Rechnungs-Upload antwortet mit 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.

OAuth-Endpunkte und Fristen. Clients registriert der Nutzer selbst im angemeldeten Bereich unter „API-Keys“.
WasWert
Autorisierunghttps://prekonto.de/api/oauth/authorize
Token und Refreshhttps://prekonto.de/api/oauth/token
Widerrufhttps://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, Lebensdauerpat_… · 1 Stunde
Refresh-Token-Präfix, Lebensdauerprt_… · 180 Tage
Authorization-Code-Präfix, Lebensdauerpac_… · 5 Minuten
PKCE-VerfahrenS256 (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.

Discovery statt Konfiguration. Bei 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.

ScopeBeschriftung im Zustimmungs-DialogWas das praktisch heißt
stammdaten:writeKreditoren- und Gegenkontenlisten hochladen und aktualisierenDarf Listen einliefern und Webhook-Abos verwalten. Manuell gepflegte Konten werden dabei nicht überschrieben, gelöscht wird nie.
invoices:writeRechnungen zur Verarbeitung einliefernDarf Belege hochladen. Enthält ausdrücklich KEIN Leserecht — ein Einlieferer muss die Buchungsvorschläge nicht sehen können.
buchungssaetze:readBuchungssätze und ihren Status lesenDer Lese-Scope für alles Fachliche: Buchungssätze, Rechnungen samt Verarbeitungsstand, Kreditoren- und Gegenkontenliste.
buchungssaetze:writeBuchungssätze korrigieren und bestätigenDarf Kontierungen ändern und freigeben. Freigegebene Sätze gehen in den nächsten Export — das ist der Scope mit den weitesten Folgen.
export:readDATEV-Buchungsstapel (EXTF) herunterladenDarf 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.

MethodePfadErfolgScopeWas sie tut
GET/api/v1200jeder ScopeVersion, Issuer, Discovery-URLs und die effektiven Scopes des Credentials. Der Endpunkt zum Prüfen eines Keys.
operationId: getApiInfo
GET/api/v1/openapi.json200keinerDas 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.

MethodePfadErfolgScopeWas sie tut
GET/api/v1/mandanten200jeder ScopeAlle 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“.

MethodePfadErfolgScopeWas sie tut
PUT/api/v1/mandanten/{id}/kreditoren200stammdaten:writeUpsert ü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}/kreditoren200buchungssaetze:readKreditoren nach Kontonummer sortiert, optional gefiltert über `suche` (Name, Kontonummer oder Namens-Normalform).
operationId: listKreditoren
PUT/api/v1/mandanten/{id}/gegenkonten200stammdaten:writeWie 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}/gegenkonten200buchungssaetze:readDie 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.

MethodePfadErfolgScopeWas sie tut
POST/api/v1/mandanten/{id}/invoices202invoices:writeNimmt 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}/invoices200buchungssaetze:readRechnungen, neueste zuerst (höchstens 200), optional gefiltert über `status` (`neu`, `extrahiert`, `kontiert`, `fehler`).
operationId: listInvoices
GET/api/v1/mandanten/{id}/invoices/{invoiceId}200buchungssaetze:readDie `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`.

MethodePfadErfolgScopeWas sie tut
GET/api/v1/mandanten/{id}/buchungssaetze200buchungssaetze:readNach Belegdatum absteigend (höchstens 500). Filter: `status` sowie `von`/`bis` über das Belegdatum, jeweils inklusive.
operationId: listBookingRecords
GET/api/v1/mandanten/{id}/buchungssaetze/{satzId}200buchungssaetze:readEin 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}200buchungssaetze:writeKorrigiert 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}/bestaetigen200buchungssaetze:writeGibt 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.

MethodePfadErfolgScopeWas sie tut
POST/api/v1/mandanten/{id}/exports201export:readErzeugt 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}/exports200export:readErzeugte Stapel, neueste zuerst — jeder mit seinem Schreibprotokoll (gekürzte Texte, ersetzte Zeichen).
operationId: listExports
GET/api/v1/mandanten/{id}/exports/{batchId}/download200export:readDie 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.

MethodePfadErfolgScopeWas sie tut
GET/api/v1/mandanten/{id}/webhooks200stammdaten:writeDie Abos des Mandanten — ohne Signatur-Secret. Das gibt es nur einmal, beim Anlegen.
operationId: listWebhooks
POST/api/v1/mandanten/{id}/webhooks201stammdaten:writeLegt 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}204stammdaten:writeLö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.

MethodePfadErfolgScopeWas sie tut
POST/api/v1/mcp200jeder ScopeJSON-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.csv
Nicht nach UTF-8 umkodieren. Der Stapel wird in ISO-8859-1 mit Semikolon und CRLF erzeugt und muss byteweise so bei DATEV ankommen. Eine Pipeline, die „zur Sicherheit“ auf UTF-8 normalisiert, liefert einen Stapel mit kaputten Umlauten.

Der 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.

StatusBedeutungWas der Aufrufer tun muss
400Die Eingabe ist unbrauchbar — entscheidbar ohne die Stammdaten des Mandanten: Pflichtfeld fehlt, Zeitraum verdreht, unbekannter Statuswert, gesperrtes Korrekturfeld.Anderen Request schicken.
422Die 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.
409Der Zustand passt nicht: kein exportierbarer Buchungssatz im Zeitraum, Obergrenze erreicht.Eine andere Handlung vornehmen — nicht andere Werte schicken.
404Nicht gefunden oder Mandant eines fremden Kontos. Beides ist absichtlich nicht unterscheidbar: fremde Existenz wird nicht bestätigt.Mandanten-ID und Konto prüfen.
410Der 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.

codeStatusFall
validation_error400Feldvalidierung; Details in `feldFehler`.
status_unbekannt400Unbekannter Statuswert in einem Filter.
feld_nicht_korrigierbar400Ein Feld der Korrektur ist gesperrt (`umsatzCent`, `belegdatum`, `confidence`, `checks`).
kreditor_unbekannt422Zum korrigierten `konto` gibt es keinen Kreditor des Mandanten. Kreditoren werden nie automatisch angelegt.
gegenkonto_unbekannt422Das Konto steht nicht in der Gegenkontenliste. Gültige Werte über `listGegenkonten`.
buchung_ungueltig422Widerspruch zu den Stammdaten (Kontolänge, BU-Schlüssel auf Automatikkonto); Einzelmeldungen in `probleme`.
zeitraum_ausserhalb_wirtschaftsjahr422Der Export-Zeitraum verlässt das Wirtschaftsjahr; die Antwort nennt `wjBeginn` und `wjEnde`.
keine_saetze409Kein exportierbarer Buchungssatz im Zeitraum.
limit_erreicht409Eine 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_verschwunden410Die Exportdatei liegt nicht mehr in der Ablage.
not_found404Nicht gefunden oder fremder Mandant.
url_nicht_erlaubt400Die 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 hier

Die drei Ereignisse

EventWannFelder in daten
invoice.processedEine Rechnung ist durch die Pipeline. Geht IMMER heraus — auch bei `status: "fehler"`.`invoiceId`, `uploadId`, `status`, `fehler`, `buchungssatzId`, `buchungssatzStatus`
buchungssatz.review_neededDabei entstand ein Buchungssatz im Status `review`. Das Ereignis, an dem eine Automatisierung eine Aufgabe erzeugen will.`invoiceId`, `buchungssatzId`, `gruende`, `confidence`, `offeneChecks`
export.createdEin 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

HeaderInhalt
X-Prekonto-Signaturesha256=<HMAC-SHA256 über den Rohkörper>
X-Prekonto-EventEvent-Name — Routen ohne den Body zu parsen
X-Prekonto-TimestampZeitpunkt (ISO-8601), für das Replay-Fenster
X-Prekonto-DeliveryEvent-ID — dieselbe wie id im Körper, für Idempotenz
Content-Typeapplication/json
User-Agentprekonto-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

Über die empfangenen Bytes rechnen, nicht über neu serialisiertes JSON. Schon eine andere Schlüsselreihenfolge ändert den HMAC. prekonto serialisiert den Körper genau einmal und signiert denselben String, der gesendet wird — der Empfänger muss dasselbe tun.
// 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

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

API-Keys, OAuth-Clients und Anweisungen werden in der Oberfläche gepflegt und haben keinen v1-Endpunkt; ihre Grenzen stehen hier, weil sie begrenzen, womit und wie oft ein v1-Aufruf arbeiten kann.
WasGrenze
Datei-Upload (Beleg und Stammdatenliste)40 MB → sonst 413
Sätze je JSON-Stammdaten-Import20 000
Rechnungsliste je Abfrage200 (neueste zuerst)
Buchungssatzliste je Abfrage500 (Belegdatum absteigend)
Aktive API-Keys je Konto20
Redirect-URIs je OAuth-Client10
Anweisungen je Mandant100 → sonst 409 limit_erreicht
Länge einer Anweisung2000 Zeichen
Webhook-Zustellung: Zeitlimit / Versuche10 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.