Zum Inhalt
v26.3

DATEV-Export automatisieren (Steuerberater/Kanzleien)

Der DATEV-Export lässt sich vollständig unbeaufsichtigt automatisieren: einmalig konfigurieren, danach per Skript/Cronjob monatlich anstoßen, den Status abfragen und das Ergebnis herunterladen oder direkt per Mail an die Kanzlei senden lassen. Kein manueller Klick im Admin-Bereich nötig.

Endpoints

DatevController — Basis api/v1/datev:

Methode Endpoint Zweck
GET /config Aktuelle DATEV-Konfiguration lesen (Berater-/Mandanten-Nr., Konten, Kanzlei-Mail)
PUT /config Konfiguration setzen (Full Replace)
POST /export Export für einen Zeitraum anstoßen → liefert sessionId
GET /export/{sessionId}/status Fortschritt/Status pollen
GET /export/{sessionId}/download Fertige Export-Datei als ZIP herunterladen
POST /send Export für einen Zeitraum direkt per E-Mail an die Kanzlei senden

Admin-Rechte erforderlich

POST /export (und /send) verlangen die Rolle Admin oder SupportAdmin — ein normaler Kassenbenutzer bekommt 403 FORBIDDEN. Für die Automatisierung braucht der eingesetzte Account entsprechend eine dieser Rollen.

Konfiguration lesen/setzen

GET /api/v1/datev/config
Authorization: Bearer eyJ...
{
  "success": true,
  "data": {
    "id": "datevcfg_1",
    "mandatennummer": 12345,
    "beraterId": 6789,
    "kontoMwstNormal": 4400,
    "kontoMwstReduziert": 4300,
    "kontoMwstFrei": 4200,
    "srcKonto": 1600,
    "srcKontoKarte": 1360,
    "barKontoSrc": 1000,
    "barKontoTarget": 1600,
    "barOnly": false,
    "includeDocuments": true,
    "includeSpendings": true,
    "spendingKonto": 6300,
    "pfandKonto": 1590,
    "kundenguthabenKonto": 1701,
    "gutscheinKonto": 1702,
    "sachkontenLaenge": 4,
    "exportMode": 0,
    "stbEmail": "kanzlei@steuerberater.example",
    "stbName": "Steuerberatung Musterfrau",
    "zipPassword": "***",
    "byPaymentTyp": [],
    "wgrAccountMappings": []
  }
}

PUT /api/v1/datev/config erwartet denselben Feldsatz (ohne id/Zeitstempel) als kompletten Ersatz — fehlende Felder werden nicht gemerged, sondern auf den DTO-Default zurückgesetzt. Vor dem Schreiben also erst GET /config lesen, Werte übernehmen, gezielt ändern, dann PUT.

Export starten

POST /api/v1/datev/export
Authorization: Bearer eyJ...
Content-Type: application/json

{ "startDate": "2026-06-01T00:00:00Z", "endDate": "2026-06-30T23:59:59Z", "mode": 0 }
{ "success": true, "data": { "sessionId": "datev_a1b2c3", "isComplete": false, "progress": 0, "downloadUrl": null } }

mode ist ein int 06 (die im Admin-Bereich als Dropdown angebotenen Export-Varianten — z. B. mit/ohne Belege, Bar-only). Für ein reguläres monatliches EXTF-Paket reicht mode: 0.

Status pollen

GET /api/v1/datev/export/datev_a1b2c3/status
Authorization: Bearer eyJ...
{ "success": true, "data": { "sessionId": "datev_a1b2c3", "isComplete": true, "progress": 100, "error": null,
  "downloadUrl": "/api/v1/datev/export/datev_a1b2c3/download" } }

Unbekannte sessionId404 mit errorCode: "NOT_FOUND".

Herunterladen

GET /api/v1/datev/export/datev_a1b2c3/download
Authorization: Bearer eyJ...

