portald Operator Guide
Operator guide for portald, the customer-portal read-model gateway: one REST API over Lastgang, invoices, ledger and supply status, plus §41 EnWG self-service.
On this page 10 sections
portald is a stateless read-model gateway that aggregates the LF back-end
services into one customer-facing REST API, and proxies the § 41 EnWG
self-service writes to the services that own them.
It serves the LF (Lieferant) role; every route is keyed on a Marktlokation (MaLo).
graph LR
customer["Customer app<br/>(mobile / web)"]
portald["portald :9480<br/>(this service)"]
vertragd["vertragd :9780<br/>customer ↔ MaLo · contracts"]
edmd["edmd :8380<br/>Lastgang"]
billingd["billingd :9280<br/>invoices · XRechnung"]
accountingd["accountingd :9380<br/>ledger · SEPA"]
marktd["marktd :8180<br/>VersorgungsStatus"]
einsd["einsd :9180<br/>EEG settlement"]
outputd["outputd :9880<br/>issued documents"]
customer -->|"GET /portal/{malo_id}/…<br/>Bearer JWT"| portald
portald -->|"1. authenticate?malo_id=…<br/>(customer token forwarded)"| vertragd
portald -->|"2. read"| edmd
portald --> billingd
portald --> accountingd
portald --> marktd
portald --> einsd
portald --> outputd
Port: :9480
Design principles
- Stateless. No database, no cache, no session store. Every response is assembled from the authoritative services on the request path.
- No domain policy. Notice periods, tariff rules, IBAN validation and
invoice rendering belong to the services that own them.
portaldvalidates request shape and relays their verdicts unchanged. - Degrade a tile, not the screen. On the dashboard, a field is
nullwhen its upstream is unconfigured or has no data.
Authorization
portald verifies no tokens and holds no customer↔MaLo map. It forwards the
customer's Authorization: Bearer header to
vertragd GET /api/v1/kunden/authenticate?malo_id=… — the owner of the OIDC
verifier, the customer record and the mapping — and relays the verdict. Its own
service credential rides as X-Api-Key, never as a second Authorization
header.
vertragd answers | portald answers |
|---|---|
2xx with a kunden_id | the request proceeds |
401 or 404, or no bearer token on the request | 401 |
403 | 403 |
unreachable, 5xx, or 2xx without a kunden_id | 503 |
Every customer-scoped handler takes the PortalAuthCtx only auth::authorize
returns; tests/it/authorization_guard.rs fails if any handler answers a
refusing vertragd with upstream data. …/invoices/{record_id}/download also
compares the record's malo_id with the
authorised one. Starting without vertragd_url is refused unless
allow_insecure_no_auth = true.
Endpoints
All paths are prefixed /api/v1/portal/{malo_id}.
| Method | Path | Upstream |
|---|---|---|
GET | /dashboard | marktd · accountingd · billingd · edmd — five reads, concurrently |
GET | /lastgang?from=&to= | edmd — BO4E Lastgang array |
GET | /invoices?limit=&outcome= | billingd — page size defaults to 24, clamped to 1–100 |
GET | /invoices/{record_id}/download | billingd — the EN 16931 CII XML of the stored model (GET /billing/{id}/xrechnung); BT-24 declares the XRechnung CIUS only on the B2G path |
GET | /dokumente?kind=&limit= | outputd — the document inbox: what was issued and sent. kind is an allowlist (INVOICE, MAHNUNG, PREISANPASSUNG); page size defaults to 50, clamped to 1–200 |
GET | /dokumente/{document_id} | outputd — the bytes as issued; opening it records the portal read receipt |
GET | /balance | accountingd — open-items balance |
GET | /kontoauszug?from=&to= | accountingd — account statement (§ 666 BGB); both bounds together scope it to a period and open it at that period's balance |
GET | /vorauszahlung | accountingd — Abschlag schedule (§ 40 Abs. 1 EnWG) |
GET | /eeg | einsd — plants + settlements |
GET | /versorgung | marktd — supply state |
GET | /vertrag | vertragd — active supply contract |
GET | /kuendigungsfrist | vertragd — reachable end dates per reason |
POST | /tarifwechsel | vertragd |
POST | /kuendigen | vertragd |
GET | /kontakt | vertragd — DSGVO Art. 15; read it before a PUT, which replaces the Geschaeftspartner whole. Projected: operator notes and the ERP key are left out |
PUT | /kontakt | vertragd — DSGVO Art. 16. geschaeftspartner crosses the BO4E gate here, so a mistyped enum is a 422 naming the field |
PUT | /sepa | accountingd |
/health/live, /health/ready and /metrics come from the service runner.
Failures
Every failure is the service runtime's problem body. An upstream that fails —
unreachable (503), too slow (504), a 5xx or an unreadable body (502), or
not configured in this deployment (503) — is answered with that upstream's
name and an error_id the log line carries; its own error text stays in the
log. A 4xx refusal from vertragd or accountingd on a write is relayed
with its status and body, because it names the rule the customer ran into (a
notice period, a contract state, an IBAN check). A record, document or
contract that does not exist, or belongs to another Marktlokation, is 404.
Invoices and documents are two different lists
/invoices is what billingd calculated, drafts held by the risk gate
included. /dokumente is what the customer was sent, byte for byte, with
delivery evidence; opening one records the read receipt a § 41f EnWG dispute
asks about. outputd refuses a document query that names neither a MaLo nor a
Kundennummer.
Dashboard
GET /api/v1/portal/{malo_id}/dashboard fetches from every configured upstream
concurrently and returns one object:
{
"malo_id": "51238696012",
"tenant": "9900357000004",
"kundentyp": "B2C",
"versorgung": { "lieferstatus": "Beliefert", "lf_mp_id": "9900357000004" },
"balance": { "balance_ct": -4500, "currency": "EUR" },
"last_invoice": [ { "id": "…", "total_brutto_eur": "126.14" } ],
"meter_summary": { "arbeitsmenge_kwh": "312.5", "sparte": "STROM" },
"vorauszahlung": { "betrag_ct": 8900, "naechste_faelligkeit": "2026-07-01" }
}
Self-service writes (§ 41 EnWG)
Notice periods live in vertragd
portald validates the date format only. Whether a lieferende or
wirksamkeit is reachable depends on Vertragsart, Haushaltskunde status (§ 3
Nr. 57 EnWG) and the reason — § 20 Abs. 1 StromGVV/GasGVV in the
Grundversorgung, § 41b Abs. 5 EnWG on a move, § 41 Abs. 5 Satz 4 EnWG after a
price change, § 309 Nr. 9 lit. c BGB on term length — and vertragd decides.
Its 422 naming the rule is relayed unchanged. Call GET /kuendigungsfrist
first to show the reachable dates.
SEPA mandates
PUT /sepa registers a mandate with accountingd, which validates the IBAN
(ISO 13616 mod-97) and the debtor address.
The caller supplies neither sequence_type (FRST before RCUR is
accountingd's to track) nor mandatsref (derived here with a random suffix,
35 characters, the SEPA MndtId limit, so a same-day IBAN correction gets a new
mandate).
debtor_address is optional: EPC153-22 v2.2 (30 September 2026) permits a
collection without a structured address and sets no end date for that, and
accountingd refuses only a half-filled one.
Configuration
# portald.toml
tenant = "9900357000004"
# Required — the authorization authority for every route.
vertragd_url = "http://vertragd:9780"
vertragd_api_key = "env:PORTALD_VERTRAGD_SERVICE_KEY" # sent as X-Api-Key
edmd_url = "http://edmd:8380"
billingd_url = "http://billingd:9280"
accountingd_url = "http://accountingd:9380"
einsd_url = "http://einsd:9180"
marktd_url = "http://marktd:8180"
outputd_url = "http://outputd:9880"
# Opaque service Bearer tokens; register each in the upstream's service keys.
# edmd_api_key = "env:PORTALD_EDMD_SERVICE_KEY"
# billingd_api_key = "env:PORTALD_BILLINGD_SERVICE_KEY"
# accountingd_api_key = "env:PORTALD_ACCOUNTINGD_SERVICE_KEY"
# einsd_api_key = "env:PORTALD_EINSD_SERVICE_KEY"
# marktd_api_key = "env:PORTALD_MARKTD_SERVICE_KEY"
# outputd_api_key = "env:PORTALD_OUTPUTD_SERVICE_KEY"
# MP-ID a self-service SEPA mandate is registered under. Defaults to `tenant`;
# must match accountingd's `lf_mp_id`.
# lf_mp_id = "9900357000004"
# Local development only: serve portal routes without resolving ownership.
# allow_insecure_no_auth = true
[http]
addr = "0.0.0.0:9480"
There is no oidc_issuer / oidc_audience: portald verifies no tokens.
Deployment
Stateless — no schema, no migrations. Deploy as many replicas as needed behind a load balancer.
# docker-compose.yml (excerpt)
portald:
image: ghcr.io/hupe1980/mako-portald:latest
ports: ["9480:9480"]
volumes:
- ./portald.toml:/etc/mako/portald.toml:ro
environment:
PORTALD_CONFIG: /etc/mako/portald.toml
PORTALD_VERTRAGD_SERVICE_KEY: "${PORTALD_VERTRAGD_SERVICE_KEY}"
No MCP server
portald serves no /mcp. An MCP tool takes a malo_id and carries no
customer token, so it cannot pass the vertragd ownership check every portal
route makes, and would read every customer in the tenant. Agents read the same
data from the service that owns it, each behind that service's Cedar policy:
| Datum | MCP tool |
|---|---|
| Invoices | billingd list_billing_records, get_billing_record |
| Balance, account statement, Abschlag | accountingd get_balance, list_ledger, get_vorauszahlung |
| Lastgang | edmd get_timeseries |
| EEG/KWKG plants | einsd list_plants, get_plant |
| Supply status | marktd get_versorgungsstatus |
agentd's portald-agent holds read grants on these services, none mutating.
Informatorisches Unbundling (§ 6a EnWG)
An LF-role service. It reads marktd only for VersorgungsStatus — the LF's
own supply records — never NB grid topology or NB billing data. The unbundled NB
services (netzbilanzd, processd's Sperrauftrag queue) are not reachable through it.
Related services
| Service | Role |
|---|---|
vertragd | Authorization authority; contracts, Tarifwechsel, Kündigung |
edmd | Meter data |
billingd | Invoices + XRechnung rendering |
accountingd | Account ledger + SEPA |
einsd | EEG/KWKG settlement |
marktd | Supply status + MaLo master data |
outputd | Issued documents + delivery evidence (the inbox) |