Async Python client for the comdirect Banking and Brokerage API. Handles the OAuth2 + push-TAN login flow, refreshes tokens in the background, and gives you typed dataclasses for every read-only endpoint of the public comdirect REST API.
Heads up: This code is AI-assisted but has been reviewed and run against production. Every read-only method in this library was end-to-end verified against a real account on 2026-04-16 — see
COMDIRECT_API.mdfor the details, including a correctedremittanceInfoparser, live pagination limits, and verified Accept-header behaviour for document downloads.
- Full OAuth2 + TAN auth — 5-step flow, push-TAN polling, session activation.
- Automatic token refresh — background task refreshes 120s before expiry.
- Token persistence — optional atomic-write JSON file so restarts don't need a new TAN.
- Banking endpoints — balances + transactions (incl.
>500pagination helper). - Brokerage endpoints — depots, positions, depot transactions, orders, instrument lookup.
- Messages endpoints — PostBox document list + binary download (PDF/HTML).
- Reports endpoint —
allbalancescross-product view (accounts, depots, cards, loans). - Verified
remittanceInfoparser — 37/35-char windows, SEPA label extraction, whitespace normalization. - Typed dataclasses —
Account,AccountBalance,Transaction,Depot,DepotPosition,Order,Document,ProductBalance, etc. - Typed errors — specific exceptions for auth / TAN timeout / 422 / 500 / account-not-found / network timeout.
- Configurable TAN timing — if your phone approval takes longer than 60s.
- comdirect REST API PDF (German, April 2020)
- comdirect REST API Swagger
COMDIRECT_API.md— our verified notes, including pitfalls not in the official docs (remittance parsing, pagination rules, 406 on documents, etc.)
pip install comdirect-clientRequires Python 3.9+. Sole runtime dependency is httpx.
For development:
git clone https://github.com/mcdax/comdirect-python-library.git
cd comdirect-python-library
poetry install --with devimportasyncioimportosfromcomdirect_clientimportComdirectClientasyncdefmain():
asyncwithComdirectClient(
client_id=os.environ["COMDIRECT_CLIENT_ID"],
client_secret=os.environ["COMDIRECT_CLIENT_SECRET"],
username=os.environ["COMDIRECT_USERNAME"],
password=os.environ["COMDIRECT_PASSWORD"],
token_storage_path=os.path.expanduser("~/.comdirect_tokens.json"),
) asclient:
# First run: triggers push-TAN on your phone (approve within 60s).# Later runs: reuses tokens from disk, no TAN needed.ifnotclient.is_authenticated():
awaitclient.authenticate()
balances=awaitclient.get_account_balances()
forbinbalances:
print(f"{b.account.accountDisplayId} ({b.account.accountType.key}): "f"{b.balance.value}{b.balance.unit}")
ifbalances:
txs=awaitclient.get_transactions(balances[0].accountId, paging_count=20)
fortxintxs[:5]:
date=tx.bookingDateortx.valutaDateamt=f"{tx.amount.value}{tx.amount.unit}"iftx.amountelse"n/a"kind=tx.transactionType.keyiftx.transactionTypeelse""print(f" {date}{amt:>15}{kind:>20}{' / '.join(tx.remittance_lines[:1])}")
asyncio.run(main())ComdirectClient(
client_id: str,
client_secret: str,
username: str,
password: str,
*,
base_url: str="https://api.comdirect.de",
token_storage_path: str|None=None,
reauth_callback: Callable[[str], None|Awaitable[None]] |None=None,
tan_status_callback: Callable[[str, dict], None] |None=None,
token_refresh_threshold_seconds: int=120,
timeout_seconds: float=30.0,
tan_timeout_seconds: int=60,
tan_poll_interval_seconds: float=1.0,
)- Use as an async context manager (
async with ComdirectClient(...) as client:), or manage the lifecycle manually and callawait client.close()on shutdown. token_storage_pathenables persistent login across process restarts. The file is written atomically with0600permissions.reauth_callback(reason)is invoked when the background refresh fails. Sync or async.tan_status_callback(status, data)fires at key points of the push-TAN poll — status is one of"requested","pending","approved","timeout".tan_timeout_seconds/tan_poll_interval_secondslet you widen the push-TAN wait window if users are slow to approve.
| Method | What it does |
|---|---|
await authenticate() | Runs the full 5-step flow. Triggers push-TAN. Raises AuthenticationError, TANTimeoutError, SessionActivationError. Only push-TAN (P_TAN_PUSH) is supported headlessly; photo-TAN / SMS-TAN raise AuthenticationError with a clear message. |
await refresh_token() | Refreshes the access + refresh token pair. Called automatically by the background task. Returns False if the refresh fails (invokes the reauth callback). |
is_authenticated() | True if tokens are loaded (even if expired — the refresh token may still be valid). |
get_token_expiry() | The current access token's expiry as a UTC-aware datetime. |
register_reauth_callback(cb) / register_tan_status_callback(cb) | Replace the callbacks after construction. |
balances=awaitclient.get_account_balances()
txs=awaitclient.get_transactions(
account_id,
transaction_state="BOOKED", # BOOKED / NOTBOOKED / BOTHtransaction_direction="DEBIT", # CREDIT / DEBIT / CREDIT_AND_DEBITmin_booking_date="2025-06-01", # ISO date; also unlocks history > 6 monthsmax_booking_date="2025-12-31", # ISO datepaging_count=500, # server cap is 500paging_first=0, # > 0 requires transactionState="BOOKED"
)
all_booked=awaitclient.fetch_all_booked_transactions(
account_id,
min_booking_date="2020-01-01",
)
# Walks paging-first in 500-sized chunks with transactionState=BOOKED.# This is the only way to retrieve > 500 transactions per account.Full discussion of pagination rules (why paging-first requires BOOKED, why the default response is capped, how matches works) is in COMDIRECT_API.md.
depots=awaitclient.get_depots()
depot=depots[0]
positions=awaitclient.get_depot_positions(depot.depotId)
position=awaitclient.get_depot_position(depot.depotId, position.positionId)
dtxs=awaitclient.get_depot_transactions(depot.depotId, min_booking_date="-30d")
orders=awaitclient.get_depot_orders(depot.depotId,
min_creation_timestamp="2026-01-01T00:00:00,00")
order=awaitclient.get_order(order_id)
instrument=awaitclient.get_instrument("US0378331005") # WKN/ISIN/mnemonic/UUIDDate/timestamp format conventions differ across brokerage endpoints — see the table in COMDIRECT_API.md.
Order write operations (place / modify / cancel / prevalidation / cost indication / quote flow) are deliberately not wrapped — they require per-transaction TAN re-approval. Open an issue or a PR if you need them.
docs=awaitclient.get_documents(min_document_date="2025-01-01", paging_count=50)
# Download one document (PDF or HTML) as bytes.content, mime_type=awaitclient.get_document_content(docs[0].documentId)
# For documents with a predocument (Vorschaltseite):ifdocs[0].documentMetaDataanddocs[0].documentMetaData.predocumentExists:
pre_content, pre_mime=awaitclient.get_document_content(
docs[0].documentId, predocument=True
)The library sends Accept: application/pdf, text/html for document downloads. Sending Accept: application/json triggers 406 Not Acceptable from the server — that's an API design choice, not a library bug (verified against production on 2026-04-16).
all_balances=awaitclient.get_all_balances()
# Returns ProductBalance entries across accounts, depots, cards, loans,# and fixed-term savings. Each entry's .balance is kept as a raw dict# because the shape depends on .productType — branch on productType and# index into balance yourself.forpinall_balances:
print(p.productType, p.productId, p.balance)All response bodies are parsed into dataclasses with from_dict classmethods. Monetary amounts use Decimal, dates use datetime.date (only for Transaction.bookingDate; everything else stays as ISO strings).
Account— master data (accountId,accountDisplayId,currency,clientId,accountType: EnumText, optionaliban,bic,creditLimit)AccountBalance—accountId,account: Account,balance: AmountValue,balanceEUR,availableCashAmount,availableCashAmountEURTransaction—bookingStatus,reference,valutaDate,newTransaction, optionalamount,transactionType,remittanceInfo,bookingDate,remitter,debtor,creditor,endToEndReference,directDebitCreditorId,directDebitMandateId.remittance_lines— Buchungstext as rendered by the banking web UI (one entry per on-screen line).remittance— fullParsedRemittancewithbuchungstext_lines,end_to_end_reference,mandate_reference,creditor_idextracted from the SEPA labels embedded inremittanceInfo
Depot,DepotPosition,DepotTransaction,Order,Execution,Instrument,Price
Document,DocumentMetadata
ProductBalance(with rawbalancedict — shape varies byproductType)
AmountValue { value: Decimal, unit: str }EnumText { key: str, text: str }— match onkey, thetextis a German-ish display stringAccountInformation { holderName, iban?, bic? }
The remittanceInfo field is a flat string with fixed-width windows — not newline-separated as the April-2020 PDF claims. The library's parser is verified against live data and the banking web UI. You generally shouldn't need to parse it yourself, but if you want to:
fromcomdirect_clientimportparse_remittance_inforesult=parse_remittance_info(tx.remittanceInfo, tx.bookingStatus)
result.buchungstext_lines# list[str], each one chunk as shown in the web UIresult.end_to_end_reference# from the 'End-to-End-Ref.:' SEPA labelresult.mandate_reference# from 'CORE / Mandatsref.:'result.creditor_id# from 'Gläubiger-ID:'Full rules (37 vs 35 char windows, whitespace collapse, SEPA label extraction, verified test cases) are in COMDIRECT_API.md.
After authenticate() or on startup with restored tokens, a background task refreshes the access token ~120 seconds before expiry. You don't need to call refresh_token() yourself in normal usage.
asyncwithComdirectClient(
...,
token_storage_path="/secure/dir/comdirect_tokens.json",
) asclient:
# On first run, triggers a TAN; afterwards, reuses the stored refresh# token across restarts so you skip the TAN approval.ifnotclient.is_authenticated():
awaitclient.authenticate()The token file is JSON with three fields (access_token, refresh_token, token_expiry). Writes are atomic (temp file + os.replace) and the file is created with 0600 permissions from the first byte — even if you kill the process mid-write, you won't end up with a corrupt file or world-readable tokens.
On a failed refresh, the file is cleared automatically to prevent zombie-login states across restarts.
asyncdefon_reauth(reason: str) ->None:
# reason is one of:# "token_refresh_failed" — explicit refresh call failed# "automatic_refresh_failed" — background refresh task gave upawaitnotify_slack(f"comdirect reauth required: {reason}")
asyncwithComdirectClient(..., reauth_callback=on_reauth) asclient:
...Both sync and async callbacks are supported.
All exceptions inherit from ComdirectAPIError:
| Exception | Raised when |
|---|---|
AuthenticationError | Bad credentials, unsupported TAN type, any auth-flow HTTP error |
TANTimeoutError | Push-TAN not approved within tan_timeout_seconds |
SessionActivationError | PATCH session activation failed (usually wrong header format) |
TokenExpiredError | Access token expired AND refresh failed |
NetworkTimeoutError | httpx.TimeoutException on any request |
AccountNotFoundError | 404 on any banking / brokerage endpoint |
ValidationError | 422 from the API (bad parameter values, unsupported paging, etc.) |
ServerError | 500 from the API |
TokenStorageError | Token file missing, corrupt, unreadable or unwritable |
The API returns structured BusinessMessage payloads on 422 errors (body + x-http-response-info header). Those are currently only surfaced via the exception message; see COMDIRECT_API.md for the full shape if you need to parse them yourself from a caught ValidationError.
Keep one ComdirectClient alive for the lifetime of your application. Destroying and recreating it each operation cancels the background refresh task and forces a new TAN every ~10 minutes.
fromcontextlibimportasynccontextmanagerfromfastapiimportFastAPIfromcomdirect_clientimportComdirectClientclient: ComdirectClient|None=None@asynccontextmanagerasyncdeflifespan(app: FastAPI):
globalclientclient=ComdirectClient(
client_id=settings.COMDIRECT_CLIENT_ID,
client_secret=settings.COMDIRECT_CLIENT_SECRET,
username=settings.COMDIRECT_USERNAME,
password=settings.COMDIRECT_PASSWORD,
token_storage_path="/app/data/comdirect_tokens.json",
)
ifnotclient.is_authenticated():
awaitclient.authenticate()
yieldawaitclient.close()
app=FastAPI(lifespan=lifespan)
@app.get("/balances")asyncdefbalances():
returnawaitclient.get_account_balances()asyncdefmain():
asyncwithComdirectClient(
...,
token_storage_path="tokens.json",
reauth_callback=lambdareason: print(f"reauth needed: {reason}"),
) asclient:
ifnotclient.is_authenticated():
awaitclient.authenticate()
whileTrue:
balances=awaitclient.get_account_balances()
print(f"balance: {balances[0].balance.value}{balances[0].balance.unit}")
awaitasyncio.sleep(3600)
asyncio.run(main())poetry install --with dev
poetry run pytest
poetry run mypy comdirect_client
poetry run black --check comdirect_client tests examples
poetry run ruff check comdirect_client tests examples96 unit tests covering auth state, token refresh, token storage, API parameter building, error mapping, remittance parsing with real captured samples, and the brokerage/messages/reports data classes. CI runs them on Python 3.9/3.10/3.11/3.12.
Live testing against the real comdirect API needs credentials in the environment (see the test.sh template). A full read-only validation run was recorded in the commit history and re-executed on every significant change.
comdirect_client/
├── __init__.py — public exports
├── client.py — ComdirectClient (facade + state + background refresh)
├── http_api.py — low-level HTTP per endpoint (one function each)
├── auth_flow.py — the 5-step auth as a plain async function
├── remittance.py — verified 37/35-char remittanceInfo parser
├── models.py — banking data classes + common types
├── models_brokerage.py — Depot, DepotPosition, DepotTransaction, Order, ...
├── models_messages.py — Document, DocumentMetadata
├── models_reports.py — ProductBalance
├── token_storage.py — atomic JSON token file
└── exceptions.py — typed errors
tests/ — 96 unit tests (no network)
examples/basic_usage.py — end-to-end demo with real auth
COMDIRECT_API.md — verified API notes, pitfalls, remittance spec
Files are organised by concern — client.py is the only stateful piece; everything else is either pure functions or pure data classes.
MIT-licensed. This is an unofficial client — not affiliated with Comdirect Bank AG. Use at your own risk, especially for order placement operations should you extend the library to cover those.