Antwort: 200 OK, Content-Type: application/zip, Datei datev_export.zip (DATEV-EXTF-Format, optional mit Belegen laut includeDocuments, optional passwortgeschützt laut zipPassword).

Direkt an die Kanzlei senden

POST /api/v1/datev/send
Authorization: Bearer eyJ...
Content-Type: application/json

{ "startDate": "2026-06-01T00:00:00Z", "endDate": "2026-06-30T23:59:59Z", "mode": 0 }
{ "success": true, "data": true }

Verschickt den fertigen Export per E-Mail an die in config.stbEmail/stbName hinterlegte Kanzlei-Adresse — kein separater Download-Schritt nötig.

Rezept: Zeitraum exportieren und herunterladen

API="https://ihre-instanz.dikas.de/api/v1"
TOKEN="eyJ..."

# 1. Einmalig: Konfiguration setzen (siehe oben, hier ausgelassen)

# 2. Export für Juni 2026 anstoßen
SESSION=$(curl -s -X POST "$API/datev/export" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "startDate": "2026-06-01T00:00:00Z", "endDate": "2026-06-30T23:59:59Z", "mode": 0 }' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['sessionId'])")

# 3. Status pollen, bis fertig
while true; do
  STATUS=$(curl -s "$API/datev/export/$SESSION/status" -H "Authorization: Bearer $TOKEN")
  DONE=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['isComplete'])")
  [ "$DONE" = "True" ] && break
  sleep 5
done

# 4a. Herunterladen ...
curl -s "$API/datev/export/$SESSION/download" -H "Authorization: Bearer $TOKEN" -o "datev_2026-06.zip"

# 4b. ... ODER direkt an die Kanzlei senden
curl -s -X POST "$API/datev/send" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "startDate": "2026-06-01T00:00:00Z", "endDate": "2026-06-30T23:59:59Z", "mode": 0 }'

Copy-Paste Cron-Skript: automatischer Monatsexport

Läuft am 1. jedes Monats (z. B. 0 6 1 * * in Crontab), exportiert den kompletten Vormonat und sendet ihn per send an die Kanzlei. Meldet sich frisch an, pollt mit Timeout, wertet success/errorCode aus und setzt einen sinnvollen Exit-Code (für Monitoring/Alerting).

#!/usr/bin/env bash
# datev-monatsexport.sh — am 1. jedes Monats per Cron: Vormonat exportieren + an Kanzlei senden.
# Crontab-Beispiel: 0 6 1 * * /pfad/zu/datev-monatsexport.sh >> /var/log/datev-export.log 2>&1
set -euo pipefail

API="https://ihre-instanz.dikas.de/api/v1"
DIKAS_USER="${DIKAS_USER:?DIKAS_USER env var fehlt}"
DIKAS_PASSWORD="${DIKAS_PASSWORD:?DIKAS_PASSWORD env var fehlt}"
POLL_INTERVAL=5
POLL_TIMEOUT=300   # Sekunden, bevor der Export als hängengeblieben gilt

# Vormonat als [Anfang, Ende] in UTC ISO-8601 berechnen
START_DATE=$(date -u -d "$(date +%Y-%m-01) -1 month" +%Y-%m-%dT00:00:00Z)
END_DATE=$(date -u -d "$(date +%Y-%m-01) -1 second" +%Y-%m-%dT23:59:59Z)

log() { echo "[$(date -u +%FT%TZ)] $*"; }

json_get() {
  # json_get '<json>' '<data-key>' — liest ein Feld aus data{} per stdlib json (kein jq-Zwang).
  # WICHTIG: Die API serialisiert null-Felder explizit mit (kein WhenWritingNull) — sowohl
  # `data` selbst als auch das gesuchte Feld können JSON-`null` sein. Beides muss sauber auf
  # einen leeren String fallen, NICHT auf den Python-Literal-String "None" (der in bash mit
  # `[ -n "$X" ]` als NICHT-leer gilt und das Skript bei jedem gesunden Export abbrechen ließe).
  python3 -c "
import sys, json
obj = json.loads(sys.argv[1])
v = obj.get('data') or {}
v = v.get(sys.argv[2])
print('' if v is None else v)
" "$1" "$2"
}

