Automating the DATEV Export (Tax Advisors/Firms)¶
The DATEV export can be fully automated, unattended: configure it once, then trigger it monthly via a script/cron job, poll the status, and download the result or have it sent directly by email to the firm. No manual click in the admin area is required.
Endpoints¶
DatevController — base api/v1/datev:
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/config |
Read current DATEV configuration (advisor/client number, accounts, firm email) |
PUT |
/config |
Set configuration (full replace) |
POST |
/export |
Trigger an export for a period → returns sessionId |
GET |
/export/{sessionId}/status |
Poll progress/status |
GET |
/export/{sessionId}/download |
Download the finished export file as ZIP |
POST |
/send |
Send the export for a period directly by email to the firm |
Admin rights required
POST /export (and /send) require the Admin or SupportAdmin role — a regular
POS user gets 403 FORBIDDEN. For automation, the account used therefore needs one of
these roles.
Reading/Setting the Configuration¶
{
"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 expects the same set of fields (without id/timestamp) as a
complete replacement — missing fields are not merged but reset to the DTO default. Before
writing, first read GET /config, take over the values, change them selectively, then PUT.
Starting an Export¶
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 is an int 0–6 (the export variants offered as a dropdown in the admin area —
e.g. with/without receipts, cash-only). For a regular monthly EXTF package, mode: 0 is sufficient.
Polling the Status¶
{ "success": true, "data": { "sessionId": "datev_a1b2c3", "isComplete": true, "progress": 100, "error": null,
"downloadUrl": "/api/v1/datev/export/datev_a1b2c3/download" } }
Unknown sessionId → 404 with errorCode: "NOT_FOUND".
Downloading¶
Response: 200 OK, Content-Type: application/zip, file datev_export.zip (DATEV EXTF format,
optionally with receipts per includeDocuments, optionally password-protected per zipPassword).
Sending Directly to the Firm¶
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 }
Sends the finished export by email to the firm address stored in config.stbEmail/stbName —
no separate download step required.
Recipe: Export and Download a Period¶
API="https://ihre-instanz.dikas.de/api/v1"
TOKEN="eyJ..."
# 1. One-time: set the configuration (see above, omitted here)
# 2. Trigger the export for June 2026
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. Poll the status until done
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. Download ...
curl -s "$API/datev/export/$SESSION/download" -H "Authorization: Bearer $TOKEN" -o "datev_2026-06.zip"
# 4b. ... OR send directly to the firm
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 Script: Automatic Monthly Export¶
Runs on the 1st of every month (e.g. 0 6 1 * * in crontab), exports the entire previous
month, and sends it to the firm via send. It logs in fresh, polls with a timeout, evaluates
success/errorCode, and sets a meaningful exit code (for monitoring/alerting).
#!/usr/bin/env bash
# datev-monatsexport.sh — on the 1st of every month via cron: export the previous month + send to the firm.
# Crontab example: 0 6 1 * * /path/to/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 missing}"
DIKAS_PASSWORD="${DIKAS_PASSWORD:?DIKAS_PASSWORD env var missing}"
POLL_INTERVAL=5
POLL_TIMEOUT=300 # seconds before the export is considered stuck
# Compute the previous month as [start, end] in UTC ISO-8601
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>' — reads a field from data{} via the stdlib json module (no jq required).
# IMPORTANT: The API explicitly serializes null fields (no WhenWritingNull) — both `data`
# itself and the requested field can be JSON `null`. Both cases must fall through cleanly to
# an empty string, NOT to the Python literal string "None" (which bash treats as NON-empty
# with `[ -n "$X" ]` and would abort the script on every healthy export).
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>' — reads TOP-LEVEL fields of ApiResponse<T>
# ({success, data, errorCode, errorMessage}) — errorCode/errorMessage sit ALONGSIDE data,
# not inside it (on an error, data is null anyway). Same null-safety as 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 "ERROR: $*"
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 failed (network)"
SUCCESS=$(json_top "$LOGIN_RESPONSE" "success")
if [ "$SUCCESS" != "True" ]; then
ERR_CODE=$(json_top "$LOGIN_RESPONSE" "errorCode")
fail "Login rejected (errorCode=$ERR_CODE)"
fi
ACCESS_TOKEN=$(json_get "$LOGIN_RESPONSE" "accessToken")
REFRESH_TOKEN=$(json_get "$LOGIN_RESPONSE" "refreshToken")
[ -n "$ACCESS_TOKEN" ] || fail "No accessToken in login response"
log "Login OK. Exporting period $START_DATE to $END_DATE."
# Helper function: authenticated request with a SINGLE refresh retry on 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 expired — renewing via refresh token."
REFRESH_RESPONSE=$(curl -sS -X POST "$API/auth/refresh" -H "Content-Type: application/json" \
-d "{ \"refreshToken\": \"$REFRESH_TOKEN\" }") || fail "Refresh request failed"
if [ "$(json_top "$REFRESH_RESPONSE" "success")" != "True" ]; then
fail "Refresh token invalid/expired — restart the script with a fresh login."
fi
ACCESS_TOKEN=$(json_get "$REFRESH_RESPONSE" "accessToken")
REFRESH_TOKEN=$(json_get "$REFRESH_RESPONSE" "refreshToken")
# Retry once with the new 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. Start the export
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 rejected: $ERR_CODE — $ERR_MSG"
fi
SESSION_ID=$(json_get "$EXPORT_RESPONSE" "sessionId")
[ -n "$SESSION_ID" ] || fail "No sessionId in export response"
log "Export started, sessionId=$SESSION_ID"
# 3. Poll the status with a 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 query failed: $ERR_CODE"
fi
# Completion is detected exclusively via isComplete (verified against DatevExportStatusResponse) —
# NOT via the optional error field, otherwise a healthy, still-running export would never have
# an "error" in between, but wrong prioritization could still stop it anyway.
# error is only checked AFTER isComplete=true (at that point it means: done, but failed).
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 finished, but with an error: $EXPORT_ERROR"
fi
log "Export finished after ${ELAPSED}s."
break
fi
if [ "$ELAPSED" -ge "$POLL_TIMEOUT" ]; then
fail "Timeout ($POLL_TIMEOUT s) — export did not finish in time (sessionId=$SESSION_ID)."
fi
sleep "$POLL_INTERVAL"
ELAPSED=$((ELAPSED + POLL_INTERVAL))
done
# 4. Send to the firm (configuration must have stbEmail/stbName set)
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 "Sending to the firm failed: $ERR_CODE — $ERR_MSG"
fi
log "DATEV export for $START_DATE to $END_DATE successfully sent to the firm."
exit 0
Exit codes: 0 = exported and sent successfully, 1 = aborted with a log line (login, export,
timeout, sending) — suitable for cron mail notification on non-zero exit.
Auth¶
api/v1/datev/* is protected exclusively via JWT ([Authorize], roles Admin/SupportAdmin
for export/send). There is no API-key shortcut: the api-keys management
(ApiKeysController) only feeds the legacy /rest/* compatibility layer, not the
api/v1 controllers. For unattended automation, the script therefore needs a technical user
with the Admin role and must perform the login/refresh cycle itself (see the cron script above).
See Also¶
- DATEV Export (User Manual) — the manual operation in the admin area.
- Tax Advisors & Accounting Firms — the firm portal as an alternative/complement to the API export.