json_top() {
  # json_top '<json>' '<top-level-key>' — liest TOP-LEVEL-Felder von ApiResponse<T>
  # ({success, data, errorCode, errorMessage}) — errorCode/errorMessage stehen NEBEN data,
  # nicht darin (bei einem Fehler ist data ohnehin null). Gleiche Null-Sicherheit wie json_get.
  python3 -c "
import sys, json
obj = json.loads(sys.argv[1])
v = obj.get(sys.argv[2])
print('' if v is None else v)
" "$1" "$2"
}

fail() {
  log "FEHLER: $*"
  exit 1
}

# 1. Login
LOGIN_RESPONSE=$(curl -sS -X POST "$API/auth/login" -H "Content-Type: application/json" \
  -d "{ \"username\": \"$DIKAS_USER\", \"password\": \"$DIKAS_PASSWORD\" }") || fail "Login-Request fehlgeschlagen (Netzwerk)"

SUCCESS=$(json_top "$LOGIN_RESPONSE" "success")
if [ "$SUCCESS" != "True" ]; then
  ERR_CODE=$(json_top "$LOGIN_RESPONSE" "errorCode")
  fail "Login abgelehnt (errorCode=$ERR_CODE)"
fi
ACCESS_TOKEN=$(json_get "$LOGIN_RESPONSE" "accessToken")
REFRESH_TOKEN=$(json_get "$LOGIN_RESPONSE" "refreshToken")
[ -n "$ACCESS_TOKEN" ] || fail "Kein accessToken in Login-Antwort"

log "Login OK. Exportiere Zeitraum $START_DATE bis $END_DATE."

# Hilfsfunktion: authentifizierter Request mit EINMALIGEM Refresh-Retry bei 401
api_call() {
  local method="$1" path="$2" body="${3:-}"
  local resp status
  if [ -n "$body" ]; then
    resp=$(curl -sS -w '\n%{http_code}' -X "$method" "$API$path" \
      -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" -d "$body")
  else
    resp=$(curl -sS -w '\n%{http_code}' -X "$method" "$API$path" \
      -H "Authorization: Bearer $ACCESS_TOKEN")
  fi
  status=$(echo "$resp" | tail -n1)
  body_out=$(echo "$resp" | sed '$d')

  if [ "$status" = "401" ]; then
    log "Access-Token abgelaufen — erneuere per Refresh-Token."
    REFRESH_RESPONSE=$(curl -sS -X POST "$API/auth/refresh" -H "Content-Type: application/json" \
      -d "{ \"refreshToken\": \"$REFRESH_TOKEN\" }") || fail "Refresh-Request fehlgeschlagen"
    if [ "$(json_top "$REFRESH_RESPONSE" "success")" != "True" ]; then
      fail "Refresh-Token ungültig/abgelaufen — Skript neu mit frischem Login starten."
    fi
    ACCESS_TOKEN=$(json_get "$REFRESH_RESPONSE" "accessToken")
    REFRESH_TOKEN=$(json_get "$REFRESH_RESPONSE" "refreshToken")

    # Einmal wiederholen mit neuem Token
    if [ -n "$body" ]; then
      resp=$(curl -sS -w '\n%{http_code}' -X "$method" "$API$path" \
        -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" -d "$body")
    else
      resp=$(curl -sS -w '\n%{http_code}' -X "$method" "$API$path" \
        -H "Authorization: Bearer $ACCESS_TOKEN")
    fi
    status=$(echo "$resp" | tail -n1)
    body_out=$(echo "$resp" | sed '$d')
  fi

  echo "$body_out"
  return 0
}

# 2. Export starten
EXPORT_BODY="{ \"startDate\": \"$START_DATE\", \"endDate\": \"$END_DATE\", \"mode\": 0 }"
EXPORT_RESPONSE=$(api_call POST /datev/export "$EXPORT_BODY")

if [ "$(json_top "$EXPORT_RESPONSE" "success")" != "True" ]; then
  ERR_CODE=$(json_top "$EXPORT_RESPONSE" "errorCode")
  ERR_MSG=$(json_top "$EXPORT_RESPONSE" "errorMessage")
  fail "Export-Start abgelehnt: $ERR_CODE$ERR_MSG"
fi

SESSION_ID=$(json_get "$EXPORT_RESPONSE" "sessionId")
[ -n "$SESSION_ID" ] || fail "Keine sessionId in Export-Antwort"
log "Export gestartet, sessionId=$SESSION_ID"

# 3. Status pollen mit Timeout
ELAPSED=0
while true; do
  STATUS_RESPONSE=$(api_call GET "/datev/export/$SESSION_ID/status")

  if [ "$(json_top "$STATUS_RESPONSE" "success")" != "True" ]; then
    ERR_CODE=$(json_top "$STATUS_RESPONSE" "errorCode")
    fail "Status-Abfrage fehlgeschlagen: $ERR_CODE"
  fi

  # Fertig-Erkennung ausschließlich über isComplete (verifiziert gegen DatevExportStatusResponse) —
  # NICHT über das optionale error-Feld, sonst würde ein gesunder, noch laufender Export nie
  # zwischenzeitlich ein "error" haben, aber falsche Priorisierung könnte ihn trotzdem stoppen.
  # error wird erst geprüft, NACHDEM isComplete=true ist (dann heißt es: fertig, aber fehlgeschlagen).
  IS_COMPLETE=$(json_get "$STATUS_RESPONSE" "isComplete")
  if [ "$IS_COMPLETE" = "True" ]; then
    EXPORT_ERROR=$(json_get "$STATUS_RESPONSE" "error")
    if [ -n "$EXPORT_ERROR" ]; then
      fail "DATEV-Export abgeschlossen, aber mit Fehler: $EXPORT_ERROR"
    fi
    log "Export abgeschlossen nach ${ELAPSED}s."
    break
  fi

  if [ "$ELAPSED" -ge "$POLL_TIMEOUT" ]; then
    fail "Timeout ($POLL_TIMEOUT s) — Export ist nicht rechtzeitig fertig geworden (sessionId=$SESSION_ID)."
  fi

  sleep "$POLL_INTERVAL"
  ELAPSED=$((ELAPSED + POLL_INTERVAL))
done

# 4. An die Kanzlei senden (Konfiguration muss stbEmail/stbName gesetzt haben)
SEND_RESPONSE=$(api_call POST /datev/send "$EXPORT_BODY")
if [ "$(json_top "$SEND_RESPONSE" "success")" != "True" ]; then
  ERR_CODE=$(json_top "$SEND_RESPONSE" "errorCode")
  ERR_MSG=$(json_top "$SEND_RESPONSE" "errorMessage")
  fail "Versand an Kanzlei fehlgeschlagen: $ERR_CODE$ERR_MSG"
fi

log "DATEV-Export für $START_DATE bis $END_DATE erfolgreich an die Kanzlei gesendet."
exit 0

Exit-Codes: 0 = erfolgreich exportiert und versendet, 1 = Abbruch mit Log-Zeile (Login, Export, Timeout, Versand) — geeignet für Cron-Mail-Benachrichtigung bei Nicht-Null-Exit.

Auth

api/v1/datev/* ist ausschließlich per JWT geschützt ([Authorize], Rollen Admin/SupportAdmin für export/send). Es gibt keinen API-Key-Shortcut: Die api-keys-Verwaltung (ApiKeysController) speist nur die Legacy-/rest/*-Kompatibilitätsschicht, nicht die api/v1-Controller. Für eine unbeaufsichtigte Automatisierung braucht das Skript also einen technischen Benutzer mit Admin-Rolle und muss den Login-/Refresh-Zyklus selbst durchführen (siehe Cron-Skript oben).

Siehe auch